Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ba7799fe | |||
| 8bf6ac9e51 | |||
| 2ea9788ec7 | |||
| be729c29a1 | |||
| 584d7fd146 | |||
| 6c9a4414cb | |||
| 864a488ee0 | |||
| fb9724faed | |||
| 58aabb9bf0 | |||
| ae15025b1d | |||
| be8ce04334 | |||
| 4ac4f64f33 | |||
| 2d05da6709 | |||
| 1af9fba291 | |||
| 4442da31c3 | |||
| 0af6f7c971 | |||
| b74ce2da93 | |||
| fcff28ab60 | |||
| ea2f196ff7 | |||
| b0a1ff3c3f | |||
| a806532d25 | |||
| ca21ae7216 | |||
| 7d944cfa35 | |||
| dd6efd0c50 | |||
| fa3b20d1bc | |||
| 543325105c | |||
| 983f539268 | |||
| ee313675ca | |||
| 72c08f3689 | |||
| c73b131b83 | |||
| 2e6e7fc057 | |||
| a8a52f5fd0 | |||
| 746deca754 | |||
| 0614883a4f | |||
| 95e1728c6e | |||
| ab26704049 | |||
| 99d16190a8 | |||
| 5911cee01c | |||
| 7fdea90ded | |||
| 6aacbbab1c | |||
| abe1388f8c | |||
| a83e0840d7 | |||
| 77d8562916 | |||
| 66a802beb7 | |||
| 593b52096f | |||
| 3e0fed80ce | |||
| 3df7b2e672 | |||
| 1cb7a832f1 | |||
| 53f18ca6ee | |||
| 9477155bcf | |||
| ff9886f08b | |||
| 7cdb4d6698 | |||
| 9b315c11cd | |||
| 166e01c7c3 | |||
| c1ad8a0121 | |||
| b9d90fe7ec | |||
| b869602185 | |||
| 256d201bd2 | |||
| 727c92b695 | |||
| 70bd033e6c | |||
| c6da3932f3 | |||
| d9eff2df08 | |||
| 797e9c15f4 | |||
| db2f9206be | |||
| a0df66587b | |||
| bab8e47d63 | |||
| ae98d33edf | |||
| 4d212a8f8a | |||
| 680df7371a | |||
| 2b47380ac5 | |||
| 4815060839 | |||
| 26e557de8a | |||
| 683c9e02d8 | |||
| 43c35ddaf2 | |||
| 36c571b6bb | |||
| b7acc91f8c | |||
| b44d7ca5c3 | |||
| 5248e91a3d | |||
| 610277c8c7 | |||
| 52f4e95414 | |||
| 8cadd19253 | |||
| 8902af420e | |||
| 6542d1e055 | |||
| e1d7c318ab | |||
| 394989b281 | |||
| 209336b429 | |||
| bbcd992614 | |||
| a84fa5da61 | |||
| fdb636f98f | |||
| 91115447e8 | |||
| 0cfbb6911d | |||
| 968e8af8a3 | |||
| b658eeb8e9 | |||
| 99d0891413 | |||
| d252a746f0 | |||
| dae2a38e79 | |||
| b75e1867f6 | |||
| 6f74643227 | |||
| 9fe87a7003 | |||
| a3f787ddaf | |||
| 3f4a079c78 | |||
| 7e50c892ad | |||
| e98bfe193b | |||
| 59af6b2635 | |||
| f0bf450725 | |||
| 891990bb35 | |||
| 2da24e12ff | |||
| a9b6dddf70 | |||
| a57777e7cd | |||
| f036e0bc43 | |||
| 3e26431602 | |||
| 9d6aebbc0d | |||
| dae248590b | |||
| 37c83d4c12 | |||
| 12de8be0cf | |||
| 5a8b0ca79c | |||
| d6d03a7c3c | |||
| 55c408d86c | |||
| e1fb28e8fb | |||
| 8b075f7387 | |||
| 48186085d8 | |||
| d8e58b5f8c | |||
| 6ea4e5365e | |||
| 5fbd010b71 | |||
| da7560b918 | |||
| 4887093c3a | |||
| 09a9394c30 | |||
| eabceecaae | |||
| d3e5cbe18b | |||
| b8658712bb | |||
| 0bcaadceb4 | |||
| a62039e6bb | |||
| 9d84d6bf25 | |||
| 213b2a571e | |||
| e244bccb5c | |||
| d5d33c3ace | |||
| 47ef3f2a99 | |||
| b63dc9075f | |||
| 2180c27b16 | |||
| d203447eb3 | |||
| e3853071d6 | |||
| d9ba1cdf28 | |||
| daf87f895b | |||
| 53f84bfcc4 | |||
| 67444434b9 | |||
| fb94ce60ae | |||
| c1357cf4af | |||
| fe683900bb | |||
| 1b4a74c23d | |||
| 6229c81f6e | |||
| fb2eff5f23 | |||
| 90d17ed4b1 | |||
| 8bb87cd685 | |||
| 95311704fe | |||
| eabc1e7d3d | |||
| 89fa50a013 | |||
| 7659aee468 | |||
| bacfd4945d | |||
| 40f0529cbf | |||
| 6ec12010f3 |
@ -3,3 +3,4 @@
|
||||
|
||||
DATABASE_PASSWORD=your_password_here
|
||||
JWT_SECRET=generate-a-random-64-char-string-here
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
|
||||
28
README.md
28
README.md
@ -1,10 +1,16 @@
|
||||
> [!IMPORTANT]
|
||||
> **本项目已迁移至 [MetaZone Community Engine (MCE)](https://git.metazone.cc/MetaZone/mce)**
|
||||
>
|
||||
> MetaLab 已更名为 MCE 并继续开发。此仓库仅为历史存档,不再接受 Pull Request、Issue 或任何代码更新。
|
||||
>
|
||||
> 请前往 👉 **[mce](https://git.metazone.cc/MetaZone/mce)** 获取最新版本。
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go" alt="Go 1.26">
|
||||
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
|
||||
<img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31">
|
||||
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0">
|
||||
<img src="https://img.shields.io/badge/status-hiatus-red?style=flat-square" alt="status">
|
||||
</p>
|
||||
|
||||
<h1 align="center">MetaLab</h1>
|
||||
@ -17,17 +23,6 @@
|
||||
|
||||
MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间。
|
||||
|
||||
> **当前状态:开发已暂停**。
|
||||
|
||||
### ⚠️ 暂停说明
|
||||
|
||||
本项目自 2026 年 5 月起暂停开发。主要原因:
|
||||
|
||||
- **编辑器集成问题未解决**——作为技术社区的核心功能,尝试了多种富文本/Markdown 编辑器方案,在 SSR + 原生 JS 架构下均无法实现满意的集成效果
|
||||
- **优先保证社区上线**——经评估,社区早日上线运行的价值高于自建所有基础设施,决定先采用 [Flarum](https://flarum.org)(MIT 协议)作为社区平台
|
||||
|
||||
本仓库代码保留,后续可能继续开发。
|
||||
|
||||
**核心理念:**
|
||||
- 🎯 **专注技术**——代码、架构、逻辑与可验证的事实
|
||||
- 🤝 **平等交流**——没有特权,每个成员权利平等
|
||||
@ -43,7 +38,7 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
|
||||
| 数据库 | **PostgreSQL** | 16+ |
|
||||
| 认证 | **JWT** (HS256) | v5 |
|
||||
| 密码加密 | **bcrypt** (cost=12) | — |
|
||||
| 配置 | **Viper** | 1.21 |
|
||||
| 编辑器 | **Vditor** | 3.11 |
|
||||
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
|
||||
| 架构 | 分层六边形架构 | — |
|
||||
|
||||
@ -61,12 +56,17 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
|
||||
- ✅ **用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切)
|
||||
- ✅ **头像处理**——JPEG/PNG/WebP 上传,方形裁切,512×512 WebP 编码 ≤100KB
|
||||
- ✅ **消息中心**——审核通知推送,SSR 消息页面,单条/全部标为已读,未读数角标(1/66/99+)
|
||||
- ✅ **帖子系统**——Markdown 发布/编辑/删除,Vditor 所见即所得编辑器,图片粘贴/拖拽上传
|
||||
- ✅ **代码高亮**——highlight.js 60+ 主题,支持 30+ 编程语言
|
||||
- ✅ **Shortcode 卡片**——`[zone:event:ID]` 活动/游戏/投票/资源等可扩展卡片
|
||||
- ✅ **草稿自动保存**——localStorage 草稿,每 2 秒自动保存
|
||||
- ✅ **审核工作流**——帖子 draft → pending → approved/rejected,管理员审核面板
|
||||
- ✅ **多主题支持**——可切换的前端主题系统
|
||||
|
||||
## 计划中
|
||||
|
||||
- 🔲 帖子系统(CRUD + Markdown)
|
||||
- 🔲 话题/分类导航
|
||||
- 🔲 评论系统
|
||||
- 🔲 邮箱验证
|
||||
- 🔲 全文搜索
|
||||
- 🔲 回收站(用户数据彻底清理)
|
||||
|
||||
@ -3,12 +3,14 @@ package main
|
||||
import (
|
||||
"log"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/router"
|
||||
"metazone.cc/metalab/internal/theme"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -25,10 +27,15 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil {
|
||||
log.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化公户行(id=1, balance=0,如已存在则忽略)
|
||||
if err := db.FirstOrCreate(&model.CommunityFund{ID: 1, Balance: 0}).Error; err != nil {
|
||||
log.Fatalf("初始化公户失败: %v", err)
|
||||
}
|
||||
|
||||
// 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
|
||||
// 支持软删除用户邮箱复用
|
||||
migrations := []string{
|
||||
@ -48,6 +55,9 @@ func main() {
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
|
||||
common.ComputeAssetHashes("templates/MetaLab-2026")
|
||||
|
||||
// 模板加载(前端主题 + 管理后台)
|
||||
tmpl, err := theme.LoadTemplates(
|
||||
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
|
||||
@ -65,6 +75,20 @@ func main() {
|
||||
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||
r.Static("/uploads/posts", "./storage/uploads/posts")
|
||||
|
||||
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
|
||||
var redisClient *redis.Client
|
||||
if cfg.Redis.Enabled {
|
||||
redisClient, err = common.NewRedisClient(cfg.Redis)
|
||||
if err != nil {
|
||||
log.Printf("Redis 不可达,启动后首次请求将自动降级,后续恢复自动切回: %v", err)
|
||||
// 不置 nil:go-redis 自带重连,FallbackStore 负责健康检测与恢复
|
||||
} else {
|
||||
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
|
||||
}
|
||||
} else {
|
||||
log.Println("Redis 未启用,使用内存存储")
|
||||
}
|
||||
|
||||
// 站点设置管理器(DB 持久化 + 内存缓存)
|
||||
siteSettings, err := config.NewSiteSettings(db)
|
||||
if err != nil {
|
||||
@ -72,7 +96,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 路由注册
|
||||
router.Setup(r, db, cfg, siteSettings)
|
||||
router.Setup(r, db, cfg, siteSettings, redisClient)
|
||||
|
||||
// 启动
|
||||
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
||||
|
||||
19
config.yaml
19
config.yaml
@ -13,11 +13,20 @@ database:
|
||||
dbname: metalab_dev
|
||||
sslmode: disable
|
||||
|
||||
jwt:
|
||||
secret: "" # 生产环境通过 JWT_SECRET 环境变量或 .env 注入
|
||||
access_expire: 15 # 分钟
|
||||
refresh_expire: 168 # 小时 (7 天)
|
||||
remember_expire: 720 # 小时 (30 天)
|
||||
# 会话配置(服务端 Session,滑动窗口续期)
|
||||
# 每次请求自动续期,解决"记住我"掉线问题
|
||||
session:
|
||||
idle_timeout: 120 # 不记住我:空闲超时(分钟),120 = 2 小时
|
||||
remember_timeout: 43200 # 记住我:空闲超时(分钟),默认 43200 = 30 天
|
||||
cleanup_interval: 300 # 后台清理过期会话间隔(秒),默认 300,仅内存模式使用
|
||||
|
||||
# Redis 配置(可选,enabled: false 时使用内存存储)
|
||||
redis:
|
||||
enabled: true # 是否启用 Redis 存储会话
|
||||
host: 127.0.0.1
|
||||
port: 6379
|
||||
password: "" # 敏感值由 .env 注入
|
||||
db: 0
|
||||
|
||||
bcrypt:
|
||||
cost: 12
|
||||
|
||||
426
docs/audit-report-2025-05-31.md
Normal file
426
docs/audit-report-2025-05-31.md
Normal file
@ -0,0 +1,426 @@
|
||||
# MetaLab 项目全量代码审计报告
|
||||
|
||||
**审计日期**: 2025-05-31
|
||||
**审计范围**: `lab.metazone.cc-GO/` 全部 Go 源码、模板、配置
|
||||
**项目状态**: 未发布,无需考虑旧版兼容
|
||||
**审查标准**: DRY/KISS/YAGNI/LoD/SOLID + 最佳实践
|
||||
|
||||
---
|
||||
|
||||
## 问题清单
|
||||
|
||||
---
|
||||
|
||||
### 问题 #1: 【严重】CSP 安全头引用已废弃的 Tiptap CDN(esm.sh)
|
||||
|
||||
**类型**: 死代码 / 残留配置
|
||||
**位置**: `internal/middleware/security.go:12-21`
|
||||
**违反原则**: KISS(引用了不存在的依赖)
|
||||
|
||||
```go
|
||||
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js)
|
||||
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
||||
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
||||
"connect-src 'self' https://esm.sh"
|
||||
```
|
||||
|
||||
**问题**: 项目已迁移到 Vditor 编辑器,但 CSP 头仍保留 Tiptap 时代的 esm.sh CDN 白名单。三个指令(script-src、style-src、connect-src)都包含未使用的 `https://esm.sh`。
|
||||
|
||||
**风险**:
|
||||
- 扩大了不必要的 CSP 白名单,引入额外信任域
|
||||
- connect-src 允许到 esm.sh 的连接,可能泄露页面信息
|
||||
|
||||
**建议**: 移除所有 `https://esm.sh` 引用,highlight.js 主题已本地化为 73 个静态文件(`static/vditor/dist/js/highlight.js/styles/`),CSP 可完全收紧为 `'self'`。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #2: 【严重】Login() 中封禁状态检查缺少防时序攻击保护
|
||||
|
||||
**类型**: 安全缺陷
|
||||
**位置**: `internal/service/auth_service.go:151-154`
|
||||
**违反原则**: 最佳安全实践
|
||||
|
||||
```go
|
||||
// 封禁
|
||||
if user.Status == model.StatusBanned {
|
||||
return nil, common.ErrUserBanned // ← 没有 bcrypt dummy hash 比对
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 其他分支(维护模式、用户不存在、密码错误、StatusLocked)都有 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)` 防时序攻击,唯独 StatusBanned 分支缺失。攻击者可以通过响应时间差异判断被封禁的账号是否存在。
|
||||
|
||||
**建议**: 在返回 `ErrUserBanned` 前增加 dummy hash 比对:
|
||||
```go
|
||||
if user.Status == model.StatusBanned {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return nil, common.ErrUserBanned
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 #3: 【严重】自定义 constantTimeEq 不如 crypto/subtle 安全
|
||||
|
||||
**类型**: 安全缺陷 / 最佳实践
|
||||
**位置**: `internal/middleware/csrf_token.go:44-53`(被 `csrf.go:47` 调用)
|
||||
**违反原则**: 安全最佳实践
|
||||
|
||||
```go
|
||||
// constantTimeEq 恒定时间字符串比较(防时序攻击)
|
||||
func constantTimeEq(a, b string) bool {
|
||||
if len(a) != len(b) { // ← 长度不等时提前返回,泄露长度信息
|
||||
return false
|
||||
}
|
||||
var result byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
result |= a[i] ^ b[i]
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:
|
||||
1. `constantTimeEq` 在同包内被 `csrf.go:47` 调用,**非死代码**
|
||||
2. 但其手写实现存在两个安全隐患:
|
||||
- **长度提前返回泄露信息**:`len(a) != len(b)` 时立即返回 false,攻击者可通过响应时间判断 CSRF token 长度
|
||||
- **无编译器优化防护**:`crypto/subtle.ConstantTimeCompare` 内部使用了特殊的编译器屏障防止被优化,手写版本可能被 Go 编译器优化掉 XOR 结果检查
|
||||
3. 函数定义在 `csrf_token.go`(token 生成文件)而非 `csrf.go`(校验文件),逻辑归属不当
|
||||
|
||||
**建议**: 删除 `constantTimeEq`,改用 `crypto/subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1`。这也是 Go 官方推荐的做法。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #4: 【高】Admin 控制器锁定操作消息显示错误
|
||||
|
||||
**类型**: Bug
|
||||
**位置**: `internal/controller/admin/admin_controller.go:93-96`
|
||||
**违反原则**: 无(纯 bug)
|
||||
|
||||
```go
|
||||
action := "封禁"
|
||||
if req.Status == model.StatusActive {
|
||||
action = "解封"
|
||||
} else if req.Status == model.StatusLocked {
|
||||
action = "已删除" // ← BUG: 应该是 "已锁定"
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 当管理员执行锁定操作时,成功提示消息显示"已删除成功",而不是"已锁定成功"。Locked 与 Deleted 是两个完全不同的状态。
|
||||
|
||||
**建议**: 改为 `action = "已锁定"`
|
||||
|
||||
---
|
||||
|
||||
### 问题 #5: 【高】Login() 中密码验证方式不一致
|
||||
|
||||
**类型**: 代码一致性问题
|
||||
**位置**: `internal/service/auth_service.go:138,152`
|
||||
**违反原则**: KISS(同一逻辑用了两种实现)
|
||||
|
||||
```go
|
||||
// StatusDeleted 分支:
|
||||
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
|
||||
|
||||
// StatusBanned 之后的主路径:
|
||||
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
|
||||
```
|
||||
|
||||
而 `CheckPassword` 内部只是封装了单行:
|
||||
```go
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 虽然功能上没有 bug,但在同一函数中既有 `common.CheckPassword()` 调用,也有其他分支使用 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)`(直接调用 bcrypt)。风格不统一,且 `CheckPassword` 的封装价值极低(仅包装一行标准库调用,不如直接使用 bcrypt 调用更直观)。
|
||||
|
||||
**建议**:
|
||||
- 选项A: 删除 `common.CheckPassword`,统一使用 `bcrypt.CompareHashAndPassword`
|
||||
- 选项B: 在 `CheckPassword` 中增加防时序的一致性包装,统一入口
|
||||
|
||||
---
|
||||
|
||||
### 问题 #6: 【中】大量空模板目录(YAGNI 违规)
|
||||
|
||||
**类型**: YAGNI 违规
|
||||
**位置**:
|
||||
- `templates/MetaLab-2026/html/post/`(空)
|
||||
- `templates/MetaLab-2026/html/comment/`(空)
|
||||
- `templates/MetaLab-2026/html/partials/`(空)
|
||||
- `templates/MetaLab-2026/html/search/`(空)
|
||||
- `templates/MetaLab-2026/html/error/`(空)
|
||||
- `templates/MetaLab-2026/html/admin/`(空)
|
||||
- `templates/system/email/`(空)
|
||||
|
||||
**问题**: 7 个空目录,标注为"预留"。项目尚未发布,这些目录的创建时机应该和实际功能开发同步,而非提前占位。
|
||||
|
||||
**建议**: 删除所有空目录。需要时随功能一起创建。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #7: 【中】Shortcode 预留类型(poll/resource)无后端实现
|
||||
|
||||
**类型**: YAGNI 违规
|
||||
**位置**: `internal/model/shortcode.go:37-38`, `internal/service/shortcode_service.go:126-137`
|
||||
**违反原则**: YAGNI
|
||||
|
||||
```go
|
||||
ShortcodePoll ShortcodeType = "poll" // ※预留(后端API未实现)
|
||||
ShortcodeResource ShortcodeType = "resource" // ※预留(后端API未实现)
|
||||
```
|
||||
|
||||
**问题**: poll 和 resource 两个 shortcode 类型的后端 API 未实现,前端也未实现(shortcode.js 中可能也未实现对应渲染),但代码中已注册了完整的解析和占位 HTML 生成逻辑。用户实际上可以使用 `[zone:poll:xxx]` 语法,但会得到一个永远"加载中..."的卡片。
|
||||
|
||||
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。前端渲染逻辑将随 API 同步实现。
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 问题 #8: 【中】redis_store.go 全注释的"预留实现"
|
||||
|
||||
**类型**: YAGNI / 死代码
|
||||
**位置**: `internal/session/redis_store.go`
|
||||
**违反原则**: YAGNI
|
||||
|
||||
**问题**: 整个文件是注释掉的代码,没有实际可执行逻辑。如果未来需要 Redis 支持,到时再创建即可。
|
||||
|
||||
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。Redis 支持将在后续迭代中实现。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #9: 【中】tokenCtrl 是 authCtrl 的无意义别名
|
||||
|
||||
**类型**: 不必要的字段重复
|
||||
**位置**: `internal/router/deps_core.go:30`, `internal/router/deps_extra.go:54`
|
||||
**违反原则**: KISS
|
||||
|
||||
```go
|
||||
// deps_core.go
|
||||
type dependencies struct {
|
||||
// ...
|
||||
tokenCtrl *controller.AuthController // ← 与 authCtrl 类型完全相同
|
||||
}
|
||||
|
||||
// deps_extra.go
|
||||
return &dependencies{
|
||||
// ...
|
||||
tokenCtrl: authCtrl, // ← 赋的是同一个对象
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: `tokenCtrl` 和 `authCtrl` 指向同一个 `*controller.AuthController` 实例,`tokenCtrl` 仅在 `api.go` 的路由中使用(`d.tokenCtrl.CheckEmail/Register/Login/...`)。这个别名不带来任何好处,反而增加理解成本。
|
||||
|
||||
**建议**: 删除 `tokenCtrl` 字段,`api.go` 中直接使用 `d.authCtrl`。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #10: 【中】RateLimiter.check() 存在竞态条件
|
||||
|
||||
**类型**: 并发缺陷
|
||||
**位置**: `internal/middleware/ratelimit_core.go:34-81`
|
||||
|
||||
```go
|
||||
func (rl *RateLimiter) check(...) (RateLimitResult, func()) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
// ... 读取 state ...
|
||||
|
||||
recordFail := func() {
|
||||
rl.mu.Lock() // ← 重新获取锁
|
||||
defer rl.mu.Unlock()
|
||||
// ... 修改 state ...
|
||||
}
|
||||
return RateLimitResult{Blocked: false}, recordFail
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: `check()` 持锁检查后释放锁,返回的 `recordFail` 闭包在**锁外**执行,重新获取锁后再修改状态。
|
||||
|
||||
**修复方案**: 将 `check()` + `recordFail` 闭包模式重构为 `try()` 原子操作模式——在持锁状态下一次性完成检查+递增,消除竞态窗口。API 改为 `AllowAccount() RateLimitResult` / `AllowIP() RateLimitResult`(不再返回闭包)。调用方在失败时不再需要显式调用 `recordFail()`,成功时仍调用 `Clear()` 清除计数。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #11: 【低】audit_service.go review() 注释编号跳跃
|
||||
|
||||
**类型**: 文档瑕疵
|
||||
**位置**: `internal/service/audit_service.go:152-166`
|
||||
|
||||
```go
|
||||
// 3. 查审核人信息
|
||||
reviewer, err := s.userRepo.FindByID(reviewerID)
|
||||
// ...
|
||||
|
||||
// 5. 标记审核结果 ← 跳过了 4
|
||||
submission.ReviewedBy = &reviewerID
|
||||
```
|
||||
|
||||
**问题**: 注释编号从"3."直接跳到"5.",缺少"4."。
|
||||
|
||||
**建议**: 修正编号为连续递增。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #12: 【低】编译产物未纳入 .gitignore(误报,实际不存在)
|
||||
|
||||
**类型**: 仓库整洁性
|
||||
**位置**: `server`(根目录), `cmd/server/server`
|
||||
**状态**: 误报。经核实,`.gitignore` 已配置 `server` 规则且从未被 git 跟踪,`git rm --cached` 实际为空操作。此项已从修复表中移除。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #13: 【低】项目零测试覆盖
|
||||
|
||||
**类型**: 质量保障缺失
|
||||
**位置**: 整个项目(`*_test.go` 搜索结果: 0)
|
||||
|
||||
**问题**: 项目没有任何单元测试或集成测试。对于包含认证、权限、审核、数据持久化等复杂逻辑的系统,零测试意味着每次重构和修改都有回归风险。
|
||||
|
||||
**建议**: 至少为核心模块添加测试:
|
||||
1. `model/user.go` - HasMinRole/CanOperateRole(纯函数,易测)
|
||||
2. `service/auth_service.go` - 注册/登录/密码验证逻辑
|
||||
3. `service/admin_service.go` - checkAndOperate 权限矩阵
|
||||
4. `middleware/ratelimit_core.go` - 限流逻辑
|
||||
|
||||
---
|
||||
|
||||
### 问题 #14: 【低】全项目使用 log.Printf 无结构化日志
|
||||
|
||||
**类型**: 可维护性 / 可观测性
|
||||
**位置**: 10 个文件,约 17 处 `log.Printf` 调用
|
||||
|
||||
**问题**: 项目大量使用标准库 `log.Printf`,无日志级别、无结构化字段、无上下文追踪。在生产环境中排查问题困难,无法按级别过滤日志。
|
||||
|
||||
**建议**: 引入轻量结构化日志库(如 `slog`,Go 1.21+ 标准库),按级别区分 Info/Warn/Error,关键路径添加 trace/request ID。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #15: 【低】err != nil 返回时上下文信息丢失
|
||||
|
||||
**类型**: 可调试性
|
||||
**位置**: 多处,例如 `internal/repository/user_repo.go`
|
||||
|
||||
```go
|
||||
func (r *UserRepo) Create(user *model.User) error {
|
||||
return r.db.Create(user).Error // ← 无上下文
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 数据库操作失败时,调用方只知道"出错了",无法快速定位是哪个操作、哪个实体、哪个 ID 导致的失败。
|
||||
|
||||
**建议**: 使用 `fmt.Errorf("创建用户失败: %w", err)` 包装错误,在保持错误链的同时添加操作上下文。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #16: 【低】common.CheckPassword 封装价值极低
|
||||
|
||||
**类型**: KISS 违规
|
||||
**位置**: `internal/common/crypto.go:12-15`
|
||||
|
||||
```go
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 仅包装一行标准库调用,无额外逻辑。与同一文件中封装了 bcrypt 成本参数的 `HashPassword` 不同,`CheckPassword` 没有提供抽象价值。反而因为隐藏了 `bcrypt.CompareHashAndPassword` 的调用,在需要 `dummyHash` 比对时(如 auth_service.go 的时序攻击防护)不得不绕过它直接调用 bcrypt。
|
||||
|
||||
**建议**:
|
||||
- 如果保留 `CheckPassword`,将 dummy hash 比对也内置进去
|
||||
- 或者删除此函数,直接在各处显式调用 `bcrypt.CompareHashAndPassword`
|
||||
|
||||
---
|
||||
|
||||
### 问题 #17: 【低】deps_core.go 中文注释错别字
|
||||
|
||||
**类型**: 文档瑕疵
|
||||
**位置**: `internal/router/deps_core.go:45`
|
||||
|
||||
```go
|
||||
// 启动后台过清理 goroutine
|
||||
```
|
||||
|
||||
**问题**: "过清理"应为"过期清理",少了一个"期"字。
|
||||
|
||||
**建议**: 修正为 `启动后台过期清理 goroutine`
|
||||
|
||||
---
|
||||
|
||||
## 汇总统计
|
||||
|
||||
| 严重程度 | 数量 | 问题编号 |
|
||||
|---------|------|---------|
|
||||
| 严重 | 3 | #1, #2, #3 |
|
||||
| 高 | 2 | #4, #5 |
|
||||
| 中 | 5 | #6, #7, #8, #9, #10 |
|
||||
| 低 | 7 | #11, #12, #13, #14, #15, #16, #17 |
|
||||
|
||||
**总计: 17 个问题**
|
||||
|
||||
### 按原则分类
|
||||
|
||||
| 原则 | 问题编号 |
|
||||
|------------|---------|
|
||||
| 安全 | #1, #2, #3 |
|
||||
| YAGNI | #6, #7, #8 |
|
||||
| KISS | #5, #9, #16 |
|
||||
| Bug | #4, #10 |
|
||||
| 质量/可维护性 | #12, #13, #14, #15 |
|
||||
| 文档 | #11, #17 |
|
||||
|
||||
---
|
||||
|
||||
## 整体评价
|
||||
|
||||
项目的分层架构设计合理,严格遵循单向依赖(router→controller→service→repository→model),接口隔离原则(ISP)执行到位,每层都通过最小接口依赖下层。依赖注入清晰,无循环依赖。
|
||||
|
||||
主要问题集中在三个方面:
|
||||
1. **安全防护需加强** - CSP 配置残留、时序攻击防护不完整
|
||||
2. **代码清理不及时** - 存在死代码、预留目录、未使用函数
|
||||
3. **工程基础设施薄弱** - 零测试、无结构化日志、错误上下文丢失
|
||||
|
||||
建议优先处理严重级别问题(#1~#3),然后按批次逐步处理其余问题。
|
||||
|
||||
---
|
||||
|
||||
## 修复记录
|
||||
|
||||
**修复日期**: 2025-05-31
|
||||
**执行方式**: 全量修复(commit: `fix: 审计问题全量修复(安全/YAGNI/Bug/代码质量)`)
|
||||
|
||||
### 已修复(13 项)
|
||||
|
||||
| # | 修复内容 | 变更文件 |
|
||||
|---|---------|---------|
|
||||
| 1 | 移除 CSP 中 esm.sh (Tiptap 残留) 和 cdnjs.cloudflare.com(主题已本地化),CSP 全面收紧为 'self' | `middleware/security.go` |
|
||||
| 2 | StatusBanned 分支增加 dummy hash 防时序攻击 | `service/auth_service.go` |
|
||||
| 3 | 删除手写 constantTimeEq,改用 `crypto/subtle.ConstantTimeCompare` | `middleware/csrf_token.go`, `middleware/csrf.go` |
|
||||
| 4 | 锁定操作消息修正"已删除"→"已锁定" | `controller/admin/admin_controller.go` |
|
||||
| 5 | 删除 common.CheckPassword 薄封装,统一改用 bcrypt.CompareHashAndPassword | `common/crypto.go`, `service/auth_service.go` |
|
||||
| 6 | 删除 12 个空预留目录(含第二轮追加 5 个) | `templates/MetaLab-2026/html/{post,comment,partials,search,error,admin,topic,user}`, `templates/{system,system/email}`, `templates/MetaLab-2026/static/{img,vendor}` |
|
||||
| 9 | 删除 tokenCtrl 别名字段,统一使用 authCtrl | `router/deps_core.go`, `router/deps_extra.go`, `router/api.go` |
|
||||
| 10 | 将 check()+recordFail 闭包重构为 try() 原子操作,消除竞态 | `middleware/ratelimit_core.go`, `middleware/ratelimit.go`, `middleware/ratelimit_cleanup.go`, `controller/auth_api_login.go`, `controller/auth_api_register.go` |
|
||||
| 11 | 修正 review() 注释编号 3→5→4 | `service/audit_service.go` |
|
||||
| 17 | 修正"过清理"→"过期清理" | `router/deps_core.go` |
|
||||
|
||||
### 已回滚(误删,已排期开发,保留)
|
||||
|
||||
| # | 回滚内容 | 变更文件 |
|
||||
|---|---------|---------|
|
||||
| 7 | 恢复 poll/resource shortcode 类型(误删) | `model/shortcode.go`, `service/shortcode_service.go` |
|
||||
| 8 | 恢复 redis_store.go 预留实现(误删) | `session/redis_store.go` |
|
||||
|
||||
### 误报(实际不存在)
|
||||
|
||||
| # | 说明 |
|
||||
|---|------|
|
||||
| 12 | 编译产物从未被 git 跟踪,`.gitignore` 已生效,`git rm --cached` 为空操作 |
|
||||
|
||||
### 暂缓修复(3 项)
|
||||
|
||||
| # | 暂缓原因 | 后续计划 |
|
||||
|---|---------|---------|
|
||||
| 13 | 零测试 — 需要建立测试框架、mock 策略,工作量大 | 核心模块优先:hasMinRole、checkAndOperate、RateLimiter |
|
||||
| 14 | 无结构化日志 — 需评估 slog vs zap,全量替换 log.Printf | Go 1.21+ 使用标准库 slog 渐进替换 |
|
||||
| 15 | 错误上下文丢失 — 涉及全部 repository 层,工作量大 | 按文件逐批添加 `fmt.Errorf("...: %w", err)` |
|
||||
27
docs/migrations/001_uid_to_id.sql
Normal file
27
docs/migrations/001_uid_to_id.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- Migration: 重命名 BaseModel 使用表的主键列 uid → id
|
||||
-- 涉及表: users, audit_submissions, notifications, user_checkins, user_tasks
|
||||
-- 执行前请备份数据库
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- users 表主键及序列重命名
|
||||
ALTER TABLE users RENAME COLUMN uid TO id;
|
||||
ALTER SEQUENCE IF EXISTS users_uid_seq RENAME TO users_id_seq;
|
||||
|
||||
-- audit_submissions 表主键及序列重命名
|
||||
ALTER TABLE audit_submissions RENAME COLUMN uid TO id;
|
||||
ALTER SEQUENCE IF EXISTS audit_submissions_uid_seq RENAME TO audit_submissions_id_seq;
|
||||
|
||||
-- notifications 表主键及序列重命名
|
||||
ALTER TABLE notifications RENAME COLUMN uid TO id;
|
||||
ALTER SEQUENCE IF EXISTS notifications_uid_seq RENAME TO notifications_id_seq;
|
||||
|
||||
-- user_checkins 表主键及序列重命名
|
||||
ALTER TABLE user_checkins RENAME COLUMN uid TO id;
|
||||
ALTER SEQUENCE IF EXISTS user_checkins_uid_seq RENAME TO user_checkins_id_seq;
|
||||
|
||||
-- user_tasks 表主键及序列重命名
|
||||
ALTER TABLE user_tasks RENAME COLUMN uid TO id;
|
||||
ALTER SEQUENCE IF EXISTS user_tasks_uid_seq RENAME TO user_tasks_id_seq;
|
||||
|
||||
COMMIT;
|
||||
231
docs/post-system-audit.md
Normal file
231
docs/post-system-audit.md
Normal file
@ -0,0 +1,231 @@
|
||||
# 帖子系统代码审计报告
|
||||
|
||||
> 审计日期:2026-05-30
|
||||
> 审计范围:`internal/` 下所有 Post 相关文件(router / controller / service / repository / model)
|
||||
> 审计标准:DRY / KISS / YAGNI / LoD / SOLID + 分层架构规范
|
||||
|
||||
---
|
||||
|
||||
## 一、架构合规性总览
|
||||
|
||||
| 检查项 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 分层依赖方向 | ✅ | router→controller→service→repo→model,严格单向 |
|
||||
| Controller 接口文件 | ✅ | `controller/interfaces.go` + `admin/interfaces.go` |
|
||||
| Service Repository 接口 | ✅ | `service/repository.go`,8 个方法,无冗余 |
|
||||
| ISP 接口隔离 | ✅ | 前台 `postUseCase` 与后台 `adminPostUseCase` 分离 |
|
||||
| DIP 依赖倒置 | ✅ | Controller→接口, Service→接口 |
|
||||
| 依赖注入 | ✅ | 全部构造函数注入,无硬编码 |
|
||||
| KISS | ✅ | 无过度设计 |
|
||||
| YAGNI | ✅ | 无超前功能 |
|
||||
| LoD | ✅ | 无跨层直接依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 二、问题清单
|
||||
|
||||
### 🔴 中等问题(4 个)
|
||||
|
||||
#### 问题 1:Repository 方法代码重复(~70%)
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/repository/post_repo.go` |
|
||||
| **位置** | `FindPageable`(行 49-73)vs `FindAdminPageable`(行 76-102) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | 两个方法的核心查询逻辑(Table + Select + Joins + keyword LIKE + Count + Offset/Limit + ORDER BY)几乎完全一致,唯一区别是 `FindPageable` 固定过滤 `status = 'approved'`,`FindAdminPageable` 支持可选的 status 参数。两段代码约 70% 重复。 |
|
||||
|
||||
**现状:**
|
||||
```go
|
||||
// FindPageable (行 49-73)
|
||||
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||
query := r.db.Table("posts").
|
||||
Select("posts.*, users.username as author_name").
|
||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||
Where("posts.deleted_at IS NULL").
|
||||
Where("posts.status = ?", model.PostStatusApproved) // ← 唯一差异
|
||||
|
||||
if keyword != "" { /* LIKE 过滤 */ }
|
||||
// ... Count + Offset/Limit + Find
|
||||
}
|
||||
|
||||
// FindAdminPageable (行 76-102)
|
||||
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||
query := r.db.Table("posts").
|
||||
Select("posts.*, users.username as author_name").
|
||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||
Where("posts.deleted_at IS NULL")
|
||||
// ← 无硬编码 status,由参数控制
|
||||
|
||||
if keyword != "" { /* LIKE 过滤 */ }
|
||||
if status != "" { query = query.Where("posts.status = ?", status) }
|
||||
// ... Count + Offset/Limit + Find
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 提取私有方法 `findPageableCommon`,两个公开方法调用它并传入各自的 WHERE 条件。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 2:Service 方法重复
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/service/post_service.go` |
|
||||
| **位置** | `List`(行 148-156)vs `ListAdmin`(行 159-167) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | 两个方法的 nil 检查 + 分页调用模式完全一致,唯一区别是调用的 repo 方法不同。 |
|
||||
|
||||
**现状:**
|
||||
```go
|
||||
// List (行 148-156)
|
||||
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
posts, total, err := s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
|
||||
if posts == nil { posts = []model.Post{} }
|
||||
return posts, total, err
|
||||
}
|
||||
|
||||
// ListAdmin (行 159-167)
|
||||
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
posts, total, err := s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
|
||||
if posts == nil { posts = []model.Post{} }
|
||||
return posts, total, err
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 提取公共的 Pagination 创建 + nil 检查逻辑。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 3:Controller 权限检查重复 6 处
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/post_controller.go` |
|
||||
| **位置** | `ShowPage`(行 82-90)、`EditPage`(行 144-150)、`Update`(行 202-211)、`Delete`(行 239-248)、`Submit`(行 272-281)、`ShowAPI`(行 333-339) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | 以下模式在 6 个方法中逐字重复: |
|
||||
|
||||
```go
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
if !model.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 提取私有方法 `getPostAndCheckAccess(id, c)` 返回 `(*model.Post, bool)`。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 4:Model 层职责过重
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/model/post.go` |
|
||||
| **位置** | 行 48-83 |
|
||||
| **违反原则** | SRP(单一职责) |
|
||||
| **描述** | 一个文件混合了多种职责: |
|
||||
|
||||
| 内容 | 类型 | 应在位置 |
|
||||
|------|------|---------|
|
||||
| `Post` struct | 数据模型 | ✅ Model 层 |
|
||||
| `PostStatusDraft` 等常量 | 状态常量 | ✅ Model 层 |
|
||||
| `PostStatusDisplayNames` | 视图映射 | ❌ 应移到 View/Controller 层 |
|
||||
| `PostListResult` | DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `PostCreateRequest` / `PostUpdateRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `PostRejectRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `PostListQuery` | 查询 DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `IsPostAccessible` | 业务权限逻辑 | ❌ 应移到 Service 层 |
|
||||
|
||||
**建议修复:** DTO 结构体移到 `model/dto.go`,`PostStatusDisplayNames` 移到 common 或 controller,`IsPostAccessible` 移到 service 层或独立权限模块。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 轻微问题(3 个)
|
||||
|
||||
#### 问题 5:工具函数位置不当
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/post_controller.go` |
|
||||
| **位置** | `saveUploadedFile`(行 410-430) |
|
||||
| **违反原则** | SRP / LoD |
|
||||
| **描述** | `saveUploadedFile` 是通用文件 I/O 工具函数(创建目录 + 32KB buffer 循环写入),与 HTTP 处理无关,不依赖 controller 的任何字段。放在 controller 文件中职责不匹配。 |
|
||||
|
||||
**建议修复:** 移到 `internal/common/` 或新建 `internal/util/` 包。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 6:Controller 层分页重复初始化
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/post_controller.go` |
|
||||
| **位置** | `ListPage`(行 47-49)和 `ListAPI`(行 307-308) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | Controller 层为模板渲染再次创建 `Pagination` 对象并调用 `DefaultPagination()`,而 Service 层 `List()` / `ListAdmin()` 内部已经做过一次分页参数校验。Controller 层可以信任 Service 返回的 total 直接计算。 |
|
||||
|
||||
```go
|
||||
// controller 层重复的:
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
// ... 然后调用 service.List(),service 里又做了一遍
|
||||
```
|
||||
|
||||
**建议修复:** Controller 层直接用 `common.Pagination.PageCount(total, pageSize)` 计算总页数,不再重复调用 `DefaultPagination()`。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 7:Admin Restore 错误分类缺失
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/admin/admin_post_controller.go` |
|
||||
| **位置** | `Restore`(行 153-155) |
|
||||
| **违反原则** | 一致性 |
|
||||
| **描述** | 同文件内其他方法(`Approve`/`Reject`/`Unlock`/`Lock`)都对 `ErrPostNotFound` 做 `errors.Is` 精确匹配返回 400,但 `Restore` 没有,所有错误统一返回 500。 |
|
||||
|
||||
```go
|
||||
// Restore — 缺少错误分类
|
||||
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||
// ...
|
||||
if err := ctrl.postService.Restore(id); err != nil {
|
||||
// ← 此处应匹配 ErrPostNotFound,返回 400 而非 500
|
||||
common.Error(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 添加 `errors.Is(err, common.ErrPostNotFound)` 判断,返回 400。
|
||||
|
||||
---
|
||||
|
||||
## 三、亮点
|
||||
|
||||
1. **状态机设计清晰**:Post 状态流转(draft → pending → approved/rejected → locked)每个转换有明确前置条件检查和专用错误哨兵
|
||||
2. **接口设计优秀**:`postUseCase` vs `adminPostUseCase` 的分离体现良好的关注点分离
|
||||
3. **错误哨兵统一管理**:`common/errors.go` 集中定义所有 Post 相关错误
|
||||
4. **审核开关灵活**:通过 `SiteSettings.IsAuditEnabled()` 运行时控制
|
||||
5. **Shortcode 扩展性好**:新增类型只需添加常量和 case 分支
|
||||
6. **软删除 + 恢复**:完善的软删除和恢复机制
|
||||
|
||||
---
|
||||
|
||||
## 四、优先级建议
|
||||
|
||||
| 优先级 | 问题编号 | 原因 |
|
||||
|--------|---------|------|
|
||||
| P0 | — | 无阻塞性问题 |
|
||||
| P1 | 1, 3 | Repository/Controller 重复影响维护成本 |
|
||||
| P2 | 2, 4 | Service 重复 + Model 职责拆分 |
|
||||
| P3 | 5, 6, 7 | 轻微优化项 |
|
||||
319
docs/vditor-migration-plan.md
Normal file
319
docs/vditor-migration-plan.md
Normal file
@ -0,0 +1,319 @@
|
||||
# Vditor 整合 + MD 存储迁移方案
|
||||
|
||||
> **状态:已实施 ✓** `2026-05-30`
|
||||
|
||||
## 决策与结果
|
||||
|
||||
| 决策 | 结论 | 结果 |
|
||||
|------|------|------|
|
||||
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
|
||||
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
|
||||
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
|
||||
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
|
||||
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist,5.8MB(仅必需插件) |
|
||||
|
||||
---
|
||||
|
||||
## 最终数据流
|
||||
|
||||
```
|
||||
编辑器: Vditor(wysiwyg) → vditor.getValue() → MD 纯文本
|
||||
↓
|
||||
存储: 后端直接存 MD(纯文本无 XSS 风险,无需 sanitize)
|
||||
└─ generateExcerpt() 生成 plain text 摘要
|
||||
↓
|
||||
显示: Vditor.preview() 客户端 MD → HTML + github-dark 代码主题
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 变更文件清单(11 个)
|
||||
|
||||
| 文件 | 改动 | 详情 |
|
||||
|------|------|------|
|
||||
| `internal/model/post.go` | 修改 | `Body` → MD 存储,去 `BodyHTML`,加 `Excerpt`(varchar 500) |
|
||||
| `internal/service/post_service.go` | 重写 | 去 bluemonday,加 `generateExcerpt()`(纯 Go regex,无外部依赖) |
|
||||
| `internal/controller/post_controller.go` | 修改 | `UploadImage` → Vditor 原生格式,`ShowPage` 去 BodyHTML,去 `html/template` import |
|
||||
| `internal/common/response.go` | 新增函数 | `VditorUploadOk()` — Vditor 图片上传成功响应 |
|
||||
| `templates/.../posts/new.html` | 重写 | Tiptap → Vditor,去除 importmap/工具栏/语言选择器 |
|
||||
| `templates/.../posts/show.html` | 重写 | Vditor 客户端 MD 渲染,去 highlight.js CDN,去代码标签注入脚本 |
|
||||
| `templates/.../posts/index.html` | 微调 | `Body` 截断 → `Excerpt`(带降级回退) |
|
||||
| `templates/.../editor.js` | 重写 | Vditor 初始化 + CSRF + upload + draft + word count + submit |
|
||||
| `templates/.../posts.css` | 删减 | 去 Tiptap 样式(~150 行)+ 工具栏样式(~40 行),加 Vditor 微调 |
|
||||
| `static/vditor/dist/` | 新增 | 本地 Vditor 文件(5.8MB) |
|
||||
| `go.mod / go.sum` | 清理 | 移除 bluemonday 依赖 |
|
||||
|
||||
**不涉及的路由/中间件/仓储:零改动。**
|
||||
|
||||
---
|
||||
|
||||
## 各层详细实现
|
||||
|
||||
### 1. Model `internal/model/post.go`
|
||||
|
||||
```go
|
||||
type Post struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary
|
||||
// ... 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Service `internal/service/post_service.go`
|
||||
|
||||
- 删除 `sanitizePolicy`(bluemonday UGC)和 `sanitizeHTML()`
|
||||
- 新增 `generateExcerpt(md string) string`:
|
||||
- 按行逐条去掉 MD 语法(代码块、图片、标题 #、粗体/斜体、引用 >、列表标记等)
|
||||
- 链接保留文字 `[text](url)` → `text`
|
||||
- 按 rune 截断 300 字符,末尾加 `...`
|
||||
- 纯 Go `regexp` 实现,零外部依赖
|
||||
- `Create()` / `Update()` 生成 `Excerpt`,MD 直存无消毒
|
||||
|
||||
### 3. Controller `internal/controller/post_controller.go`
|
||||
|
||||
**UploadImage — Vditor 原生响应格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"errFiles": [],
|
||||
"succMap": {
|
||||
"微信截图_2026.png": "/uploads/posts/1_1717071234567.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**ShowPage — 去掉 `PostBodyHTML`**,MD 由前端渲染。
|
||||
|
||||
### 4. Common `internal/common/response.go`
|
||||
|
||||
```go
|
||||
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": gin.H{
|
||||
"errFiles": []string{},
|
||||
"succMap": succMap,
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 编辑器 `posts/new.html` + `editor.js`
|
||||
|
||||
**Vditor 初始化关键配置:**
|
||||
|
||||
```javascript
|
||||
new Vditor('vditor', {
|
||||
mode: 'wysiwyg',
|
||||
cdn: '/static/vditor', // 所有动态加载走本地
|
||||
height: '100%',
|
||||
lang: 'zh_CN',
|
||||
toolbar: [
|
||||
'headings', 'bold', 'italic', 'strike', '|',
|
||||
'line', 'code', 'inline-code', 'link', 'quote', '|',
|
||||
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
|
||||
'upload', 'table', '|',
|
||||
'undo', 'redo', '|',
|
||||
'fullscreen', 'code-theme', '|',
|
||||
'outline', 'preview', 'devtools',
|
||||
],
|
||||
upload: {
|
||||
url: '/api/posts/upload-image',
|
||||
fieldName: 'file',
|
||||
max: 5 * 1024 * 1024,
|
||||
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
|
||||
setHeaders() { // 每次请求重新读取 CSRF
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' };
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
||||
hljs: { style: 'github-dark', enable: true },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**CSRF 处理**:`upload.setHeaders` 为函数,每次上传前动态读取 `meta[name="csrf-token"]`,不会因 token 过期而失败。
|
||||
|
||||
**草稿机制**:
|
||||
- 每 2 秒自动保存 `title` + `md` 到 `localStorage`
|
||||
- 键名:`draft_post_title` / `draft_post_body_md`(与旧 Tiptap HTML 草稿隔离)
|
||||
- 新帖模式下自动恢复草稿
|
||||
|
||||
**表单提交**:`vditor.getValue()` 获取 MD,`POST /api/posts` 或 `PUT /api/posts/:id`
|
||||
|
||||
### 6. 详情页 `posts/show.html`
|
||||
|
||||
```html
|
||||
<!-- MD 内容通过隐藏 textarea 安全传递给 JS -->
|
||||
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
|
||||
|
||||
<!-- Vditor 仅需 method.min.js(47KB),不需要完整编辑器 -->
|
||||
<script src="/static/vditor/dist/method.min.js"></script>
|
||||
<script>
|
||||
Vditor.preview(document.getElementById('postContent'), md, {
|
||||
cdn: '/static/vditor',
|
||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
||||
hljs: { style: 'github-dark', enable: true },
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 7. 列表页 `posts/index.html`
|
||||
|
||||
```html
|
||||
<!-- Excerpt 优先,降级回退 Body 截断(兼容旧数据) -->
|
||||
<p class="post-card-summary">
|
||||
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
|
||||
</p>
|
||||
```
|
||||
|
||||
### 8. 样式 `posts.css`
|
||||
|
||||
- 移除:`.tiptap` 及所有子选择器(~100 行)、highlight.js 硬编码主题色(~35 行)、手动工具栏样式(~40 行)、JS 注入代码标签 CSS
|
||||
- 保留:`.editor-layout` / `.editor-header` / `.editor-main` / `.editor-pane` / `.editor-statusbar`(布局)
|
||||
- 新增:`#vditor` flex 适配、`.vditor-toolbar` / `.vditor-content` 主题微调
|
||||
|
||||
---
|
||||
|
||||
## Vditor 本地文件结构
|
||||
|
||||
```
|
||||
templates/MetaLab-2026/static/vditor/dist/
|
||||
├── index.css # 43KB 编辑器 + 预览全部样式
|
||||
├── index.min.js # 292KB 编辑器核心(wysiwyg/sv/ir + 工具栏 + 上传)
|
||||
├── method.min.js # 47KB 独立方法(Vditor.preview 等,详情页用)
|
||||
├── css/content-theme/
|
||||
│ ├── light.css # 内容区明亮主题
|
||||
│ ├── dark.css # 内容区暗色主题
|
||||
│ ├── ant-design.css # Ant Design 主题
|
||||
│ └── wechat.css # 微信主题
|
||||
├── images/
|
||||
│ ├── img-loading.svg # 上传进度指示
|
||||
│ ├── logo.png # Vditor logo
|
||||
│ └── emoji/ # 内置表情包(b3log/octocat/doge 等)
|
||||
├── js/
|
||||
│ ├── highlight.js/
|
||||
│ │ ├── highlight.min.js # 1.4MB 代码高亮核心
|
||||
│ │ ├── third-languages.js # 额外语言支持
|
||||
│ │ └── styles/*.min.css # 60+ 代码主题
|
||||
│ ├── lute/
|
||||
│ │ └── lute.min.js # 3.9MB WASM MD 解析器(Vditor 核心依赖)
|
||||
│ ├── i18n/
|
||||
│ │ └── zh_CN.js # 8KB 中文语言包
|
||||
│ └── icons/
|
||||
│ ├── material.js # Material Design 图标
|
||||
│ └── ant.js # Ant Design 图标
|
||||
```
|
||||
|
||||
**已剔除的插件**(节省 16MB+):
|
||||
|
||||
| 插件 | 大小 | 功能 | 对技术论坛无用 |
|
||||
|------|------|------|---------------|
|
||||
| mathjax | 6.5MB | LaTeX 数学公式 | ❌ |
|
||||
| mermaid | 2.6MB | 流程图/时序图 | ❌ |
|
||||
| graphviz | 2.0MB | 图谱渲染 | ❌ |
|
||||
| katex | 1.5MB | 数学公式引擎 | ❌ |
|
||||
| echarts | 1.0MB | 图表渲染 | ❌ |
|
||||
| markmap | 836KB | 思维导图 | ❌ |
|
||||
| abcjs | 356KB | 五线谱 | ❌ |
|
||||
| plantuml | 32KB | PlantUML | ❌ |
|
||||
| flowchart.js / smiles-drawer | ~400KB | 流程图 / 化学分子式 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Shortcode 扩展(`[zone:type:params]`)
|
||||
|
||||
### 语法
|
||||
|
||||
在 Markdown 正文中嵌入特殊卡片,语法为 `[zone:类型:参数]`:
|
||||
|
||||
```markdown
|
||||
# 周末活动汇总
|
||||
|
||||
下面是我们本周的推荐活动:
|
||||
|
||||
[zone:event:summer2026]
|
||||
|
||||
更多内容请关注官方动态...
|
||||
```
|
||||
|
||||
### 已注册类型
|
||||
|
||||
| 语法 | 渲染结果 | 状态 |
|
||||
|------|---------|------|
|
||||
| `[zone:event:活动ID]` | 活动卡片(🎪 图标 + ID + 跳转链接) | ✅ 已实现(占位) |
|
||||
| `[zone:game:游戏slug]` | 游戏卡片(🎮 图标 + slug + 跳转链接) | ✅ 已实现(占位) |
|
||||
| `[zone:poll:投票ID]` | 投票组件 | ⏳ 预留 |
|
||||
| `[zone:resource:资源ID]` | 资源推荐卡片 | ⏳ 预留 |
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
MD body "[zone:event:summer2026]"
|
||||
│
|
||||
▼
|
||||
ShortcodeService.Process() ← 后端:正则替换 [zone:...] → 占位 <div>
|
||||
│
|
||||
▼
|
||||
VDitor.preview() ← 前端:MD → HTML,占位 div 原样保留
|
||||
│
|
||||
▼
|
||||
renderShortcodes() ← 前端 shortcode.js:扫描 .zone-card,渲染 UI 卡片
|
||||
```
|
||||
|
||||
### 文件清单
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `internal/model/shortcode.go` | 类型常量 + 正则 + DTO |
|
||||
| `internal/service/shortcode_service.go` | 解析器 + 占位 HTML 生成器 |
|
||||
| `internal/controller/post_controller.go` | ShowPage/ShowAPI 调用 Process() |
|
||||
| `internal/router/deps_extra.go` | DI 注入 ShortcodeService |
|
||||
| `templates/.../js/shortcode.js` | 前端卡片渲染器 |
|
||||
| `templates/.../html/posts/show.html` | 引入 shortcode.js + 调用 renderShortcodes() |
|
||||
| `templates/.../static/css/posts.css` | 卡片样式 |
|
||||
| `templates/.../static/js/editor.js` | hint 自动补全提示 |
|
||||
|
||||
### 如何添加新类型
|
||||
|
||||
1. `internal/model/shortcode.go`:注册 `ShortcodeType` 常量
|
||||
2. `internal/service/shortcode_service.go`:在 `renderPlaceholder()` 添加 case
|
||||
3. `templates/.../js/shortcode.js`:在 `cardRenderers` 注册渲染函数
|
||||
4. `templates/.../static/js/editor.js`:在 `hint.extend` 添加补全提示
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 未注册或格式错误的 shortcode **不报错**,原文保留,避免破坏用户内容
|
||||
- 占位 div 结构为 `<div class="zone-card" data-zone-type="..." data-zone-id="...">`
|
||||
- 编辑器输入 `[zone:` 时自动弹出补全列表(Vditor hint.extend)
|
||||
- 后端 API 就绪后,将 `shortcode.js` 中的静态卡片替换为 `fetch()` 动态数据即可
|
||||
|
||||
---
|
||||
|
||||
## 不涉及的部分
|
||||
|
||||
- `internal/router/api.go` — 路由不变(`POST /api/posts/upload-image` 端点不变)
|
||||
- `internal/router/frontend.go` — SSR 页面路由不变
|
||||
- `internal/repository/post_repo.go` — 仓储不变(Model 字段变更由 GORM 自动映射)
|
||||
- `internal/middleware/` — CSRF / Auth 中间件不变
|
||||
- `internal/config/` — 配置不变
|
||||
- `cmd/server/main.go` — `Static()` 映射不变(`/static` → `templates/.../static`)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **DB Schema**:GORM AutoMigrate 自动添加 `excerpt` 列;旧的 `body_html` 列残留但不影响运行(GORM 不会 DROP,可手动清理)
|
||||
2. **已有帖子**:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
|
||||
3. **CSRF**:`upload.setHeaders` 为函数,每次上传前动态读取最新 token,不受 token 过期影响
|
||||
4. **时序**:Vditor 主文件先加载,lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
|
||||
5. **bluemonday**:已从 `go.mod` 中移除(`go mod tidy` 自动清理)
|
||||
9
go.mod
9
go.mod
@ -5,20 +5,20 @@ go 1.26.0
|
||||
require (
|
||||
github.com/chai2010/webp v1.4.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/redis/go-redis/v9 v9.20.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/sync v0.20.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
@ -29,7 +29,6 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@ -54,10 +53,10 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
|
||||
20
go.sum
20
go.sum
@ -1,11 +1,15 @@
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
@ -37,13 +41,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@ -68,8 +68,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@ -83,6 +81,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
|
||||
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
@ -115,8 +115,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
|
||||
55
internal/cache/home_cache.go
vendored
Normal file
55
internal/cache/home_cache.go
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
|
||||
// HomePageCache 首页内存缓存,30 秒 TTL,创建/更新/删除/审核通过时失效
|
||||
type HomePageCache struct {
|
||||
mu sync.RWMutex
|
||||
posts []model.Post
|
||||
total int64
|
||||
expires time.Time
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewHomePageCache 创建首页缓存,ttl 为缓存有效期
|
||||
func NewHomePageCache(ttl time.Duration) *HomePageCache {
|
||||
return &HomePageCache{ttl: ttl}
|
||||
}
|
||||
|
||||
// Get 返回缓存数据,ok=false 表示缓存未命中或已过期
|
||||
func (c *HomePageCache) Get() (posts []model.Post, total int64, ok bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if time.Now().After(c.expires) {
|
||||
return nil, 0, false
|
||||
}
|
||||
// 返回浅拷贝以防止调用方修改缓存内部数据
|
||||
copied := make([]model.Post, len(c.posts))
|
||||
copy(copied, c.posts)
|
||||
return copied, c.total, true
|
||||
}
|
||||
|
||||
// Set 写入缓存数据
|
||||
func (c *HomePageCache) Set(posts []model.Post, total int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// 深拷贝一份避免外部修改影响缓存
|
||||
c.posts = make([]model.Post, len(posts))
|
||||
copy(c.posts, posts)
|
||||
c.total = total
|
||||
c.expires = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// Invalidate 主动失效缓存
|
||||
func (c *HomePageCache) Invalidate() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.posts = nil
|
||||
c.total = 0
|
||||
c.expires = time.Time{}
|
||||
}
|
||||
20
internal/common/context.go
Normal file
20
internal/common/context.go
Normal file
@ -0,0 +1,20 @@
|
||||
package common
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
||||
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
||||
func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
|
||||
if v, exists := c.Get("uid"); exists {
|
||||
if u, isUint := v.(uint); isUint {
|
||||
uid = u
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if v, exists := c.Get("role"); exists {
|
||||
if r, isStr := v.(string); isStr {
|
||||
role = r
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -11,52 +11,30 @@ import (
|
||||
|
||||
// Cookie 名称常量
|
||||
const (
|
||||
CookieName = "mlb_token"
|
||||
RefreshCookieName = "mlb_refresh"
|
||||
RMCookieName = "mlb_rm" // 记住我标记,JS 可读
|
||||
SessionCookieName = "mlb_sid" // 会话 ID Cookie(HttpOnly)
|
||||
)
|
||||
|
||||
// SetAuthCookies 设置 access + refresh Cookie
|
||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
||||
// rememberMe=false → Cookie 用 session 模式(maxAge=0),关浏览器即清除,但页面开启期间自动刷新
|
||||
func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) {
|
||||
// SetSessionCookie 写入会话 ID Cookie
|
||||
// rememberMe=true → Cookie maxAge=30天,关浏览器后仍保持登录
|
||||
// rememberMe=false → Cookie maxAge=空闲超时(默认2h),与Redis TTL一致,关浏览器后自动清除
|
||||
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
|
||||
secure := cfg.Server.Mode != "debug"
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
|
||||
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
|
||||
|
||||
var maxAge int
|
||||
if rememberMe {
|
||||
setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure)
|
||||
setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure)
|
||||
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
||||
} else {
|
||||
// session cookie:关浏览器即清除,但页面开启期间自动刷新生效
|
||||
setCookie(c, RefreshCookieName, refreshToken, 0, secure)
|
||||
setPlainCookie(c, RMCookieName, "1", 0, secure)
|
||||
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
|
||||
}
|
||||
|
||||
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
|
||||
c.SetCookie(SessionCookieName, sid, maxAge, "/", "", secure, true)
|
||||
}
|
||||
|
||||
// SetAccessCookie 仅刷新 access Cookie(refresh 续期调用)
|
||||
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
|
||||
// ClearSessionCookie 清除会话 ID Cookie(logout 调用)
|
||||
func ClearSessionCookie(c *gin.Context, cfg *config.Config) {
|
||||
secure := cfg.Server.Mode != "debug"
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
|
||||
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
|
||||
}
|
||||
|
||||
// ClearAuthCookies 清除所有认证 Cookie(logout 调用)
|
||||
func ClearAuthCookies(c *gin.Context, cfg *config.Config) {
|
||||
secure := cfg.Server.Mode != "debug"
|
||||
c.SetCookie(CookieName, "", -1, "/", "", secure, true)
|
||||
c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true)
|
||||
c.SetCookie(RMCookieName, "", -1, "/", "", secure, false)
|
||||
}
|
||||
|
||||
// setCookie 写入一个 HttpOnly Cookie
|
||||
func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
|
||||
log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure)
|
||||
c.SetCookie(name, value, maxAge, "/", "", secure, true)
|
||||
}
|
||||
|
||||
// setPlainCookie 写入非 HttpOnly Cookie(JS 可读)
|
||||
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
|
||||
c.SetCookie(name, value, maxAge, "/", "", secure, false)
|
||||
}
|
||||
|
||||
@ -12,9 +12,3 @@ func HashPassword(password string, cost int) (string, error) {
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@ -11,10 +11,10 @@ var (
|
||||
ErrInvalidCred = errors.New("邮箱或密码错误")
|
||||
ErrUserNotFound = errors.New("用户不存在")
|
||||
ErrUserBanned = errors.New("该账号已被封禁")
|
||||
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复")
|
||||
ErrUserDeleted = errors.New("该账号正在注销中,登录即撤销注销")
|
||||
// 统一返回,避免泄露用户存在性、软删除状态等内部信息
|
||||
ErrUserLocked = errors.New("邮箱或密码错误")
|
||||
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
|
||||
ErrWeakPassword = errors.New("密码需至少 8 位,且包含大写字母、小写字母和数字")
|
||||
ErrTokenExpired = errors.New("登录已过期,请重新登录")
|
||||
ErrTokenInvalid = errors.New("无效的认证凭据")
|
||||
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
|
||||
@ -32,15 +32,29 @@ var (
|
||||
// 密码 & 注销相关
|
||||
ErrIncorrectPassword = errors.New("当前密码错误")
|
||||
ErrLoginBeforeDelete = errors.New("请先登录再注销")
|
||||
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号")
|
||||
ErrNeedsConfirmRestore = errors.New("需要二次确认撤销注销")
|
||||
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
|
||||
ErrRegistrationDisabled = errors.New("注册功能已关闭")
|
||||
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
|
||||
|
||||
// 帖子相关
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||
|
||||
// 域能相关
|
||||
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
|
||||
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
|
||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
||||
|
||||
// 评论相关
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrCommentEmpty = errors.New("评论内容不能为空")
|
||||
)
|
||||
|
||||
29
internal/common/file.go
Normal file
29
internal/common/file.go
Normal file
@ -0,0 +1,29 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"os"
|
||||
)
|
||||
|
||||
// SaveUploadedFile 将 multipart.File 内容写入目标路径
|
||||
func SaveUploadedFile(file multipart.File, dst string) error {
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -1,21 +1,104 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// assetHashes 静态资源文件哈希表(URL 路径 → 短哈希,文件不变哈希不变)
|
||||
var assetHashes map[string]string
|
||||
|
||||
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
|
||||
func ComputeAssetHashes(templateDir string) {
|
||||
assetHashes = make(map[string]string)
|
||||
// 前端主题:templates/MetaLab-2026/static → URL /static/
|
||||
walkStaticDir(filepath.Join(templateDir, "static"), "/static")
|
||||
// 共享资源:templates/shared/static → URL /shared/static/
|
||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
|
||||
// 管理后台:templates/admin/static → URL /admin/static/
|
||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
|
||||
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
|
||||
}
|
||||
|
||||
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32,存入 assetHashes
|
||||
func walkStaticDir(dir, urlPrefix string) {
|
||||
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := filepath.Ext(path)
|
||||
if ext != ".js" && ext != ".css" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
h := crc32.ChecksumIEEE(data)
|
||||
shortHash := strconv.FormatUint(uint64(h), 16)
|
||||
// 生成 URL 路径:urlPrefix + 相对于 dir 的子路径
|
||||
rel, _ := filepath.Rel(dir, path)
|
||||
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
|
||||
assetHashes[urlPath] = shortHash
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AssetV 返回指定静态资源的哈希版本号(模板函数)
|
||||
func AssetV(path string) string {
|
||||
if h, ok := assetHashes[path]; ok {
|
||||
return h
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// mdImageRe 匹配 Markdown 图片语法 
|
||||
var mdImageRe = regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
|
||||
|
||||
// ExtractFirstImage 从 Markdown 正文提取第一张图片 URL
|
||||
func ExtractFirstImage(md string) string {
|
||||
match := mdImageRe.FindStringSubmatch(md)
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token
|
||||
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
||||
data := gin.H{}
|
||||
for k, v := range extra {
|
||||
data[k] = v
|
||||
}
|
||||
// 注入 CSP nonce(由 SecurityHeaders 中间件生成)
|
||||
if nonce, exists := c.Get("csp_nonce"); exists {
|
||||
data["CSPNonce"] = nonce
|
||||
}
|
||||
if username, exists := c.Get("username"); exists {
|
||||
data["IsLoggedIn"] = true
|
||||
data["Username"] = username
|
||||
}
|
||||
if avatar, exists := c.Get("avatar"); exists {
|
||||
data["Avatar"] = avatar
|
||||
}
|
||||
if expVal, exists := c.Get("exp"); exists {
|
||||
if exp, ok := expVal.(int); ok {
|
||||
data["Exp"] = exp
|
||||
data["Level"] = model.GetLevelByExp(exp)
|
||||
}
|
||||
}
|
||||
// 注入 CSRF token(由 CSRF 中间件设置到上下文中)
|
||||
if token, exists := c.Get("csrf_token"); exists {
|
||||
data["CSRFToken"] = token
|
||||
@ -25,6 +108,15 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
||||
// 注入管理入口权限(moderator 及以上可看到页脚管理面板链接)
|
||||
if role, exists := c.Get("role"); exists {
|
||||
data["CanAccessAdmin"] = model.HasMinRole(role.(string), model.RoleModerator)
|
||||
data["IsOwner"] = model.HasMinRole(role.(string), model.RoleOwner)
|
||||
}
|
||||
// 注入维护状态
|
||||
if isMaintenance, exists := c.Get("is_maintenance"); exists {
|
||||
data["IsMaintenance"] = isMaintenance
|
||||
}
|
||||
// 注入封禁状态
|
||||
if status, exists := c.Get("status"); exists && status == model.StatusBanned {
|
||||
data["IsBanned"] = true
|
||||
}
|
||||
return data
|
||||
}
|
||||
@ -55,6 +147,16 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
|
||||
return data
|
||||
}
|
||||
|
||||
// RedirectToLogin 重定向到登录页,携带当前页面路径作为 redirect 参数
|
||||
func RedirectToLogin(c *gin.Context) {
|
||||
returnURL := url.QueryEscape(c.Request.URL.Path)
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
returnURL = url.QueryEscape(c.Request.URL.Path + "?" + c.Request.URL.RawQuery)
|
||||
}
|
||||
c.Redirect(http.StatusFound, fmt.Sprintf("/auth/login?redirect=%s", returnURL))
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// injectSiteInfo 从 context 注入站点展示信息
|
||||
func injectSiteInfo(c *gin.Context, data gin.H) {
|
||||
if framework, exists := c.Get("site_framework"); exists {
|
||||
|
||||
@ -49,5 +49,13 @@ func (p *Pagination) NextPage(totalPages int) int {
|
||||
return next
|
||||
}
|
||||
|
||||
// PageCount 根据 total 和 pageSize 计算总页数(无需创建 Pagination 实例)
|
||||
func PageCount(total int64, pageSize int) int {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
|
||||
// 固定时间格式,前后端统一
|
||||
const TimeFormat = time.RFC3339
|
||||
|
||||
12
internal/common/post_helpers.go
Normal file
12
internal/common/post_helpers.go
Normal file
@ -0,0 +1,12 @@
|
||||
package common
|
||||
|
||||
import "metazone.cc/metalab/internal/model"
|
||||
|
||||
// PostStatusDisplayNames 帖子状态 → 中文名称映射(供模板渲染使用)
|
||||
var PostStatusDisplayNames = map[string]string{
|
||||
model.PostStatusDraft: "草稿",
|
||||
model.PostStatusPending: "待审核",
|
||||
model.PostStatusApproved: "已发布",
|
||||
model.PostStatusRejected: "已退回",
|
||||
model.PostStatusLocked: "已锁定",
|
||||
}
|
||||
36
internal/common/redis.go
Normal file
36
internal/common/redis.go
Normal file
@ -0,0 +1,36 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
)
|
||||
|
||||
// NewRedisClient 创建 Redis 客户端并尝试验证连接
|
||||
// Redis 不可达时不返回错误,而是返回 client(go-redis 自带自动重连),由调用方决定降级策略
|
||||
func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s),配合 FallbackStore 快速降级
|
||||
ReadTimeout: 1 * time.Second,
|
||||
WriteTimeout: 1 * time.Second,
|
||||
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
|
||||
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS)
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
log.Printf("[Redis] 连接失败: %v,将降级为内存存储", err)
|
||||
return client, fmt.Errorf("Redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@ -22,3 +22,16 @@ func OkWithMessage(c *gin.Context, data interface{}, message string) {
|
||||
func Error(c *gin.Context, code int, message string) {
|
||||
c.JSON(code, gin.H{"success": false, "message": message})
|
||||
}
|
||||
|
||||
// VditorUploadOk Vditor 图片上传成功响应
|
||||
// succMap: key=原始文件名, value=文件 URL
|
||||
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": gin.H{
|
||||
"errFiles": []string{},
|
||||
"succMap": succMap,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -13,7 +13,8 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Session SessionConfig `mapstructure:"session"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
||||
Roles RolesConfig `mapstructure:"roles"`
|
||||
Audit AuditConfig `mapstructure:"audit"`
|
||||
@ -41,11 +42,20 @@ type DatabaseConfig struct {
|
||||
SSLMode string `mapstructure:"sslmode"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
AccessExpire int `mapstructure:"access_expire"` // 分钟
|
||||
RefreshExpire int `mapstructure:"refresh_expire"` // 小时
|
||||
RememberExpire int `mapstructure:"remember_expire"` // 小时
|
||||
// SessionConfig 服务端会话配置
|
||||
type SessionConfig struct {
|
||||
IdleTimeout int `mapstructure:"idle_timeout"` // 不记住我:空闲超时(分钟),默认 1440(24小时)
|
||||
RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 43200(30天)
|
||||
CleanupInterval int `mapstructure:"cleanup_interval"` // 后台清理间隔(秒),默认 300,仅内存模式使用
|
||||
}
|
||||
|
||||
// RedisConfig Redis 连接配置
|
||||
type RedisConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Host string `mapstructure:"host"`
|
||||
Port string `mapstructure:"port"`
|
||||
Password string `mapstructure:"password"`
|
||||
DB int `mapstructure:"db"`
|
||||
}
|
||||
|
||||
type BcryptConfig struct {
|
||||
@ -91,8 +101,8 @@ func Load(configPath string) *Config {
|
||||
log.Fatalf("解析配置失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[Config] JWT.SecretLen=%d JWT.AccessExpire=%d min JWT.RefreshExpire=%d h JWT.RememberExpire=%d h | Server.Mode=%s",
|
||||
len(c.JWT.Secret), c.JWT.AccessExpire, c.JWT.RefreshExpire, c.JWT.RememberExpire, c.Server.Mode)
|
||||
log.Printf("[Config] Session.IdleTimeout=%d min Session.RememberTimeout=%d min Session.CleanupInterval=%d s | Server.Mode=%s",
|
||||
c.Session.IdleTimeout, c.Session.RememberTimeout, c.Session.CleanupInterval, c.Server.Mode)
|
||||
|
||||
App = c
|
||||
return c
|
||||
@ -127,7 +137,17 @@ func loadEnvFile(path string) {
|
||||
// bindEnvOverride 将环境变量映射到 config 的嵌套键
|
||||
func bindEnvOverride(v *viper.Viper) {
|
||||
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
||||
_ = v.BindEnv("jwt.secret", "JWT_SECRET")
|
||||
_ = v.BindEnv("redis.password", "REDIS_PASSWORD")
|
||||
}
|
||||
|
||||
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
|
||||
func (c *Config) GetIdleTimeout() int {
|
||||
return c.Session.IdleTimeout
|
||||
}
|
||||
|
||||
// GetRememberTimeout 返回记住我会话空闲超时(分钟)
|
||||
func (c *Config) GetRememberTimeout() int {
|
||||
return c.Session.RememberTimeout
|
||||
}
|
||||
|
||||
// DSN 返回 PostgreSQL 连接字符串
|
||||
|
||||
@ -128,3 +128,8 @@ func (s *SiteSettings) SiteInfo() SiteInfoDefaults {
|
||||
func (s *SiteSettings) IsRegistrationEnabled() bool {
|
||||
return s.GetBool("registration.enabled", true)
|
||||
}
|
||||
|
||||
// IsMaintenanceEnabled 是否处于维护模式(维护期间仅站长可登录和访问)
|
||||
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
||||
return s.GetBool("maintenance.enabled", false)
|
||||
}
|
||||
|
||||
67
internal/controller/admin/admin_comment_controller.go
Normal file
67
internal/controller/admin/admin_comment_controller.go
Normal file
@ -0,0 +1,67 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminCommentController 后台评论管理
|
||||
type AdminCommentController struct {
|
||||
commentService adminCommentUseCase
|
||||
}
|
||||
|
||||
// NewAdminCommentController 构造函数
|
||||
func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
|
||||
return &AdminCommentController{commentService: cs}
|
||||
}
|
||||
|
||||
// CommentsPage SSR 页面
|
||||
func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "评论管理",
|
||||
"ExtraCSS": "/admin/static/css/comments.css",
|
||||
"ExtraJS": "/admin/static/js/comments.js",
|
||||
}))
|
||||
}
|
||||
|
||||
// ListComments API 列表
|
||||
func (ctrl *AdminCommentController) ListComments(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
comments, total, err := ctrl.commentService.ListAllComments(keyword, false, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取评论列表失败")
|
||||
return
|
||||
}
|
||||
if comments == nil {
|
||||
comments = []model.AdminCommentRow{}
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": comments,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 软删除评论
|
||||
func (ctrl *AdminCommentController) Delete(c *gin.Context) {
|
||||
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||
return
|
||||
}
|
||||
if err := ctrl.commentService.Delete(uint(commentID), 0, true); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
common.OkMessage(c, "删除成功")
|
||||
}
|
||||
@ -29,9 +29,22 @@ func (ac *AdminController) Dashboard(c *gin.Context) {
|
||||
if err != nil {
|
||||
totalUsers = 0
|
||||
}
|
||||
postStats, err := ac.adminService.GetPostStats()
|
||||
if err != nil {
|
||||
postStats = &service.PostStats{}
|
||||
}
|
||||
storeMetrics := ac.adminService.GetStoreMetrics()
|
||||
c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "管理首页",
|
||||
"TotalUsers": totalUsers,
|
||||
"Title": "管理首页",
|
||||
"TotalUsers": totalUsers,
|
||||
"PostTotal": postStats.Total,
|
||||
"PostApproved": postStats.Approved,
|
||||
"PostPending": postStats.Pending,
|
||||
"StoreType": storeMetrics.Type,
|
||||
"StoreHealthy": storeMetrics.Healthy,
|
||||
"StoreDegraded": storeMetrics.Degraded,
|
||||
"DegradedNote": storeMetrics.DegradedNote,
|
||||
"ActiveSessions": storeMetrics.ActiveCount,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -94,7 +107,7 @@ func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
|
||||
if req.Status == model.StatusActive {
|
||||
action = "解封"
|
||||
} else if req.Status == model.StatusLocked {
|
||||
action = "已删除"
|
||||
action = "已锁定"
|
||||
}
|
||||
common.OkMessage(c, action+"成功")
|
||||
}
|
||||
|
||||
140
internal/controller/admin/admin_energy_api_controller.go
Normal file
140
internal/controller/admin/admin_energy_api_controller.go
Normal file
@ -0,0 +1,140 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdjustEnergy 调整用户域能
|
||||
func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
|
||||
operatorUID, ok := c.Get("uid")
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserIDs []uint `json:"user_ids" binding:"required,min=1"`
|
||||
Amount int `json:"amount" binding:"required"`
|
||||
Description string `json:"description" binding:"required,min=1,max=500"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description")
|
||||
return
|
||||
}
|
||||
|
||||
// 默认模式为 admin_transfer
|
||||
if req.Mode == "" {
|
||||
req.Mode = model.FundTypeAdminTransfer
|
||||
}
|
||||
|
||||
// system_operation 仅 owner 可用
|
||||
if req.Mode == model.FundTypeSystemOperation {
|
||||
role, _ := c.Get("role")
|
||||
if !model.HasMinRole(role.(string), model.RoleOwner) {
|
||||
common.Error(c, http.StatusForbidden, "仅站长可使用系统操作模式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
|
||||
if err == common.ErrInsufficientFund {
|
||||
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
|
||||
return
|
||||
}
|
||||
common.Error(c, http.StatusInternalServerError, "调整失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "域能调整成功")
|
||||
}
|
||||
|
||||
// ListEnergyLogs 查询域能日志
|
||||
func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) {
|
||||
energyType := c.Query("type")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
logs, total, err := ac.energySvc.GetAdminEnergyLogs(energyType, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为展示友好的视图
|
||||
type logView struct {
|
||||
model.EnergyLog
|
||||
EnergyDisplay float64 `json:"energy_display"`
|
||||
TypeName string `json:"type_name"`
|
||||
}
|
||||
views := make([]logView, len(logs))
|
||||
for i, l := range logs {
|
||||
views[i] = logView{
|
||||
EnergyLog: l,
|
||||
EnergyDisplay: float64(l.Amount) / 10,
|
||||
TypeName: model.EnergyLogDisplayNames[l.Type],
|
||||
}
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": views,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// GetFundBalance 获取公户余额
|
||||
func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
|
||||
balance, err := ac.energySvc.GetFundBalance()
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
common.Ok(c, gin.H{
|
||||
"balance": balance,
|
||||
"balance_display": float64(balance) / 10,
|
||||
})
|
||||
}
|
||||
|
||||
// ListFundLogs 查询公户流水
|
||||
func (ac *AdminEnergyController) ListFundLogs(c *gin.Context) {
|
||||
logType := c.Query("type")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
logs, total, err := ac.energySvc.GetFundLogs(logType, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
type logView struct {
|
||||
model.FundLog
|
||||
AmountDisplay float64 `json:"amount_display"`
|
||||
TypeName string `json:"type_name"`
|
||||
}
|
||||
views := make([]logView, len(logs))
|
||||
for i, l := range logs {
|
||||
views[i] = logView{
|
||||
FundLog: l,
|
||||
AmountDisplay: float64(l.Amount) / 10,
|
||||
TypeName: model.FundLogDisplayNames[l.Type],
|
||||
}
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": views,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
55
internal/controller/admin/admin_energy_controller.go
Normal file
55
internal/controller/admin/admin_energy_controller.go
Normal file
@ -0,0 +1,55 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminEnergyController 后台域能管理控制器
|
||||
type AdminEnergyController struct {
|
||||
energySvc adminEnergyUseCase
|
||||
}
|
||||
|
||||
// NewAdminEnergyController 构造函数
|
||||
func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyController {
|
||||
return &AdminEnergyController{energySvc: energySvc}
|
||||
}
|
||||
|
||||
// EnergyPage 域能管理页面(公户余额 + 用户域能调整)
|
||||
func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
|
||||
balance := 0
|
||||
if b, err := ac.energySvc.GetFundBalance(); err == nil {
|
||||
balance = b
|
||||
}
|
||||
c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "域能管理",
|
||||
"ExtraCSS": "/admin/static/css/energy.css",
|
||||
"ExtraJS": "/admin/static/js/energy.js",
|
||||
"FundBalance": balance,
|
||||
"FundBalanceDisplay": float64(balance) / 10,
|
||||
}))
|
||||
}
|
||||
|
||||
// EnergyLogPage 域能日志页面
|
||||
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "域能日志",
|
||||
"ExtraCSS": "/admin/static/css/energy.css",
|
||||
"ExtraJS": "/admin/static/js/energy.js",
|
||||
"LogTypes": model.EnergyLogDisplayNames,
|
||||
}))
|
||||
}
|
||||
|
||||
// FundLogPage 公户流水页面
|
||||
func (ac *AdminEnergyController) FundLogPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/energy/fund_logs.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "公户流水",
|
||||
"ExtraCSS": "/admin/static/css/energy.css",
|
||||
"ExtraJS": "/admin/static/js/energy.js",
|
||||
"LogTypes": model.FundLogDisplayNames,
|
||||
}))
|
||||
}
|
||||
124
internal/controller/admin/admin_post_action_controller.go
Normal file
124
internal/controller/admin/admin_post_action_controller.go
Normal file
@ -0,0 +1,124 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Approve 审核通过
|
||||
func (ctrl *AdminPostController) Approve(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Approve(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "审核失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "审核通过")
|
||||
}
|
||||
|
||||
// Reject 退回
|
||||
func (ctrl *AdminPostController) Reject(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.PostRejectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请填写退回理由")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "退回失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已退回")
|
||||
}
|
||||
|
||||
// Lock 锁定
|
||||
func (ctrl *AdminPostController) Lock(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.PostLockRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请填写锁定理由")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Lock(uint(id), req.Reason); err != nil {
|
||||
if errors.Is(err, common.ErrPostNotFound) || errors.Is(err, common.ErrPostCannotLock) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "锁定失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已锁定")
|
||||
}
|
||||
|
||||
// Unlock 解锁
|
||||
func (ctrl *AdminPostController) Unlock(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Unlock(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "解锁失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已解锁")
|
||||
}
|
||||
|
||||
// Restore 恢复软删除
|
||||
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已恢复")
|
||||
}
|
||||
@ -1,12 +1,10 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@ -37,127 +35,27 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []model.Post{}
|
||||
totalPages := common.PageCount(total, pageSize)
|
||||
prevPage := page - 1
|
||||
if prevPage < 1 {
|
||||
prevPage = 1
|
||||
}
|
||||
nextPage := page + 1
|
||||
if nextPage > totalPages {
|
||||
nextPage = totalPages
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
totalPages := p.TotalPages(total)
|
||||
|
||||
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "内容管理",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": p.Page,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": p.PrevPage(),
|
||||
"NextPage": p.NextPage(totalPages),
|
||||
"PrevPage": prevPage,
|
||||
"NextPage": nextPage,
|
||||
"Keyword": keyword,
|
||||
"Status": status,
|
||||
"StatusNames": model.PostStatusDisplayNames,
|
||||
"StatusNames": common.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/admin/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// Approve 审核通过
|
||||
func (ctrl *AdminPostController) Approve(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Approve(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "审核失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "审核通过")
|
||||
}
|
||||
|
||||
// Reject 退回
|
||||
func (ctrl *AdminPostController) Reject(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.PostRejectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请填写退回理由")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "退回失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已退回")
|
||||
}
|
||||
|
||||
// Lock 锁定
|
||||
func (ctrl *AdminPostController) Lock(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Lock(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "锁定失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已锁定")
|
||||
}
|
||||
|
||||
// Unlock 解锁
|
||||
func (ctrl *AdminPostController) Unlock(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Unlock(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "解锁失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已解锁")
|
||||
}
|
||||
|
||||
// Restore 恢复软删除
|
||||
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已恢复")
|
||||
}
|
||||
|
||||
@ -50,17 +50,17 @@ func (ac *AuditController) ListAudits(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 为每个审核记录附加提交者的用户名
|
||||
// 为每个审核记录附加提交者的当前用户名
|
||||
type auditItem struct {
|
||||
model.AuditSubmission
|
||||
SubmitterUsername string `json:"submitter_username"`
|
||||
CurrentUsername string `json:"current_username"`
|
||||
}
|
||||
|
||||
items := make([]auditItem, 0, len(result.Items))
|
||||
for _, a := range result.Items {
|
||||
item := auditItem{AuditSubmission: a}
|
||||
if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
|
||||
item.SubmitterUsername = user.Username
|
||||
item.CurrentUsername = user.Username
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
@ -3,14 +3,17 @@ package admin
|
||||
import (
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
)
|
||||
|
||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:4 个方法)
|
||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:6 个方法)
|
||||
type adminUseCase interface {
|
||||
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
|
||||
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
|
||||
ResetUserToken(operatorUID, targetUID uint) error
|
||||
CountUsers() (int64, error)
|
||||
GetPostStats() (*service.PostStats, error)
|
||||
GetStoreMetrics() session.StoreMetrics
|
||||
}
|
||||
|
||||
// auditUseCase AuditController 对 AuditService 的最小依赖(ISP:3 个方法)
|
||||
@ -33,12 +36,27 @@ type auditStatusProvider interface {
|
||||
FindByID(id uint) (*model.User, error)
|
||||
}
|
||||
|
||||
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:7 个方法)
|
||||
type adminPostUseCase interface {
|
||||
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
||||
Approve(postID uint) error
|
||||
Reject(postID uint, reason string) error
|
||||
Lock(postID uint) error
|
||||
Lock(postID uint, reason string) error
|
||||
Unlock(postID uint) error
|
||||
Restore(postID uint) error
|
||||
}
|
||||
|
||||
// adminCommentUseCase AdminCommentController 对 CommentService 的最小依赖(ISP:2 个方法)
|
||||
type adminCommentUseCase interface {
|
||||
ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error)
|
||||
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
||||
}
|
||||
|
||||
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:4 个方法)
|
||||
type adminEnergyUseCase interface {
|
||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
GetFundBalance() (int, error)
|
||||
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||
}
|
||||
|
||||
@ -31,43 +31,36 @@ func (ac *AuthController) Login(c *gin.Context) {
|
||||
email := req.Email
|
||||
ip := clientIP(c)
|
||||
|
||||
// --- 限流:账户维度 ---
|
||||
acctResult, recordAccount := ac.rateLimiter.AllowAccount(email)
|
||||
// --- 限流:账户维度(原子检查+递增)---
|
||||
acctResult := ac.rateLimiter.AllowAccount(email)
|
||||
if acctResult.Blocked {
|
||||
common.Error(c, http.StatusTooManyRequests, acctResult.Message)
|
||||
return
|
||||
}
|
||||
|
||||
// --- 限流:IP 维度 ---
|
||||
ipResult, recordIP := ac.rateLimiter.AllowIP(ip)
|
||||
// --- 限流:IP 维度(原子检查+递增)---
|
||||
ipResult := ac.rateLimiter.AllowIP(ip)
|
||||
if ipResult.Blocked {
|
||||
common.Error(c, http.StatusTooManyRequests, ipResult.Message)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, refreshToken, user, err := ac.authService.Login(req, ip)
|
||||
user, err := ac.authService.Login(req, ip)
|
||||
if err != nil {
|
||||
// 记录失败 → 两个维度各 +1
|
||||
if recordAccount != nil {
|
||||
recordAccount()
|
||||
}
|
||||
if recordIP != nil {
|
||||
recordIP()
|
||||
}
|
||||
|
||||
// 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
|
||||
switch err {
|
||||
case common.ErrInvalidCred:
|
||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
case common.ErrUserBanned:
|
||||
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
||||
case common.ErrUserLocked:
|
||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
case common.ErrMaintenanceMode:
|
||||
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
|
||||
case common.ErrNeedsConfirmRestore:
|
||||
// 注销账号登录 → 需要二次确认恢复
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"action": "confirm_restore",
|
||||
"message": "你的账号正在注销中,登录将中止注销流程",
|
||||
"message": "你的账号正在注销中,登录将撤销注销并恢复账号",
|
||||
})
|
||||
default:
|
||||
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||
@ -78,7 +71,14 @@ func (ac *AuthController) Login(c *gin.Context) {
|
||||
// 登录成功 → 清除失败计数
|
||||
ac.rateLimiter.Clear(email, ip)
|
||||
|
||||
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
||||
// 创建服务端 session
|
||||
sid, err := ac.sessionManager.Create(user, req.RememberMe, ip, c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
|
||||
|
||||
common.OkWithMessage(c, user, "登录成功")
|
||||
}
|
||||
@ -91,7 +91,7 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, refreshToken, user, err := ac.authService.ConfirmRestore(req, clientIP(c))
|
||||
user, err := ac.authService.ConfirmRestore(req, clientIP(c))
|
||||
if err != nil {
|
||||
switch err {
|
||||
case common.ErrInvalidCred:
|
||||
@ -102,6 +102,12 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
||||
common.OkWithMessage(c, user, "账号已恢复,欢迎回来")
|
||||
sid, err := ac.sessionManager.Create(user, req.RememberMe, clientIP(c), c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
|
||||
common.OkWithMessage(c, user, "注销已撤销,欢迎回来")
|
||||
}
|
||||
|
||||
@ -34,19 +34,20 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 注册 IP 限流:1 分钟 5 次
|
||||
regResult, recordReg := ac.rateLimiter.AllowIP(clientIP(c) + ":register")
|
||||
// 注册 IP 限流:1 分钟 5 次(原子检查+递增)
|
||||
regKey := clientIP(c) + ":register"
|
||||
regResult := ac.rateLimiter.AllowIP(regKey)
|
||||
if regResult.Blocked {
|
||||
common.Error(c, http.StatusTooManyRequests, "注册请求过于频繁,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, refreshToken, user, err := ac.authService.Register(req, clientIP(c))
|
||||
user, err := ac.authService.Register(req, clientIP(c))
|
||||
if err != nil {
|
||||
if recordReg != nil {
|
||||
recordReg()
|
||||
}
|
||||
// 失败计数已在 AllowIP 中原子递增,无需额外记录
|
||||
switch err {
|
||||
case common.ErrMaintenanceMode:
|
||||
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
|
||||
case common.ErrEmailExists:
|
||||
common.Error(c, http.StatusConflict, "该邮箱已注册")
|
||||
case common.ErrWeakPassword:
|
||||
@ -59,40 +60,48 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
||||
// 注册成功 → 清除该 IP 的注册限流计数
|
||||
ac.rateLimiter.ClearIP(regKey)
|
||||
|
||||
sid, err := ac.sessionManager.Create(user, req.RememberMe, clientIP(c), c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
|
||||
|
||||
common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab")
|
||||
}
|
||||
|
||||
// Logout 退出登录:清除所有认证 Cookie
|
||||
// Logout 退出登录:删除服务端 session + 清除 Cookie
|
||||
func (ac *AuthController) Logout(c *gin.Context) {
|
||||
common.ClearAuthCookies(c, ac.cfg)
|
||||
if sid, err := c.Cookie(common.SessionCookieName); err == nil && sid != "" {
|
||||
_ = ac.sessionManager.Destroy(sid)
|
||||
}
|
||||
common.ClearSessionCookie(c, ac.cfg)
|
||||
common.OkMessage(c, "已退出登录")
|
||||
}
|
||||
|
||||
// RefreshToken 用 refresh token 换取新的 access token
|
||||
// RefreshToken 检查登录状态并续期(session 模式下每次请求已自动续期,此端点用于前端显式检查)
|
||||
func (ac *AuthController) RefreshToken(c *gin.Context) {
|
||||
refreshToken, err := c.Cookie(common.RefreshCookieName)
|
||||
if err != nil {
|
||||
sid, err := c.Cookie(common.SessionCookieName)
|
||||
if err != nil || sid == "" {
|
||||
common.Error(c, http.StatusUnauthorized, "请重新登录")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken)
|
||||
if err != nil {
|
||||
common.ClearAuthCookies(c, ac.cfg)
|
||||
switch err {
|
||||
case common.ErrTokenExpired, common.ErrTokenRevoked:
|
||||
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
||||
case common.ErrUserBanned:
|
||||
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
||||
default:
|
||||
common.Error(c, http.StatusUnauthorized, "请重新登录")
|
||||
}
|
||||
s, err := ac.sessionManager.Validate(sid)
|
||||
if err != nil || s == nil {
|
||||
common.ClearSessionCookie(c, ac.cfg)
|
||||
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
|
||||
common.SetAccessCookie(c, accessToken, ac.cfg)
|
||||
|
||||
common.Ok(c, nil)
|
||||
common.Ok(c, gin.H{
|
||||
"uid": s.UserID,
|
||||
"email": s.Email,
|
||||
"username": s.Username,
|
||||
"role": s.Role,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
"metazone.cc/metalab/internal/theme"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -12,32 +14,42 @@ import (
|
||||
|
||||
// AuthController 认证相关页面 + API
|
||||
type AuthController struct {
|
||||
authService authUseCase
|
||||
tokenService tokenRefresher
|
||||
rateLimiter rateLimiter
|
||||
cfg *config.Config
|
||||
authService authUseCase
|
||||
sessionManager *session.Manager
|
||||
rateLimiter rateLimiter
|
||||
cfg *config.Config
|
||||
siteSettings *config.SiteSettings
|
||||
}
|
||||
|
||||
// NewAuthController 构造函数
|
||||
func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter rateLimiter, cfg *config.Config) *AuthController {
|
||||
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg}
|
||||
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config, siteSettings *config.SiteSettings) *AuthController {
|
||||
return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg, siteSettings: siteSettings}
|
||||
}
|
||||
|
||||
// RegisterPage 注册页面(已登录用户重定向到首页,注册关闭时重定向到登录页)
|
||||
// RegisterPage 注册页面(已登录用户重定向到首页)
|
||||
func (ac *AuthController) RegisterPage(c *gin.Context) {
|
||||
if _, exists := c.Get("uid"); exists {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载准则失败")
|
||||
return
|
||||
// 优先从 DB 读取社区准则,为空时 fallback 到静态文件
|
||||
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
|
||||
var guidelines template.HTML
|
||||
if guidelinesHTML != "" {
|
||||
guidelines = template.HTML(guidelinesHTML)
|
||||
} else {
|
||||
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载准则失败")
|
||||
return
|
||||
}
|
||||
guidelines = content
|
||||
}
|
||||
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "注册",
|
||||
"ExtraCSS": "/static/css/auth.css",
|
||||
"Guidelines": guidelines,
|
||||
"Title": "注册",
|
||||
"ExtraCSS": "/static/css/auth.css",
|
||||
"Guidelines": guidelines,
|
||||
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
63
internal/controller/comment_action_controller.go
Normal file
63
internal/controller/comment_action_controller.go
Normal file
@ -0,0 +1,63 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Delete DELETE /api/comments/:id
|
||||
func (ctrl *CommentController) Delete(c *gin.Context) {
|
||||
uid, role, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查由 service 层统一处理
|
||||
isAdmin := model.HasMinRole(role, model.RoleAdmin)
|
||||
if err := ctrl.commentService.Delete(uint(commentID), uid, isAdmin); err != nil {
|
||||
if errors.Is(err, common.ErrPermissionDenied) {
|
||||
common.Error(c, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// SearchUsers GET /api/users/search?q=keyword
|
||||
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
q := c.Query("q")
|
||||
if q == "" {
|
||||
common.Ok(c, []model.UserSearchResult{})
|
||||
return
|
||||
}
|
||||
|
||||
users, err := ctrl.commentService.SearchUsers(q, uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, users)
|
||||
}
|
||||
11
internal/controller/comment_controller.go
Normal file
11
internal/controller/comment_controller.go
Normal file
@ -0,0 +1,11 @@
|
||||
package controller
|
||||
|
||||
// CommentController 评论控制器
|
||||
type CommentController struct {
|
||||
commentService commentUseCase
|
||||
}
|
||||
|
||||
// NewCommentController 构造函数
|
||||
func NewCommentController(cs commentUseCase) *CommentController {
|
||||
return &CommentController{commentService: cs}
|
||||
}
|
||||
52
internal/controller/comment_list_controller.go
Normal file
52
internal/controller/comment_list_controller.go
Normal file
@ -0,0 +1,52 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListRootComments 获取文章顶级评论 GET /api/posts/:id/comments
|
||||
func (ctrl *CommentController) ListRootComments(c *gin.Context) {
|
||||
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
comments, total, err := ctrl.commentService.ListRootComments(uint(postID), page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取评论失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": comments,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// ListReplies 获取顶级评论的全部回复 GET /api/posts/:id/comments/:root_id/replies
|
||||
func (ctrl *CommentController) ListReplies(c *gin.Context) {
|
||||
rootID, err := strconv.ParseUint(c.Param("root_id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||
return
|
||||
}
|
||||
|
||||
replies, err := ctrl.commentService.ListReplies(uint(rootID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取回复失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{"replies": replies})
|
||||
}
|
||||
119
internal/controller/comment_upload_controller.go
Normal file
119
internal/controller/comment_upload_controller.go
Normal file
@ -0,0 +1,119 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UploadImage 上传评论图片 POST /api/comments/upload-image
|
||||
// 需要 LV4+(Exp ≥ 1500),复用帖子图片上传的 WebP 转码逻辑
|
||||
func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
// LV4+ 检查(Exp ≥ 1500)
|
||||
expVal, exists := c.Get("exp")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusForbidden, "无法获取经验值")
|
||||
return
|
||||
}
|
||||
exp := expVal.(int)
|
||||
if exp < 1500 {
|
||||
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查扩展名
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
allowedExt := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||
if !allowedExt[ext] {
|
||||
common.Error(c, http.StatusBadRequest, "仅支持 jpg/jpeg/png/gif/webp 格式")
|
||||
return
|
||||
}
|
||||
|
||||
// 限制 5MB
|
||||
if header.Size > 5<<20 {
|
||||
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "读取文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
storageDir := "storage/uploads/posts"
|
||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
||||
|
||||
var savePath, url string
|
||||
|
||||
// JPEG/PNG 转 WebP
|
||||
if isJPEGPNG {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "图片格式无效")
|
||||
return
|
||||
}
|
||||
webpData := encodeWebPBinary(img, len(data))
|
||||
if webpData != nil && len(webpData) < len(data) {
|
||||
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
os.WriteFile(savePath, webpData, 0644)
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
}
|
||||
|
||||
// 回退存储
|
||||
if savePath == "" {
|
||||
dataOut := data
|
||||
if isJPEGPNG {
|
||||
decImg, _, decErr := image.Decode(bytes.NewReader(data))
|
||||
if decErr == nil {
|
||||
var buf bytes.Buffer
|
||||
if ext == ".png" {
|
||||
png.Encode(&buf, decImg)
|
||||
} else {
|
||||
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||
}
|
||||
dataOut = buf.Bytes()
|
||||
}
|
||||
}
|
||||
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
os.WriteFile(savePath, dataOut, 0644)
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
|
||||
// 返回标准 JSON(前端将 URL 插入评论文本)
|
||||
common.Ok(c, gin.H{"url": url})
|
||||
}
|
||||
72
internal/controller/comment_write_controller.go
Normal file
72
internal/controller/comment_write_controller.go
Normal file
@ -0,0 +1,72 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CreateRoot POST /api/posts/:id/comments
|
||||
func (ctrl *CommentController) CreateRoot(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Body string `json:"body" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请输入评论内容")
|
||||
return
|
||||
}
|
||||
|
||||
comment, err := ctrl.commentService.CreateRoot(uid, uint(postID), req.Body)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, comment)
|
||||
}
|
||||
|
||||
// CreateReply POST /api/comments/:id/reply
|
||||
func (ctrl *CommentController) CreateReply(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
parentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Body string `json:"body" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请输入回复内容")
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := ctrl.commentService.CreateReply(uid, uint(parentID), req.Body)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, reply)
|
||||
}
|
||||
162
internal/controller/energy_controller.go
Normal file
162
internal/controller/energy_controller.go
Normal file
@ -0,0 +1,162 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// EnergyController 域能 API 控制器
|
||||
type EnergyController struct {
|
||||
energySvc energyUseCase
|
||||
}
|
||||
|
||||
// NewEnergyController 构造函数
|
||||
func NewEnergyController(energySvc energyUseCase) *EnergyController {
|
||||
return &EnergyController{energySvc: energySvc}
|
||||
}
|
||||
|
||||
// Energize 赋能文章
|
||||
func (ec *EnergyController) Energize(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PostID uint `json:"post_id" binding:"required"`
|
||||
Amount int `json:"amount" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case common.ErrPostNotFound:
|
||||
common.Error(c, http.StatusNotFound, "文章不存在")
|
||||
case common.ErrCannotEnergizeSelf:
|
||||
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
|
||||
case common.ErrInsufficientEnergy:
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
|
||||
case common.ErrEnergizeLimitReached:
|
||||
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
|
||||
case common.ErrInvalidEnergyAmount:
|
||||
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
|
||||
default:
|
||||
common.Error(c, http.StatusInternalServerError, "赋能失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
truncated := actualAmount != req.Amount
|
||||
common.Ok(c, gin.H{
|
||||
"exp_gained": expGained,
|
||||
"actual_amount": actualAmount,
|
||||
"truncated": truncated,
|
||||
"message": buildEnergizeMessage(expGained, truncated),
|
||||
})
|
||||
}
|
||||
|
||||
// buildEnergizeMessage 根据获得的经验生成提示消息
|
||||
func buildEnergizeMessage(expGained int, truncated bool) string {
|
||||
if truncated {
|
||||
if expGained > 0 {
|
||||
return "单篇额度已满,实际赋能部分额度,+" + strconv.Itoa(expGained) + " 经验"
|
||||
}
|
||||
return "单篇额度已满,实际赋能部分额度"
|
||||
}
|
||||
if expGained > 0 {
|
||||
return "赋能成功!+" + strconv.Itoa(expGained) + " 经验"
|
||||
}
|
||||
return "赋能成功!今日经验已满,对方仍获得激励"
|
||||
}
|
||||
|
||||
// GetEnergyInfo 获取域能余额和经验上限状态,支持 ?post_id= 查询文章赋能总量
|
||||
func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
postIDStr := c.DefaultQuery("post_id", "0")
|
||||
postID, _ := strconv.ParseUint(postIDStr, 10, 64)
|
||||
|
||||
info, err := ec.energySvc.GetEnergyInfo(uid, uint(postID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取域能信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
energyDisplay := float64(info.Energy) / 10
|
||||
common.Ok(c, gin.H{
|
||||
"energy": info.Energy,
|
||||
"energy_display": energyDisplay,
|
||||
"daily_energize_exp": info.DailyEnergizeExp,
|
||||
"daily_exp_cap": info.DailyExpCap,
|
||||
"daily_exp_cap_reached": info.DailyExpCapReached,
|
||||
"post_energize_total": info.PostEnergizeTotal,
|
||||
})
|
||||
}
|
||||
|
||||
// GetEnergyLogs 获取我的域能流水
|
||||
func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
logs, total, err := ec.energySvc.GetEnergyLogs(uid, days, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取流水失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": toEnergyLogViews(logs),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// EnergyLogView 前端展示用的域能流水视图
|
||||
type EnergyLogView struct {
|
||||
ID uint `json:"id"`
|
||||
Amount int `json:"amount"`
|
||||
AmountDisplay float64 `json:"amount_display"`
|
||||
Type string `json:"type"`
|
||||
TypeName string `json:"type_name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {
|
||||
views := make([]EnergyLogView, len(logs))
|
||||
for i, l := range logs {
|
||||
views[i] = EnergyLogView{
|
||||
ID: l.ID,
|
||||
Amount: l.Amount,
|
||||
AmountDisplay: float64(l.Amount) / 10,
|
||||
Type: l.Type,
|
||||
TypeName: model.EnergyLogDisplayNames[l.Type],
|
||||
Description: l.Description,
|
||||
CreatedAt: l.CreatedAt.Format(common.TimeFormat),
|
||||
}
|
||||
}
|
||||
return views
|
||||
}
|
||||
122
internal/controller/favorite_controller.go
Normal file
122
internal/controller/favorite_controller.go
Normal file
@ -0,0 +1,122 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// FavoriteController 收藏夹 API 控制器
|
||||
type FavoriteController struct {
|
||||
svc favoriteUseCase
|
||||
}
|
||||
|
||||
// NewFavoriteController 构造函数
|
||||
func NewFavoriteController(svc favoriteUseCase) *FavoriteController {
|
||||
return &FavoriteController{svc: svc}
|
||||
}
|
||||
|
||||
// ListFolders 我的收藏夹列表 GET /api/folders
|
||||
func (fc *FavoriteController) ListFolders(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := fc.svc.ListFolders(uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取收藏夹失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, result)
|
||||
}
|
||||
|
||||
// CreateFolder 创建收藏夹 POST /api/folders
|
||||
func (fc *FavoriteController) CreateFolder(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := fc.svc.CreateFolder(uid, req.Name, req.Description, req.IsPublic)
|
||||
if err != nil {
|
||||
if err.Error() == "收藏夹名称已存在" || err.Error() == "收藏夹名称不能为空" || err.Error() == "收藏夹名称不能超过 50 个字符" {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
common.Error(c, http.StatusInternalServerError, "创建失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkWithMessage(c, f, "收藏夹创建成功")
|
||||
}
|
||||
|
||||
// UpdateFolder 修改收藏夹 PUT /api/folders/:id
|
||||
func (fc *FavoriteController) UpdateFolder(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := fc.svc.UpdateFolder(uid, uint(folderID), req.Name, req.Description, req.IsPublic); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "收藏夹已更新")
|
||||
}
|
||||
|
||||
// DeleteFolder 删除收藏夹 DELETE /api/folders/:id
|
||||
func (fc *FavoriteController) DeleteFolder(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := fc.svc.DeleteFolder(uid, uint(folderID)); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "收藏夹已删除")
|
||||
}
|
||||
143
internal/controller/favorite_item_controller.go
Normal file
143
internal/controller/favorite_item_controller.go
Normal file
@ -0,0 +1,143 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListFolderItems 获取收藏夹内文章列表 GET /api/folders/:id/posts
|
||||
func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
|
||||
uid, _, _ := common.GetGinUser(c) // 允许未登录查看公开收藏夹
|
||||
|
||||
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
|
||||
if err != nil {
|
||||
if err == common.ErrPermissionDenied {
|
||||
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
|
||||
return
|
||||
}
|
||||
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, result)
|
||||
}
|
||||
|
||||
// AddToFolder 收藏文章 POST /api/folders/:id/posts
|
||||
func (fc *FavoriteController) AddToFolder(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PostID uint `json:"post_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PostID == 0 {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := fc.svc.AddFavorite(uid, req.PostID, func() *uint { v := uint(folderID); return &v }()); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "收藏成功")
|
||||
}
|
||||
|
||||
// RemoveFromFolder 取消收藏 DELETE /api/folders/:id/posts/:postId
|
||||
func (fc *FavoriteController) RemoveFromFolder(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||
return
|
||||
}
|
||||
|
||||
postID, err := strconv.ParseUint(c.Param("postId"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := fc.svc.RemoveFavorite(uid, uint(postID)); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = folderID // 移除不依赖具体收藏夹
|
||||
common.OkMessage(c, "已取消收藏")
|
||||
}
|
||||
|
||||
// GetPostStatus 查询文章收藏状态 GET /api/posts/:id/folder-status
|
||||
func (fc *FavoriteController) GetPostStatus(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Ok(c, gin.H{"favorited": false})
|
||||
return
|
||||
}
|
||||
|
||||
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||
return
|
||||
}
|
||||
|
||||
status, err := fc.svc.GetPostFavoriteStatus(uid, uint(postID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, status)
|
||||
}
|
||||
|
||||
// ToggleFavorite 切换收藏 POST /api/posts/:id/favorite
|
||||
func (fc *FavoriteController) ToggleFavorite(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||
return
|
||||
}
|
||||
|
||||
isFavored, err := fc.svc.ToggleFavorite(uid, uint(postID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"favorited": isFavored,
|
||||
})
|
||||
}
|
||||
188
internal/controller/follow_controller.go
Normal file
188
internal/controller/follow_controller.go
Normal file
@ -0,0 +1,188 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// FollowController 关注 API 控制器
|
||||
type FollowController struct {
|
||||
followSvc followUseCase
|
||||
}
|
||||
|
||||
// NewFollowController 构造函数
|
||||
func NewFollowController(followSvc followUseCase) *FollowController {
|
||||
return &FollowController{followSvc: followSvc}
|
||||
}
|
||||
|
||||
// Toggle 切换关注 POST /api/users/:uid/follow
|
||||
func (fc *FollowController) Toggle(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
|
||||
if err != nil {
|
||||
if err.Error() == "不能关注自己" {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取最新状态
|
||||
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"is_following": isFollowing,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
|
||||
func (fc *FollowController) GetStatus(c *gin.Context) {
|
||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false)
|
||||
|
||||
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, status)
|
||||
}
|
||||
|
||||
// Followers 粉丝列表 GET /api/users/:uid/followers
|
||||
func (fc *FollowController) Followers(c *gin.Context) {
|
||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
uid, _, _ := common.GetGinUser(c) // 允许未登录
|
||||
|
||||
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, result)
|
||||
}
|
||||
|
||||
// Following 关注列表 GET /api/users/:uid/following
|
||||
func (fc *FollowController) Following(c *gin.Context) {
|
||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
uid, _, _ := common.GetGinUser(c)
|
||||
|
||||
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, result)
|
||||
}
|
||||
|
||||
// FollowPageController 关注/粉丝页面控制器
|
||||
type FollowPageController struct {
|
||||
followSvc followUseCase
|
||||
}
|
||||
|
||||
// NewFollowPageController 构造函数
|
||||
func NewFollowPageController(followSvc followUseCase) *FollowPageController {
|
||||
return &FollowPageController{followSvc: followSvc}
|
||||
}
|
||||
|
||||
// FollowersPage 粉丝列表页面
|
||||
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
uid, _, _ := common.GetGinUser(c)
|
||||
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
|
||||
if err != nil {
|
||||
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "follow/followers.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "粉丝列表",
|
||||
"ExtraCSS": "/static/css/follow.css",
|
||||
"Result": result,
|
||||
"TargetUID": targetUID,
|
||||
"Page": result.Page,
|
||||
"TotalPages": result.TotalPages,
|
||||
"HasPrev": result.Page > 1,
|
||||
"HasNext": result.Page < result.TotalPages,
|
||||
"PrevPage": result.Page - 1,
|
||||
"NextPage": result.Page + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
// FollowingPage 关注列表页面
|
||||
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
uid, _, _ := common.GetGinUser(c)
|
||||
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
|
||||
if err != nil {
|
||||
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "follow/following.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "关注列表",
|
||||
"ExtraCSS": "/static/css/follow.css",
|
||||
"Result": result,
|
||||
"TargetUID": targetUID,
|
||||
"Page": result.Page,
|
||||
"TotalPages": result.TotalPages,
|
||||
"HasPrev": result.Page > 1,
|
||||
"HasNext": result.Page < result.TotalPages,
|
||||
"PrevPage": result.Page - 1,
|
||||
"NextPage": result.Page + 1,
|
||||
}))
|
||||
}
|
||||
@ -1,31 +1,33 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"metazone.cc/metalab/internal/middleware"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
)
|
||||
|
||||
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:4 个方法)
|
||||
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:5 个方法)
|
||||
// 注意:Login/Register/ConfirmRestore 不再返回 JWT,session 由 controller 通过 SessionManager 创建
|
||||
type authUseCase interface {
|
||||
Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error)
|
||||
Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
|
||||
ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
|
||||
Register(req model.RegisterRequest, regIP string) (*model.User, error)
|
||||
Login(req model.LoginRequest, loginIP string) (*model.User, error)
|
||||
ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error)
|
||||
CheckEmail(email string) (bool, error)
|
||||
IsRegistrationEnabled() bool
|
||||
}
|
||||
|
||||
// tokenRefresher AuthController 对 TokenService 的最小依赖(ISP:1 个方法)
|
||||
type tokenRefresher interface {
|
||||
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
|
||||
}
|
||||
|
||||
// rateLimiter AuthController 对 RateLimiter 的最小依赖(ISP:3 个方法)
|
||||
// rateLimiter AuthController 对 RateLimiter 的最小依赖(ISP:4 个方法)
|
||||
type rateLimiter interface {
|
||||
AllowAccount(email string) (middleware.RateLimitResult, func())
|
||||
AllowIP(ip string) (middleware.RateLimitResult, func())
|
||||
AllowAccount(email string) middleware.RateLimitResult
|
||||
AllowIP(ip string) middleware.RateLimitResult
|
||||
Clear(email, ip string)
|
||||
ClearIP(ipKey string)
|
||||
}
|
||||
|
||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法)
|
||||
type postUseCase interface {
|
||||
Create(userID uint, title, body string) (*model.Post, error)
|
||||
GetByID(id uint) (*model.Post, error)
|
||||
@ -33,4 +35,154 @@ type postUseCase interface {
|
||||
Update(postID uint, title, body string) error
|
||||
Delete(postID uint) error
|
||||
SubmitForAudit(postID uint) error
|
||||
RecordRead(userID, postID uint)
|
||||
RecordGuestRead(visitorID string, postID uint)
|
||||
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
||||
}
|
||||
|
||||
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
||||
type studioUseCase interface {
|
||||
Create(userID uint, title, body string) (*model.Post, error)
|
||||
GetByID(id uint) (*model.Post, error)
|
||||
Update(postID uint, title, body string) error
|
||||
Delete(postID uint) error
|
||||
SubmitForAudit(postID uint) error
|
||||
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||
GetOverview(userID uint) (*service.StudioOverview, error)
|
||||
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
||||
CanCreatePost(userID uint) bool
|
||||
}
|
||||
|
||||
// spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:5 个方法)
|
||||
type spaceUseCase interface {
|
||||
GetSpaceUser(uid uint) (*model.User, error)
|
||||
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
|
||||
CountUserPosts(uid uint) (int64, error)
|
||||
GetUserStats(uid uint) (likes, favorites, reads int64, err error)
|
||||
UpdateUser(user *model.User) error
|
||||
}
|
||||
|
||||
// favoriteUseCaseForSpace SpaceController 对 FavoriteService 的最小依赖(ISP:收藏夹相关)
|
||||
type favoriteUseCaseForSpace interface {
|
||||
ListFolders(userID uint) (*service.FavoriteListResult, error)
|
||||
ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error)
|
||||
}
|
||||
|
||||
// sessionManager 登录管理对会话管理的最小依赖(ISP:4 个方法)
|
||||
type sessionManager interface {
|
||||
ListByUID(uid uint) ([]*session.Session, error)
|
||||
Destroy(sid string) error
|
||||
DestroyOtherByUID(uid uint, currentSID string) error
|
||||
UpdateRemark(sid string, uid uint, remark string) error
|
||||
}
|
||||
|
||||
// levelUseCase LevelController 对 LevelService 的最小依赖
|
||||
type levelUseCase interface {
|
||||
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||
GetLevel(userID uint) (level int, exp int, checkedIn bool, err error)
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法)
|
||||
type energyUseCase interface {
|
||||
Energize(energizerID uint, postID uint, amount int) (int, int, error)
|
||||
GetEnergyInfo(userID uint, postID uint) (*service.EnergyInfo, error)
|
||||
GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
}
|
||||
|
||||
// energyDeducter 删稿/改名时域能扣减接口(ISP)
|
||||
type energyDeducter interface {
|
||||
DeductOnDeletePost(authorUserID uint, postID uint) error
|
||||
}
|
||||
|
||||
// energyRenameHandler 改名域能操作接口(ISP)
|
||||
type energyRenameHandler interface {
|
||||
DeductOnRename(userID uint) error
|
||||
}
|
||||
|
||||
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:4 个方法)
|
||||
type energyAdminUseCase interface {
|
||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
GetFundBalance() (int, error)
|
||||
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||
}
|
||||
|
||||
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
||||
type commentUseCase interface {
|
||||
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
||||
CreateReply(userID, parentID uint, body string) (*model.Comment, error)
|
||||
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
||||
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
||||
ListReplies(rootID uint) ([]model.Comment, error)
|
||||
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
|
||||
}
|
||||
|
||||
// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法)
|
||||
type reactionUseCase interface {
|
||||
ToggleLike(userID, postID uint) (bool, error)
|
||||
ToggleDislike(userID, postID uint) (bool, error)
|
||||
GetReaction(userID, postID uint) (service.ReactionType, error)
|
||||
GetPostLikesCount(postID uint) (int, error)
|
||||
}
|
||||
|
||||
// followUseCase FollowController 对 FollowService 的最小依赖(ISP:4 个方法)
|
||||
type followUseCase interface {
|
||||
Toggle(followerID, followeeID uint) (bool, error)
|
||||
GetStatus(currentUserID, targetUserID uint) (*service.FollowStatus, error)
|
||||
ListFollowers(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
|
||||
ListFollowing(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
|
||||
}
|
||||
|
||||
// favoriteUseCase FavoriteController 对 FavoriteService 的最小依赖(ISP:9 个方法)
|
||||
type favoriteUseCase interface {
|
||||
ListFolders(userID uint) (*service.FavoriteListResult, error)
|
||||
CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error)
|
||||
UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error
|
||||
DeleteFolder(userID, folderID uint) error
|
||||
ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error)
|
||||
AddFavorite(userID, postID uint, folderID *uint) error
|
||||
RemoveFavorite(userID, postID uint) error
|
||||
ToggleFavorite(userID, postID uint) (bool, error)
|
||||
GetPostFavoriteStatus(userID, postID uint) (*service.FavoriteStatus, error)
|
||||
}
|
||||
|
||||
// profileProvider SettingsController 对 AuthService 的最小依赖(ISP:4 个方法)
|
||||
type profileProvider interface {
|
||||
GetProfile(userID uint) (*model.User, error)
|
||||
UpdateProfile(userID uint, username, bio string) error
|
||||
ChangePassword(userID uint, currentPassword, newPassword string) error
|
||||
DeleteAccount(userID uint, password, reason string) error
|
||||
}
|
||||
|
||||
// avatarProvider SettingsController 头像上传对 Service 的最小依赖(ISP:2 个方法)
|
||||
type avatarProvider interface {
|
||||
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||
}
|
||||
|
||||
// auditSubmittable SettingsController 对 AuditService 的依赖(ISP:4 个方法)
|
||||
type auditSubmittable interface {
|
||||
ShouldAudit(userID uint) (bool, error)
|
||||
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
|
||||
Submit(userID uint, auditType, newValue string) error
|
||||
GetPendingTypes(userID uint) ([]string, error)
|
||||
}
|
||||
|
||||
// sessionConfig SettingsController 对配置的最小依赖(ISP:2 个方法)
|
||||
type sessionConfig interface {
|
||||
GetIdleTimeout() int
|
||||
GetRememberTimeout() int
|
||||
}
|
||||
|
||||
// taskCompleter SettingsController 对 LevelService 的依赖(ISP:2 个方法)
|
||||
type taskCompleter interface {
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
HasCompletedTask(userID uint, taskType string) (bool, error)
|
||||
}
|
||||
|
||||
// notifyPrefReader SettingsController 通知偏好的接口(ISP)
|
||||
type notifyPrefReader interface {
|
||||
GetNotifyPrefs(userID uint) (map[string]bool, error)
|
||||
UpdateNotifyPref(userID uint, key string, enabled bool) error
|
||||
}
|
||||
|
||||
61
internal/controller/level_controller.go
Normal file
61
internal/controller/level_controller.go
Normal file
@ -0,0 +1,61 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LevelController 积分/等级/签到 API
|
||||
type LevelController struct {
|
||||
levelSvc levelUseCase
|
||||
}
|
||||
|
||||
// NewLevelController 构造函数
|
||||
func NewLevelController(levelSvc levelUseCase) *LevelController {
|
||||
return &LevelController{levelSvc: levelSvc}
|
||||
}
|
||||
|
||||
// CheckIn 手动签到 API(已废弃前端手动按钮,保留后端接口)
|
||||
func (lc *LevelController) CheckIn(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
uid := uidVal.(uint)
|
||||
|
||||
checkedIn, newExp, levelUp, err := lc.levelSvc.CheckIn(uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "签到失败")
|
||||
return
|
||||
}
|
||||
|
||||
level, _, _, _ := lc.levelSvc.GetLevel(uid)
|
||||
common.Ok(c, gin.H{
|
||||
"checked_in": checkedIn,
|
||||
"exp": newExp,
|
||||
"level": level,
|
||||
"level_up": levelUp,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLevel 获取当前等级、经验值和签到状态 API
|
||||
func (lc *LevelController) GetLevel(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
uid := uidVal.(uint)
|
||||
|
||||
level, exp, checkedIn, err := lc.levelSvc.GetLevel(uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{"level": level, "exp": exp, "checked_in": checkedIn})
|
||||
}
|
||||
@ -10,9 +10,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// notifProvider MessageController 所需的通知服务接口(ISP:4 个方法)
|
||||
// notifProvider MessageController 所需的通知服务接口(ISP:5 个方法)
|
||||
type notifProvider interface {
|
||||
List(userID uint, page, pageSize int) (*model.NotificationListResult, error)
|
||||
ListByCategory(userID uint, category string, page, pageSize int) (*model.NotificationListResult, error)
|
||||
CountUnread(userID uint) (int64, error)
|
||||
MarkRead(id, userID uint) error
|
||||
MarkAllRead(userID uint) error
|
||||
|
||||
@ -10,16 +10,17 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MessagesPage 消息中心页面(需登录,noindex)
|
||||
// MessagesPage 消息中心页面(需登录,noindex,支持分类 TAB)
|
||||
func (mc *MessageController) MessagesPage(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
if !exists {
|
||||
c.Redirect(http.StatusFound, "/auth/login")
|
||||
c.Abort()
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
uid := uidVal.(uint)
|
||||
|
||||
tab := c.DefaultQuery("tab", "all")
|
||||
|
||||
// 从 query 取分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
@ -27,7 +28,15 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
|
||||
}
|
||||
pageSize := 20
|
||||
|
||||
result, err := mc.notifService.List(uid, page, pageSize)
|
||||
var result *model.NotificationListResult
|
||||
var err error
|
||||
|
||||
if tab == "all" {
|
||||
result, err = mc.notifService.List(uid, page, pageSize)
|
||||
} else {
|
||||
result, err = mc.notifService.ListByCategory(uid, tab, page, pageSize)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
|
||||
}
|
||||
@ -51,5 +60,6 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
|
||||
"UnreadCount": result.Unread,
|
||||
"NotifyTypeNames": model.NotifyTypeNames,
|
||||
"AuditTypeNames": model.AuditTypeNames,
|
||||
"CurrentTab": tab,
|
||||
}))
|
||||
}
|
||||
|
||||
109
internal/controller/post_api_controller.go
Normal file
109
internal/controller/post_api_controller.go
Normal file
@ -0,0 +1,109 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Submit API 提交审核
|
||||
func (ctrl *PostController) Submit(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if post.UserID != uid {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已提交审核")
|
||||
}
|
||||
|
||||
// ListAPI 帖子列表 JSON API
|
||||
func (ctrl *PostController) ListAPI(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, model.PostListResult{
|
||||
Items: posts,
|
||||
Total: total,
|
||||
Page: page,
|
||||
TotalPages: common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// ShowAPI 帖子详情 JSON API
|
||||
func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
|
||||
uid, role, _ := common.GetGinUser(c)
|
||||
|
||||
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
||||
if post.Status != model.PostStatusApproved {
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户阅读计数(已发布帖子,自动去重+防刷)
|
||||
if uid > 0 && post.Status == model.PostStatusApproved {
|
||||
ctrl.postService.RecordRead(uid, post.ID)
|
||||
}
|
||||
|
||||
// 访客阅读计数(基于 Cookie 标识去重+防刷)
|
||||
if uid == 0 && post.Status == model.PostStatusApproved {
|
||||
vid := ctrl.getVisitorID(c)
|
||||
ctrl.postService.RecordGuestRead(vid, post.ID)
|
||||
}
|
||||
|
||||
ctrl.applyShortcode(post)
|
||||
|
||||
common.Ok(c, post)
|
||||
}
|
||||
@ -1,373 +1,27 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"image"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PostController 帖子控制器(SSR 页面 + API)
|
||||
type PostController struct {
|
||||
postService postUseCase
|
||||
postService postUseCase
|
||||
shortcodeSvc *service.ShortcodeService
|
||||
}
|
||||
|
||||
// NewPostController 构造函数
|
||||
func NewPostController(ps postUseCase) *PostController {
|
||||
return &PostController{postService: ps}
|
||||
}
|
||||
|
||||
// ListPage 帖子列表页
|
||||
func (ctrl *PostController) ListPage(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "社区帖子",
|
||||
"Error": "加载帖子列表失败",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []model.Post{}
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
totalPages := p.TotalPages(total)
|
||||
|
||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "社区帖子",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": p.Page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": p.PrevPage(),
|
||||
"NextPage": p.NextPage(totalPages),
|
||||
"Keyword": keyword,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// ShowPage 帖子详情页
|
||||
func (ctrl *PostController) ShowPage(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
var uid uint
|
||||
var role string
|
||||
if uidObj, exists := c.Get("uid"); exists {
|
||||
uid, _ = uidObj.(uint)
|
||||
}
|
||||
if roleObj, exists := c.Get("role"); exists {
|
||||
role, _ = roleObj.(string)
|
||||
}
|
||||
|
||||
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
||||
if post.Status != model.PostStatusApproved {
|
||||
isAuthor := uid == post.UserID
|
||||
isModerator := model.HasMinRole(role, model.RoleModerator)
|
||||
|
||||
if !isAuthor && !isModerator {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
||||
"Title": post.Title,
|
||||
"Post": post,
|
||||
"PostBodyHTML": template.HTML(post.BodyHTML),
|
||||
"UID": uid,
|
||||
"StatusNames": model.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// NewPage 发帖页面
|
||||
func (ctrl *PostController) NewPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "撰写帖子",
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// EditPage 编辑页面
|
||||
func (ctrl *PostController) EditPage(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil || post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到或无法编辑",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查:仅作者和 moderator+ 可编辑
|
||||
var uid uint
|
||||
var role string
|
||||
if uidObj, exists := c.Get("uid"); exists {
|
||||
uid, _ = uidObj.(uint)
|
||||
}
|
||||
if roleObj, exists := c.Get("role"); exists {
|
||||
role, _ = roleObj.(string)
|
||||
}
|
||||
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "编辑帖子",
|
||||
"Post": post,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// Create API 发帖
|
||||
func (ctrl *PostController) Create(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.PostCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "发帖失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkWithMessage(c, post, "发布成功")
|
||||
}
|
||||
|
||||
// Update API 编辑帖子
|
||||
func (ctrl *PostController) Update(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.PostUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查:取帖子归属
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
roleObj, _ := c.Get("role")
|
||||
role, _ := roleObj.(string)
|
||||
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
||||
common.Error(c, http.StatusForbidden, "无权编辑此帖子")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotEdit) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "编辑成功")
|
||||
}
|
||||
|
||||
// Delete API 删除帖子
|
||||
func (ctrl *PostController) Delete(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
|
||||
roleObj, _ := c.Get("role")
|
||||
role, _ := roleObj.(string)
|
||||
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
||||
common.Error(c, http.StatusForbidden, "无权删除此帖子")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// Submit API 提交审核
|
||||
func (ctrl *PostController) Submit(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查:仅帖子作者可提交审核
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
if post.UserID != uid {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
||||
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已提交审核")
|
||||
}
|
||||
|
||||
// ListAPI 帖子列表 JSON API
|
||||
func (ctrl *PostController) ListAPI(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []model.Post{}
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
common.Ok(c, model.PostListResult{
|
||||
Items: posts,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
TotalPages: p.TotalPages(total),
|
||||
})
|
||||
}
|
||||
|
||||
// ShowAPI 帖子详情 JSON API
|
||||
func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
||||
if post.Status != model.PostStatusApproved {
|
||||
var uid uint
|
||||
var role string
|
||||
if uidObj, exists := c.Get("uid"); exists {
|
||||
uid, _ = uidObj.(uint)
|
||||
}
|
||||
if roleObj, exists := c.Get("role"); exists {
|
||||
role, _ = roleObj.(string)
|
||||
}
|
||||
isAuthor := uid == post.UserID
|
||||
isModerator := model.HasMinRole(role, model.RoleModerator)
|
||||
if !isAuthor && !isModerator {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
common.Ok(c, post)
|
||||
func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostController {
|
||||
return &PostController{postService: ps, shortcodeSvc: scs}
|
||||
}
|
||||
|
||||
// allowedImageExt 允许的图片扩展名
|
||||
@ -375,77 +29,61 @@ var allowedImageExt = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
}
|
||||
|
||||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
// visitorCookieName Cookie 名称
|
||||
const visitorCookieName = "visitor_id"
|
||||
|
||||
// visitorCookieMaxAge 访客 Cookie 有效期(30 天)
|
||||
const visitorCookieMaxAge = 30 * 24 * 3600
|
||||
|
||||
// getVisitorID 获取或创建访客标识 Cookie
|
||||
func (ctrl *PostController) getVisitorID(c *gin.Context) string {
|
||||
if cookie, err := c.Cookie(visitorCookieName); err == nil && cookie != "" {
|
||||
return cookie
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
// 生成 16 字节随机 hex(32 字符)
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查扩展名
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if !allowedImageExt[ext] {
|
||||
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
|
||||
return
|
||||
}
|
||||
|
||||
// 限制 5MB
|
||||
maxSize := int64(5 << 20)
|
||||
if header.Size > maxSize {
|
||||
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
// 存储路径
|
||||
storageDir := "storage/uploads/posts"
|
||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 唯一文件名:uid_timestamp.ext
|
||||
ts := time.Now().UnixMilli()
|
||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||
savePath := filepath.Join(storageDir, filename)
|
||||
|
||||
if err := saveUploadedFile(file, savePath); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||
return
|
||||
}
|
||||
|
||||
url := "/uploads/posts/" + filename
|
||||
common.Ok(c, gin.H{"url": url})
|
||||
vid := hex.EncodeToString(b)
|
||||
c.SetCookie(visitorCookieName, vid, visitorCookieMaxAge, "/", "", false, true)
|
||||
return vid
|
||||
}
|
||||
|
||||
// saveUploadedFile 将 multipart.File 写入目标路径
|
||||
func saveUploadedFile(file multipart.File, dst string) error {
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
|
||||
func (ctrl *PostController) applyShortcode(post *model.Post) {
|
||||
if ctrl.shortcodeSvc != nil {
|
||||
result := ctrl.shortcodeSvc.Process(post.Body)
|
||||
post.Body = result.ProcessedBody
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// 限制读取 5MB 防止内存放大
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeWebPBinary 二分查找 WebP 质量,找 ≤ targetBytes 的最高质量(quality 1~80)
|
||||
// 返回 nil 表示最低质量仍超限(几乎不可能发生),应由调用方回退原格式
|
||||
func encodeWebPBinary(img image.Image, targetBytes int) []byte {
|
||||
var buf bytes.Buffer
|
||||
if err := webp.Encode(&buf, img, &webp.Options{Quality: 80}); err != nil {
|
||||
return nil
|
||||
}
|
||||
if buf.Len() <= targetBytes {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
low, high := 1, 80
|
||||
var best []byte
|
||||
for low <= high {
|
||||
mid := (low + high) / 2
|
||||
buf.Reset()
|
||||
if err := webp.Encode(&buf, img, &webp.Options{Quality: float32(mid)}); err != nil {
|
||||
return nil
|
||||
}
|
||||
if buf.Len() <= targetBytes {
|
||||
best = make([]byte, buf.Len())
|
||||
copy(best, buf.Bytes())
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
124
internal/controller/post_page_controller.go
Normal file
124
internal/controller/post_page_controller.go
Normal file
@ -0,0 +1,124 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListPage 帖子列表页
|
||||
func (ctrl *PostController) ListPage(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "社区帖子",
|
||||
"Error": "加载帖子列表失败",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := common.PageCount(total, pageSize)
|
||||
prevPage := page - 1
|
||||
if prevPage < 1 {
|
||||
prevPage = 1
|
||||
}
|
||||
nextPage := page + 1
|
||||
if nextPage > totalPages {
|
||||
nextPage = totalPages
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "社区帖子",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": prevPage,
|
||||
"NextPage": nextPage,
|
||||
"Keyword": keyword,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// ShowPage 帖子详情页
|
||||
func (ctrl *PostController) ShowPage(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
uid, role, _ := common.GetGinUser(c)
|
||||
|
||||
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
||||
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
// 未登录用户:引导登录后回到当前帖子
|
||||
if uid == 0 {
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录用户阅读计数(已发布帖子,自动去重+防刷)
|
||||
if uid > 0 && post.Status == model.PostStatusApproved {
|
||||
ctrl.postService.RecordRead(uid, post.ID)
|
||||
}
|
||||
|
||||
// 访客阅读计数(基于 Cookie 标识去重+防刷)
|
||||
if uid == 0 && post.Status == model.PostStatusApproved {
|
||||
vid := ctrl.getVisitorID(c)
|
||||
ctrl.postService.RecordGuestRead(vid, post.ID)
|
||||
}
|
||||
|
||||
ctrl.applyShortcode(post)
|
||||
|
||||
// Open Graph 社交分享数据
|
||||
ogImage := common.ExtractFirstImage(post.Body)
|
||||
if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
|
||||
prefix := ""
|
||||
if !strings.HasPrefix(ogImage, "/") {
|
||||
prefix = "/"
|
||||
}
|
||||
ogImage = "https://" + c.Request.Host + prefix + ogImage
|
||||
}
|
||||
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
|
||||
|
||||
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
||||
"Title": post.Title,
|
||||
"Post": post,
|
||||
"UID": uid,
|
||||
"StatusNames": common.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
"OgTitle": post.Title,
|
||||
"OgDescription": post.Excerpt,
|
||||
"OgImage": ogImage,
|
||||
"OgURL": ogURL,
|
||||
}))
|
||||
}
|
||||
138
internal/controller/post_upload_controller.go
Normal file
138
internal/controller/post_upload_controller.go
Normal file
@ -0,0 +1,138 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画)
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||||
// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize
|
||||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查扩展名
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if !allowedImageExt[ext] {
|
||||
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
|
||||
return
|
||||
}
|
||||
|
||||
// 限制 5MB
|
||||
maxSize := int64(5 << 20)
|
||||
if header.Size > maxSize {
|
||||
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
// 读取全部数据(后续 WebP 转换需要)
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "读取文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 存储路径
|
||||
storageDir := "storage/uploads/posts"
|
||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
var savePath, url string
|
||||
|
||||
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
||||
isWebP := ext == ".webp"
|
||||
isGIF := ext == ".gif"
|
||||
|
||||
// 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝
|
||||
// JPEG/PNG:解码后做 WebP 压缩(二分查找质量,目标 ≤ 原文件大小)
|
||||
if isJPEGPNG {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||
return
|
||||
}
|
||||
webpData := encodeWebPBinary(img, len(data))
|
||||
if webpData != nil && len(webpData) < len(data) {
|
||||
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GIF:仅解码验证,不转换
|
||||
if isGIF {
|
||||
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WebP:仅解码验证,不重复编码
|
||||
if isWebP {
|
||||
if _, err := webp.Decode(bytes.NewReader(data)); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 回退存储:JPEG/PNG 重编码剥离元数据;WebP 已验证直接存;GIF 不重编(丢动画)
|
||||
if savePath == "" {
|
||||
dataOut := data
|
||||
|
||||
if isJPEGPNG {
|
||||
decImg, _, decErr := image.Decode(bytes.NewReader(data))
|
||||
if decErr == nil {
|
||||
var buf bytes.Buffer
|
||||
var encErr error
|
||||
if ext == ".png" {
|
||||
encErr = png.Encode(&buf, decImg)
|
||||
} else {
|
||||
encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||
}
|
||||
if encErr == nil && buf.Len() > 0 {
|
||||
dataOut = buf.Bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||
return
|
||||
}
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
|
||||
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
||||
}
|
||||
108
internal/controller/reaction_controller.go
Normal file
108
internal/controller/reaction_controller.go
Normal file
@ -0,0 +1,108 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ReactionController 赞/踩 API 控制器
|
||||
type ReactionController struct {
|
||||
reactionSvc reactionUseCase
|
||||
}
|
||||
|
||||
// NewReactionController 构造函数
|
||||
func NewReactionController(reactionSvc reactionUseCase) *ReactionController {
|
||||
return &ReactionController{reactionSvc: reactionSvc}
|
||||
}
|
||||
|
||||
// ToggleLike 切换点赞 POST /api/posts/:id/like
|
||||
func (rc *ReactionController) ToggleLike(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||
return
|
||||
}
|
||||
|
||||
isLiked, err := rc.reactionSvc.ToggleLike(uid, uint(postID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取最新点赞数
|
||||
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"liked": isLiked,
|
||||
"likes_count": likesCount,
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleDislike 切换踩 POST /api/posts/:id/dislike
|
||||
func (rc *ReactionController) ToggleDislike(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||
return
|
||||
}
|
||||
|
||||
isDisliked, err := rc.reactionSvc.ToggleDislike(uid, uint(postID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"disliked": isDisliked,
|
||||
})
|
||||
}
|
||||
|
||||
// GetReaction 查询当前用户对文章的反应状态 GET /api/posts/:id/reaction
|
||||
func (rc *ReactionController) GetReaction(c *gin.Context) {
|
||||
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 未登录用户返回 none + 公开计数
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
|
||||
common.Ok(c, gin.H{
|
||||
"reaction": string(service.ReactionNone),
|
||||
"likes_count": likesCount,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
reaction, err := rc.reactionSvc.GetReaction(uid, uint(postID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"reaction": string(reaction),
|
||||
"likes_count": likesCount,
|
||||
})
|
||||
}
|
||||
@ -55,5 +55,5 @@ func (sc *SettingsController) DeleteAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "账号已注销,7 天内重新登录即可恢复")
|
||||
common.OkMessage(c, "账号已注销,7 天内重新登录即可撤销注销")
|
||||
}
|
||||
|
||||
28
internal/controller/settings_api_audit.go
Normal file
28
internal/controller/settings_api_audit.go
Normal file
@ -0,0 +1,28 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuditStatus 查询当前用户的待审核类型(需登录)
|
||||
func (sc *SettingsController) AuditStatus(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
types, err := sc.auditService.GetPendingTypes(uid.(uint))
|
||||
if err != nil {
|
||||
common.Ok(c, gin.H{"pending_types": []string{}})
|
||||
return
|
||||
}
|
||||
if types == nil {
|
||||
types = []string{}
|
||||
}
|
||||
common.Ok(c, gin.H{"pending_types": types})
|
||||
}
|
||||
79
internal/controller/settings_api_notify.go
Normal file
79
internal/controller/settings_api_notify.go
Normal file
@ -0,0 +1,79 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetNotifyPrefReader 注入通知偏好读写器
|
||||
func (sc *SettingsController) SetNotifyPrefReader(reader notifyPrefReader) {
|
||||
sc.notifyPrefReader = reader
|
||||
}
|
||||
|
||||
// GetNotifyPrefs 获取通知偏好 GET /api/settings/notify-prefs
|
||||
func (sc *SettingsController) GetNotifyPrefs(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
if sc.notifyPrefReader == nil {
|
||||
common.Error(c, http.StatusInternalServerError, "服务不可用")
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := sc.notifyPrefReader.GetNotifyPrefs(uid.(uint))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, prefs)
|
||||
}
|
||||
|
||||
// UpdateNotifyPref 更新通知偏好 PUT /api/settings/notify-prefs
|
||||
func (sc *SettingsController) UpdateNotifyPref(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
if sc.notifyPrefReader == nil {
|
||||
common.Error(c, http.StatusInternalServerError, "服务不可用")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 key 是否合法
|
||||
allowedKeys := map[string]bool{
|
||||
model.NotifyComment: true,
|
||||
model.NotifyCommentReply: true,
|
||||
model.NotifyLikeAggregated: true,
|
||||
model.NotifyFollow: true,
|
||||
}
|
||||
if !allowedKeys[req.Key] {
|
||||
common.Error(c, http.StatusBadRequest, "无效的通知类型")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sc.notifyPrefReader.UpdateNotifyPref(uid.(uint), req.Key, req.Enabled); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "更新失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "通知偏好已更新")
|
||||
}
|
||||
@ -43,6 +43,23 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 改名:首次免费,非首次扣 6 域能
|
||||
if req.Username != "" && req.Username != user.Username && sc.energySvc != nil {
|
||||
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||
if hasCompleted {
|
||||
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||
switch err {
|
||||
case common.ErrInsufficientEnergy:
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||
default:
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
|
||||
handleAuditSubmitError(c, err)
|
||||
return
|
||||
@ -52,11 +69,42 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 管理员及以上直接更新
|
||||
// 先获取当前用户信息,判断是否改名
|
||||
currentUser, err := sc.authService.GetProfile(userID)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 改名:首次免费,非首次扣 6 域能
|
||||
if req.Username != "" && req.Username != currentUser.Username && sc.energySvc != nil {
|
||||
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||
if hasCompleted {
|
||||
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||
switch err {
|
||||
case common.ErrInsufficientEnergy:
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||
default:
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil {
|
||||
handleSettingsError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 触发首次任务奖励(静默失败不影响主流程)
|
||||
if req.Username != "" {
|
||||
_, _, _ = sc.levelSvc.CompleteTask(userID, "username")
|
||||
}
|
||||
if req.Bio != "" {
|
||||
_, _, _ = sc.levelSvc.CompleteTask(userID, "bio")
|
||||
}
|
||||
|
||||
common.OkMessage(c, "个人资料已更新")
|
||||
}
|
||||
|
||||
@ -112,5 +160,8 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 触发首次上传头像奖励
|
||||
_, _, _ = sc.levelSvc.CompleteTask(userID, "avatar")
|
||||
|
||||
common.Ok(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
161
internal/controller/settings_api_sessions.go
Normal file
161
internal/controller/settings_api_sessions.go
Normal file
@ -0,0 +1,161 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListSessions 列出当前用户的所有活跃会话
|
||||
func (sc *SettingsController) ListSessions(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := sc.sessionMgr.ListByUID(uid.(uint))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
currentSID, _ := c.Cookie(common.SessionCookieName)
|
||||
|
||||
type sessionItem struct {
|
||||
ID string `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Remark string `json:"remark"`
|
||||
RememberMe bool `json:"remember_me"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastAccess string `json:"last_access"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
|
||||
var items []sessionItem
|
||||
for _, s := range sessions {
|
||||
items = append(items, sessionItem{
|
||||
ID: s.ID,
|
||||
IP: s.IP,
|
||||
UserAgent: s.UserAgent,
|
||||
Remark: s.Remark,
|
||||
RememberMe: s.RememberMe,
|
||||
CreatedAt: s.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
LastAccess: s.LastAccess.Format("2006-01-02 15:04:05"),
|
||||
IsCurrent: s.ID == currentSID,
|
||||
})
|
||||
}
|
||||
if items == nil {
|
||||
items = []sessionItem{}
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{"sessions": items})
|
||||
}
|
||||
|
||||
// DestroySession 踢出指定会话(需验证归属当前用户)
|
||||
func (sc *SettingsController) DestroySession(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
sid := c.Param("sid")
|
||||
if sid == "" {
|
||||
common.Error(c, http.StatusBadRequest, "缺少会话 ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证目标会话属于当前用户
|
||||
sessions, err := sc.sessionMgr.ListByUID(uid.(uint))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, s := range sessions {
|
||||
if s.ID == sid {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
common.Error(c, http.StatusNotFound, "会话不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 不能踢出当前会话
|
||||
currentSID, _ := c.Cookie(common.SessionCookieName)
|
||||
if sid == currentSID {
|
||||
common.Error(c, http.StatusBadRequest, "不能踢出当前设备,请使用退出登录")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sc.sessionMgr.Destroy(sid); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已踢出该设备")
|
||||
}
|
||||
|
||||
// DestroyOtherSessions 一键踢出非当前设备的所有会话
|
||||
func (sc *SettingsController) DestroyOtherSessions(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
currentSID, _ := c.Cookie(common.SessionCookieName)
|
||||
if currentSID == "" {
|
||||
common.Error(c, http.StatusBadRequest, "无法识别当前会话")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sc.sessionMgr.DestroyOtherByUID(uid.(uint), currentSID); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已踢出所有其他设备")
|
||||
}
|
||||
|
||||
// UpdateSessionRemark 更新指定会话的备注
|
||||
func (sc *SettingsController) UpdateSessionRemark(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
sid := c.Param("sid")
|
||||
if sid == "" {
|
||||
common.Error(c, http.StatusBadRequest, "缺少会话 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "请检查输入")
|
||||
return
|
||||
}
|
||||
|
||||
// 备注长度限制
|
||||
if len(req.Remark) > 32 {
|
||||
common.Error(c, http.StatusBadRequest, "备注不能超过 32 个字符")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sc.sessionMgr.UpdateRemark(sid, uid.(uint), req.Remark); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "备注已更新")
|
||||
}
|
||||
@ -1,61 +1,50 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// profileProvider SettingsController 对 Service 层的最小依赖(ISP:4 个方法)
|
||||
type profileProvider interface {
|
||||
GetProfile(userID uint) (*model.User, error)
|
||||
UpdateProfile(userID uint, username, bio string) error
|
||||
ChangePassword(userID uint, currentPassword, newPassword string) error
|
||||
DeleteAccount(userID uint, password, reason string) error
|
||||
}
|
||||
|
||||
// avatarProvider 头像上传对 Service 层的最小依赖(ISP:2 个方法)
|
||||
type avatarProvider interface {
|
||||
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||
}
|
||||
|
||||
// auditSubmittable 个人设置对审核服务的依赖(ISP:4 个方法)
|
||||
type auditSubmittable interface {
|
||||
ShouldAudit(userID uint) (bool, error)
|
||||
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
|
||||
Submit(userID uint, auditType, newValue string) error
|
||||
GetPendingTypes(userID uint) ([]string, error)
|
||||
}
|
||||
|
||||
// SettingsController 个人设置控制器
|
||||
type SettingsController struct {
|
||||
authService profileProvider
|
||||
avatarService avatarProvider
|
||||
auditService auditSubmittable
|
||||
authService profileProvider
|
||||
avatarService avatarProvider
|
||||
auditService auditSubmittable
|
||||
sessionMgr sessionManager
|
||||
sessionCfg sessionConfig
|
||||
levelSvc taskCompleter
|
||||
energySvc energyRenameHandler
|
||||
notifyPrefReader notifyPrefReader
|
||||
}
|
||||
|
||||
// NewSettingsController 构造函数
|
||||
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController {
|
||||
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService}
|
||||
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig, levelSvc taskCompleter) *SettingsController {
|
||||
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg, levelSvc: levelSvc}
|
||||
}
|
||||
|
||||
// WithEnergyService 链式注入域能服务
|
||||
func (sc *SettingsController) WithEnergyService(svc energyRenameHandler) *SettingsController {
|
||||
sc.energySvc = svc
|
||||
return sc
|
||||
}
|
||||
|
||||
// SettingsPage 个人设置页面(需登录)
|
||||
func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
if !exists {
|
||||
c.Redirect(http.StatusFound, "/auth/login")
|
||||
c.Abort()
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
uid := uidVal.(uint)
|
||||
|
||||
tab := c.Param("tab")
|
||||
if tab != "profile" && tab != "account" {
|
||||
if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" && tab != "favorites" && tab != "notify" {
|
||||
c.String(http.StatusNotFound, "页面不存在")
|
||||
return
|
||||
}
|
||||
@ -69,33 +58,60 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
||||
// 查询待审核类型(用于前端显示审核提示)
|
||||
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
|
||||
|
||||
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "个人设置",
|
||||
"ExtraCSS": "/static/css/settings.css",
|
||||
"ActiveTab": tab,
|
||||
"User": user,
|
||||
"RoleName": model.RoleDisplayNames[user.Role],
|
||||
"StatusName": model.StatusDisplayNames[user.Status],
|
||||
"PendingTypes": pendingTypes,
|
||||
"AuditTypes": model.AuditTypeNames,
|
||||
}))
|
||||
}
|
||||
|
||||
// AuditStatus 查询当前用户的待审核类型(需登录)
|
||||
func (sc *SettingsController) AuditStatus(c *gin.Context) {
|
||||
uid, exists := c.Get("uid")
|
||||
if !exists {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
// 是否已完成首次改名(用于前端确认弹窗显示消耗提示)
|
||||
hasCompletedRename := false
|
||||
if sc.levelSvc != nil {
|
||||
completed, _ := sc.levelSvc.HasCompletedTask(uid, "username")
|
||||
hasCompletedRename = completed
|
||||
}
|
||||
|
||||
types, err := sc.auditService.GetPendingTypes(uid.(uint))
|
||||
if err != nil {
|
||||
common.Ok(c, gin.H{"pending_types": []string{}})
|
||||
return
|
||||
data := gin.H{
|
||||
"Title": "个人设置",
|
||||
"ExtraCSS": "/static/css/settings.css",
|
||||
"ActiveTab": tab,
|
||||
"User": user,
|
||||
"RoleName": model.RoleDisplayNames[user.Role],
|
||||
"StatusName": model.StatusDisplayNames[user.Status],
|
||||
"PendingTypes": pendingTypes,
|
||||
"AuditTypes": model.AuditTypeNames,
|
||||
"HasCompletedRename": hasCompletedRename,
|
||||
}
|
||||
if types == nil {
|
||||
types = []string{}
|
||||
|
||||
// 登录管理 tab 需要额外数据
|
||||
if tab == "sessions" {
|
||||
sessions, _ := sc.sessionMgr.ListByUID(uid)
|
||||
if sessions == nil {
|
||||
sessions = []*session.Session{}
|
||||
}
|
||||
currentSID, _ := c.Cookie(common.SessionCookieName)
|
||||
// 解析每个 session 的设备信息
|
||||
type sessionView struct {
|
||||
*session.Session
|
||||
DeviceInfo session.DeviceInfo
|
||||
IsCurrent bool
|
||||
TTLMinutes int // 剩余有效分钟数(仅非当前设备显示)
|
||||
}
|
||||
var views []sessionView
|
||||
for _, s := range sessions {
|
||||
timeout := sc.sessionCfg.GetIdleTimeout() // 临时会话超时(分钟)
|
||||
if s.RememberMe {
|
||||
timeout = sc.sessionCfg.GetRememberTimeout() // 记住我超时(分钟)
|
||||
}
|
||||
elapsed := int(time.Since(s.LastAccess).Minutes())
|
||||
ttl := timeout - elapsed
|
||||
if ttl < 0 {
|
||||
ttl = 0
|
||||
}
|
||||
views = append(views, sessionView{
|
||||
Session: s,
|
||||
DeviceInfo: session.ParseUA(s.UserAgent),
|
||||
IsCurrent: s.ID == currentSID,
|
||||
TTLMinutes: ttl,
|
||||
})
|
||||
}
|
||||
data["Sessions"] = views
|
||||
data["CurrentSID"] = currentSID
|
||||
}
|
||||
common.Ok(c, gin.H{"pending_types": types})
|
||||
|
||||
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
|
||||
}
|
||||
|
||||
121
internal/controller/space_controller.go
Normal file
121
internal/controller/space_controller.go
Normal file
@ -0,0 +1,121 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
|
||||
type SpaceController struct {
|
||||
spaceService spaceUseCase
|
||||
followSvc followUseCase
|
||||
favoriteSvc favoriteUseCaseForSpace
|
||||
}
|
||||
|
||||
// NewSpaceController 构造函数
|
||||
func NewSpaceController(spaceService spaceUseCase) *SpaceController {
|
||||
return &SpaceController{spaceService: spaceService}
|
||||
}
|
||||
|
||||
// WithFollowService 注入关注服务(可选依赖)
|
||||
func (ctrl *SpaceController) WithFollowService(svc followUseCase) {
|
||||
ctrl.followSvc = svc
|
||||
}
|
||||
|
||||
// WithFavoriteService 注入收藏夹服务(可选依赖)
|
||||
func (ctrl *SpaceController) WithFavoriteService(svc favoriteUseCaseForSpace) {
|
||||
ctrl.favoriteSvc = svc
|
||||
}
|
||||
|
||||
// MySpace 自己的空间(/space)— 需登录,重定向到 /space/{uid}
|
||||
func (ctrl *SpaceController) MySpace(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
|
||||
}
|
||||
|
||||
// ShowSpace 统一空间页面(/space/:uid?tab=articles|following|followers|collections|settings)
|
||||
func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
||||
uidStr := c.Param("uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 64)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "用户不存在",
|
||||
"Error": "用户不存在",
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
spaceUser, err := ctrl.spaceService.GetSpaceUser(uint(uid))
|
||||
if err != nil || spaceUser == nil {
|
||||
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "用户不存在",
|
||||
"Error": "用户不存在",
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// 当前登录用户
|
||||
currentUID, _, _ := common.GetGinUser(c)
|
||||
isOwnSpace := currentUID == uint(uid)
|
||||
|
||||
// 当前激活的 Tab
|
||||
tab := c.DefaultQuery("tab", "articles")
|
||||
|
||||
// 分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 { page = 1 }
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18"))
|
||||
if pageSize < 1 || pageSize > 50 { pageSize = 18 }
|
||||
|
||||
// 构建基础数据(含用户统计)
|
||||
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
|
||||
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
|
||||
|
||||
data := gin.H{
|
||||
"Title": spaceUser.Username + " 的空间",
|
||||
"SpaceUser": spaceUser,
|
||||
"IsOwnSpace": isOwnSpace,
|
||||
"ActiveTab": tab,
|
||||
"CurrentUID": currentUID,
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
"PostCount": postCount,
|
||||
"TotalLikes": totalLikes,
|
||||
"TotalFavorites": totalFavorites,
|
||||
"TotalReads": totalReads,
|
||||
}
|
||||
|
||||
// ---- 根据 Tab 加载对应数据 ----
|
||||
switch tab {
|
||||
case "following":
|
||||
ctrl.loadFollowingData(c, data, uint(uid), currentUID, page, pageSize)
|
||||
case "followers":
|
||||
ctrl.loadFollowersData(c, data, uint(uid), currentUID, page, pageSize)
|
||||
case "collections":
|
||||
if !isOwnSpace {
|
||||
// 访客不可看收藏夹,重定向到文章
|
||||
c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles")
|
||||
return
|
||||
}
|
||||
ctrl.loadCollectionsData(c, data, currentUID, page, pageSize)
|
||||
case "settings":
|
||||
if !isOwnSpace {
|
||||
c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles")
|
||||
return
|
||||
}
|
||||
// 设置页无需额外数据,模板中直接渲染隐私开关
|
||||
default: // articles
|
||||
ctrl.loadArticlesData(c, data, uint(uid), page, pageSize)
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, data))
|
||||
}
|
||||
162
internal/controller/space_loaders.go
Normal file
162
internal/controller/space_loaders.go
Normal file
@ -0,0 +1,162 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// loadArticlesData 加载文章列表数据
|
||||
func (ctrl *SpaceController) loadArticlesData(c *gin.Context, data gin.H, uid uint, page, pageSize int) {
|
||||
posts, total, err := ctrl.spaceService.GetPostsByUser(uid, page, pageSize)
|
||||
if err != nil {
|
||||
posts = []model.Post{}
|
||||
total = 0
|
||||
}
|
||||
totalPages := common.PageCount(total, pageSize)
|
||||
data["Posts"] = posts
|
||||
data["Total"] = total
|
||||
data["Page"] = page
|
||||
data["TotalPages"] = totalPages
|
||||
data["PrevPage"] = max(1, page-1)
|
||||
data["NextPage"] = min(totalPages, page+1)
|
||||
}
|
||||
|
||||
// loadFollowingData 加载关注列表数据
|
||||
func (ctrl *SpaceController) loadFollowingData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) {
|
||||
if ctrl.followSvc == nil {
|
||||
data["FollowingResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||
return
|
||||
}
|
||||
res, err := ctrl.followSvc.ListFollowing(targetUID, currentUID, page, pageSize)
|
||||
if err != nil || res == nil {
|
||||
res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||
}
|
||||
data["FollowingResult"] = res
|
||||
}
|
||||
|
||||
// loadFollowersData 加载粉丝列表数据
|
||||
func (ctrl *SpaceController) loadFollowersData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) {
|
||||
if ctrl.followSvc == nil {
|
||||
data["FollowersResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||
return
|
||||
}
|
||||
res, err := ctrl.followSvc.ListFollowers(targetUID, currentUID, page, pageSize)
|
||||
if err != nil || res == nil {
|
||||
res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||
}
|
||||
data["FollowersResult"] = res
|
||||
}
|
||||
|
||||
// loadCollectionsData 加载收藏夹数据
|
||||
func (ctrl *SpaceController) loadCollectionsData(c *gin.Context, data gin.H, uid uint, page, pageSize int) {
|
||||
if ctrl.favoriteSvc == nil {
|
||||
data["FoldersResult"] = &service.FavoriteListResult{Folders: []model.Folder{}}
|
||||
return
|
||||
}
|
||||
|
||||
// 收藏夹列表
|
||||
folders, err := ctrl.favoriteSvc.ListFolders(uid)
|
||||
if err != nil || folders == nil {
|
||||
folders = &service.FavoriteListResult{Folders: []model.Folder{}}
|
||||
}
|
||||
data["FoldersResult"] = folders
|
||||
|
||||
// 默认展示第一个收藏夹的内容
|
||||
folderParam := c.DefaultQuery("folder", "")
|
||||
var activeFolderID uint
|
||||
if folderParam != "" {
|
||||
fid, parseErr := strconv.ParseUint(folderParam, 10, 64)
|
||||
if parseErr == nil {
|
||||
activeFolderID = uint(fid)
|
||||
}
|
||||
}
|
||||
if activeFolderID == 0 && len(folders.Folders) > 0 {
|
||||
activeFolderID = folders.Folders[0].ID
|
||||
}
|
||||
|
||||
var folderItems *service.FolderItemsResult
|
||||
if activeFolderID > 0 {
|
||||
items, itemErr := ctrl.favoriteSvc.ListFolderItems(uid, activeFolderID, page, pageSize)
|
||||
if itemErr != nil || items == nil {
|
||||
folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0}
|
||||
} else {
|
||||
folderItems = items
|
||||
}
|
||||
}
|
||||
if folderItems == nil {
|
||||
folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0}
|
||||
}
|
||||
data["FolderItems"] = folderItems
|
||||
data["ActiveFolderID"] = activeFolderID
|
||||
|
||||
// 找到当前激活的收藏夹对象传给模板
|
||||
var activeFolder *model.Folder
|
||||
for i := range folders.Folders {
|
||||
if folders.Folders[i].ID == activeFolderID {
|
||||
activeFolder = &folders.Folders[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
data["ActiveFolder"] = activeFolder
|
||||
|
||||
data["Page"] = page
|
||||
data["TotalPages"] = folderItems.TotalPages
|
||||
data["PrevPage"] = max(1, page-1)
|
||||
data["NextPage"] = min(folderItems.TotalPages, page+1)
|
||||
}
|
||||
|
||||
// UpdatePrivacy PUT /api/space/privacy — 更新隐私设置
|
||||
func (ctrl *SpaceController) UpdatePrivacy(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Field string `json:"field"`
|
||||
Value bool `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 支持的字段映射(仅处理已实现的字段)
|
||||
allowedFields := map[string]string{
|
||||
"follow_list": "follow_list_public",
|
||||
"follower_list": "follower_list_public",
|
||||
}
|
||||
|
||||
column, ok := allowedFields[req.Field]
|
||||
if !ok {
|
||||
common.OkMessage(c, "已保存") // 未实现字段直接返回成功,前端不感知
|
||||
return
|
||||
}
|
||||
|
||||
user, err := ctrl.spaceService.GetSpaceUser(uid)
|
||||
if err != nil || user == nil {
|
||||
common.Error(c, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
switch column {
|
||||
case "follow_list_public":
|
||||
user.FollowListPublic = req.Value
|
||||
case "follower_list_public":
|
||||
user.FollowerListPublic = req.Value
|
||||
}
|
||||
|
||||
if err := ctrl.spaceService.UpdateUser(user); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "保存失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "隐私设置已更新")
|
||||
}
|
||||
133
internal/controller/studio_action_controller.go
Normal file
133
internal/controller/studio_action_controller.go
Normal file
@ -0,0 +1,133 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Delete 删除帖子
|
||||
func (ctrl *StudioController) Delete(c *gin.Context) {
|
||||
uid, role, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
post, getErr := ctrl.postService.GetByID(uint(id))
|
||||
if getErr != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return
|
||||
}
|
||||
|
||||
authorID := post.UserID
|
||||
|
||||
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 删稿扣作者域能(无视负数,有限重试后静默忽略)
|
||||
if ctrl.energySvc != nil {
|
||||
const maxRetries = 3
|
||||
backoff := 100 * time.Millisecond
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if err := ctrl.energySvc.DeductOnDeletePost(authorID, uint(id)); err == nil {
|
||||
break
|
||||
}
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
} else {
|
||||
log.Printf("删稿扣域能失败(user=%d, post=%d): %v", authorID, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
common.OkMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// Submit 提交审核
|
||||
func (ctrl *StudioController) Submit(c *gin.Context) {
|
||||
uid, role, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
post, getErr := ctrl.postService.GetByID(uint(id))
|
||||
if getErr != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "已提交审核")
|
||||
}
|
||||
|
||||
// TrendsAPI 创作中心趋势数据 API GET /api/studio/trends?period=7d|30d|90d
|
||||
func (ctrl *StudioController) TrendsAPI(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
if ctrl.trendsSvc == nil {
|
||||
common.Error(c, http.StatusInternalServerError, "趋势服务不可用")
|
||||
return
|
||||
}
|
||||
|
||||
period := c.DefaultQuery("period", "7d")
|
||||
var days int
|
||||
switch period {
|
||||
case "7d":
|
||||
days = 7
|
||||
case "30d":
|
||||
days = 30
|
||||
case "90d":
|
||||
days = 90
|
||||
default:
|
||||
common.Error(c, http.StatusBadRequest, "无效的时间范围,支持 7d/30d/90d")
|
||||
return
|
||||
}
|
||||
|
||||
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
|
||||
result, err := ctrl.trendsSvc.GetTrends(uid, since)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取趋势数据失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, result)
|
||||
}
|
||||
54
internal/controller/studio_api_controller.go
Normal file
54
internal/controller/studio_api_controller.go
Normal file
@ -0,0 +1,54 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OverviewAPI 创作中心数据概览
|
||||
func (ctrl *StudioController) OverviewAPI(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := ctrl.postService.GetOverview(uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取数据失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, overview)
|
||||
}
|
||||
|
||||
// ListPostsAPI 我的帖子列表(支持状态筛选)
|
||||
func (ctrl *StudioController) ListPostsAPI(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": posts,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
34
internal/controller/studio_controller.go
Normal file
34
internal/controller/studio_controller.go
Normal file
@ -0,0 +1,34 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"metazone.cc/metalab/internal/service"
|
||||
)
|
||||
|
||||
// trendsUseCase StudioController 对趋势服务的最小依赖(ISP)
|
||||
type trendsUseCase interface {
|
||||
GetTrends(userID uint, since string) (*service.TrendResult, error)
|
||||
}
|
||||
|
||||
// StudioController 创作中心控制器(SSR 页面 + API)
|
||||
type StudioController struct {
|
||||
postService studioUseCase
|
||||
energySvc energyDeducter
|
||||
trendsSvc trendsUseCase
|
||||
}
|
||||
|
||||
// NewStudioController 构造函数
|
||||
func NewStudioController(ps studioUseCase) *StudioController {
|
||||
return &StudioController{postService: ps}
|
||||
}
|
||||
|
||||
// WithEnergyService 链式注入域能服务
|
||||
func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioController {
|
||||
ctrl.energySvc = svc
|
||||
return ctrl
|
||||
}
|
||||
|
||||
// WithTrendsService 链式注入趋势服务
|
||||
func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioController {
|
||||
ctrl.trendsSvc = svc
|
||||
return ctrl
|
||||
}
|
||||
118
internal/controller/studio_page_controller.go
Normal file
118
internal/controller/studio_page_controller.go
Normal file
@ -0,0 +1,118 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OverviewPage 创作中心首页(数据概览)
|
||||
func (ctrl *StudioController) OverviewPage(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := ctrl.postService.GetOverview(uid)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "创作中心",
|
||||
"Error": "加载数据失败",
|
||||
"ActiveTab": "overview",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "创作中心",
|
||||
"Overview": overview,
|
||||
"StatusNames": common.PostStatusDisplayNames,
|
||||
"ActiveTab": "overview",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// PostsPage 内容管理页
|
||||
func (ctrl *StudioController) PostsPage(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "内容管理 - 创作中心",
|
||||
"Error": "加载失败",
|
||||
"ActiveTab": "posts",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
overview, err2 := ctrl.postService.GetOverview(uid)
|
||||
if err2 != nil {
|
||||
log.Printf("[PostsPage] GetOverview failed for user %d: %v", uid, err2)
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "内容管理 - 创作中心",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": page,
|
||||
"PageSize": pageSize,
|
||||
"TotalPages": common.PageCount(total, pageSize),
|
||||
"CurrentStatus": status,
|
||||
"Overview": overview,
|
||||
"StatusNames": common.PostStatusDisplayNames,
|
||||
"ActiveTab": "posts",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// DraftsPage 草稿箱页面
|
||||
func (ctrl *StudioController) DraftsPage(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
posts, total, err := ctrl.postService.ListByUser(uid, model.PostStatusDraft, page, pageSize)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "草稿箱 - 创作中心",
|
||||
"Error": "加载失败",
|
||||
"ActiveTab": "drafts",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "草稿箱 - 创作中心",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": page,
|
||||
"PageSize": pageSize,
|
||||
"TotalPages": common.PageCount(total, pageSize),
|
||||
"StatusNames": common.PostStatusDisplayNames,
|
||||
"ActiveTab": "drafts",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
}
|
||||
78
internal/controller/studio_post_api_controller.go
Normal file
78
internal/controller/studio_post_api_controller.go
Normal file
@ -0,0 +1,78 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Create 创建/发布帖子
|
||||
func (ctrl *StudioController) Create(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
// LV0 用户不允许投稿
|
||||
if !ctrl.postService.CanCreatePost(uid) {
|
||||
common.Error(c, http.StatusForbidden, "经验不足,Lv1 解锁投稿")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.PostCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "发布失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkWithMessage(c, post, "发布成功")
|
||||
}
|
||||
|
||||
// Update 编辑帖子
|
||||
func (ctrl *StudioController) Update(c *gin.Context) {
|
||||
uid, role, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.PostUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
post, getErr := ctrl.postService.GetByID(uint(id))
|
||||
if getErr != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "编辑成功")
|
||||
}
|
||||
77
internal/controller/studio_write_controller.go
Normal file
77
internal/controller/studio_write_controller.go
Normal file
@ -0,0 +1,77 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// WritePage 写文章页面
|
||||
func (ctrl *StudioController) WritePage(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 支持编辑模式:传入 post_id 参数
|
||||
postIDStr := c.Query("id")
|
||||
if postIDStr != "" {
|
||||
id, err := strconv.ParseUint(postIDStr, 10, 64)
|
||||
if err == nil {
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err == nil {
|
||||
_, role, _ := common.GetGinUser(c)
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "编辑文章 - 创作中心",
|
||||
"Post": post,
|
||||
"ActiveTab": "write",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "写文章 - 创作中心",
|
||||
"ActiveTab": "write",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
}
|
||||
|
||||
// AnalyticsPage 数据分析页面
|
||||
func (ctrl *StudioController) AnalyticsPage(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.RedirectToLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := ctrl.postService.GetOverview(uid)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "数据分析 - 创作中心",
|
||||
"Error": "加载失败",
|
||||
"ActiveTab": "analytics",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "数据分析 - 创作中心",
|
||||
"Overview": overview,
|
||||
"ActiveTab": "analytics",
|
||||
"ExtraCSS": "/static/css/studio.css",
|
||||
}))
|
||||
}
|
||||
@ -1,48 +1,152 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
|
||||
// autoCheckIner AuthMiddleware 对自动签到服务的最小依赖
|
||||
type autoCheckIner interface {
|
||||
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager)
|
||||
type AuthMiddleware struct {
|
||||
cfg *config.Config
|
||||
userRepo tokenVersionStore
|
||||
cfg *config.Config
|
||||
sessionManager *session.Manager
|
||||
levelSvc autoCheckIner
|
||||
}
|
||||
|
||||
// NewAuthMiddleware 构造函数
|
||||
func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware {
|
||||
return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
|
||||
func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware {
|
||||
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
||||
}
|
||||
|
||||
// Required 登录认证中间件:校验 JWT → 检查 token_version → 注入用户信息
|
||||
// WithLevelService 链式注入等级服务(用于自动签到)
|
||||
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
|
||||
am.levelSvc = svc
|
||||
return am
|
||||
}
|
||||
|
||||
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
|
||||
// 页面请求(非 /api/ 前缀)→ 验证失败清除 cookie 并重定向到登录页
|
||||
// API 请求 → 验证失败返回 401 JSON
|
||||
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := am.authenticateToken(c)
|
||||
if err != nil {
|
||||
common.ClearAuthCookies(c, am.cfg)
|
||||
c.AbortWithStatusJSON(401, gin.H{
|
||||
"success": false, "message": "登录已过期,请重新登录",
|
||||
})
|
||||
sid, err := c.Cookie(common.SessionCookieName)
|
||||
if err != nil || sid == "" {
|
||||
am.abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
injectUserContext(c, claims)
|
||||
|
||||
s, err := am.sessionManager.Validate(sid)
|
||||
if err != nil || s == nil {
|
||||
am.abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
injectSessionContext(c, s)
|
||||
am.tryAutoCheckIn(c, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行
|
||||
// abortUnauthorized 统一处理未认证:API 返回 JSON,页面请求跳转登录
|
||||
func (am *AuthMiddleware) abortUnauthorized(c *gin.Context) {
|
||||
common.ClearSessionCookie(c, am.cfg)
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false, "message": "登录已过期,请重新登录",
|
||||
})
|
||||
} else {
|
||||
common.RedirectToLogin(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional 可选认证:已登录则注入用户信息,未登录也放行
|
||||
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := am.authenticateToken(c)
|
||||
if err != nil {
|
||||
sid, err := c.Cookie(common.SessionCookieName)
|
||||
if err != nil || sid == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
injectUserContext(c, claims)
|
||||
|
||||
s, err := am.sessionManager.Validate(sid)
|
||||
if err != nil || s == nil {
|
||||
common.ClearSessionCookie(c, am.cfg)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
injectSessionContext(c, s)
|
||||
am.tryAutoCheckIn(c, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// tryAutoCheckIn 尝试自动签到,并同步 session Exp 与 DB(静默失败,不影响主流程)
|
||||
// CheckIn 即使已签到也会返回 DB 中最新的 Exp,这里始终与 session 比较,
|
||||
// 确保 CompleteTask 等非签到路径的 Exp 变化也能同步到 session,避免 NAV 显示旧等级
|
||||
// 被封禁用户跳过自动签到
|
||||
func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) {
|
||||
if am.levelSvc == nil || s == nil {
|
||||
return
|
||||
}
|
||||
if s.Status == model.StatusBanned {
|
||||
return
|
||||
}
|
||||
_, newExp, _, _ := am.levelSvc.CheckIn(s.UserID)
|
||||
// 只允许 Exp 只增不减:DB 异常返回 0 或主从延迟读到旧值时不会回退
|
||||
if newExp > s.Exp {
|
||||
s.Exp = newExp
|
||||
_ = am.sessionManager.UpdateSession(s)
|
||||
c.Set("exp", newExp)
|
||||
}
|
||||
}
|
||||
|
||||
// injectSessionContext 将会话中的用户信息注入 gin context
|
||||
func injectSessionContext(c *gin.Context, s *session.Session) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
c.Set("uid", s.UserID)
|
||||
c.Set("email", s.Email)
|
||||
c.Set("username", s.Username)
|
||||
c.Set("avatar", s.Avatar)
|
||||
c.Set("role", s.Role)
|
||||
c.Set("status", s.Status)
|
||||
c.Set("exp", s.Exp)
|
||||
c.Set("sid", s.ID)
|
||||
}
|
||||
|
||||
// BannedWriteGuard 封禁用户写操作守卫
|
||||
// 仅拦截 POST/PUT/DELETE/PATCH 请求,GET/HEAD/OPTIONS 放行
|
||||
// 需在 Required() 之后调用,确保上下文中已有用户 status
|
||||
func (am *AuthMiddleware) BannedWriteGuard() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 只检查写操作
|
||||
switch c.Request.Method {
|
||||
case "GET", "HEAD", "OPTIONS":
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
status, _ := c.Get("status")
|
||||
if status == model.StatusBanned {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "账号已被封禁,无法执行此操作",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,16 +12,25 @@ import (
|
||||
// AdminAuth 管理后台页面认证:失败时 302 跳首页而非返回 JSON
|
||||
func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := am.authenticateToken(c)
|
||||
if err != nil {
|
||||
log.Printf("[AdminAuth] auth failed path=%s err=%v → 302", c.Request.URL.Path, err)
|
||||
common.ClearAuthCookies(c, am.cfg)
|
||||
sid, err := c.Cookie(common.SessionCookieName)
|
||||
if err != nil || sid == "" {
|
||||
common.ClearSessionCookie(c, am.cfg)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path)
|
||||
injectUserContext(c, claims)
|
||||
|
||||
s, err := am.sessionManager.Validate(sid)
|
||||
if err != nil || s == nil {
|
||||
log.Printf("[AdminAuth] session invalid path=%s → 302", c.Request.URL.Path)
|
||||
common.ClearSessionCookie(c, am.cfg)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[AdminAuth] OK uid=%d role=%s path=%s", s.UserID, s.Role, c.Request.URL.Path)
|
||||
injectSessionContext(c, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// parseToken 解析并验证 JWT
|
||||
func parseToken(tokenStr, secret string) (jwt.MapClaims, error) {
|
||||
now := time.Now()
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
log.Printf("[parseToken] FAIL: now=%v err=%v (secret_len=%d token_len=%d)",
|
||||
now, err, len(secret), len(tokenStr))
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
if exp, exists := claims["exp"]; exists {
|
||||
var expTime time.Time
|
||||
switch v := exp.(type) {
|
||||
case float64:
|
||||
expTime = time.Unix(int64(v), 0)
|
||||
case *jwt.NumericDate:
|
||||
expTime = v.Time
|
||||
}
|
||||
if !expTime.IsZero() {
|
||||
log.Printf("[parseToken] OK: exp=%v (%d) now=%v isExpired=%v",
|
||||
expTime, expTime.Unix(), now, now.After(expTime))
|
||||
}
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// IsLoginPage 检查是否已在登录状态,已登录用户跳过登录/注册页
|
||||
func IsLoginPage(c *gin.Context) bool {
|
||||
return strings.HasPrefix(c.Request.URL.Path, "/auth/")
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// authenticateToken 统一的认证核心流程:读 Cookie → 解析 JWT → 检查 token_version
|
||||
func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, error) {
|
||||
tokenStr, err := c.Cookie(common.CookieName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid := uint(claims["uid"].(float64))
|
||||
tokenVer := int(claims["ver"].(float64))
|
||||
currentVer, err := am.userRepo.FindTokenVersion(uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokenVer != currentVer {
|
||||
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
|
||||
return nil, common.ErrTokenRevoked
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// injectUserContext 将 JWT claims 中的用户信息注入 gin context
|
||||
func injectUserContext(c *gin.Context, claims jwt.MapClaims) {
|
||||
if claims == nil {
|
||||
return
|
||||
}
|
||||
uid, ok := claims["uid"].(float64)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Set("uid", uint(uid))
|
||||
c.Set("email", claims["email"])
|
||||
c.Set("username", claims["username"])
|
||||
c.Set("role", claims["role"])
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/config"
|
||||
@ -43,7 +45,9 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if !constantTimeEq(cookieToken, headerToken) {
|
||||
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
|
||||
log.Printf("[CSRF] MISMATCH | path=%s | cookie_len=%d | header_len=%d",
|
||||
c.Request.URL.Path, len(cookieToken), len(headerToken))
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"success": false, "message": "CSRF 验证失败",
|
||||
})
|
||||
|
||||
@ -26,7 +26,7 @@ func SetCSRFToken(c *gin.Context, cfg *config.Config) string {
|
||||
}
|
||||
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
maxAge := int(cfg.JWT.RememberExpire * 3600)
|
||||
maxAge := cfg.Session.RememberTimeout * 60
|
||||
c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false)
|
||||
c.Set(csrfMetaName, token)
|
||||
return token
|
||||
@ -41,14 +41,4 @@ func generateCSRFToken() (string, error) {
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// constantTimeEq 恒定时间字符串比较(防时序攻击)
|
||||
func constantTimeEq(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var result byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
result |= a[i] ^ b[i]
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
|
||||
90
internal/middleware/maintenance.go
Normal file
90
internal/middleware/maintenance.go
Normal file
@ -0,0 +1,90 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MaintenanceMiddleware 维护模式中间件:维护期间仅站长(Owner)可访问
|
||||
type MaintenanceMiddleware struct {
|
||||
cfg *config.Config
|
||||
siteSettings *config.SiteSettings
|
||||
sessionManager *session.Manager
|
||||
}
|
||||
|
||||
// NewMaintenanceMiddleware 构造函数
|
||||
func NewMaintenanceMiddleware(cfg *config.Config, siteSettings *config.SiteSettings, sm *session.Manager) *MaintenanceMiddleware {
|
||||
return &MaintenanceMiddleware{cfg: cfg, siteSettings: siteSettings, sessionManager: sm}
|
||||
}
|
||||
|
||||
// Handler 返回 Gin 中间件处理函数
|
||||
func (mm *MaintenanceMiddleware) Handler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 未启用维护模式 → 放行
|
||||
if !mm.siteSettings.IsMaintenanceEnabled() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 白名单:登录相关页面和 API 始终可访问
|
||||
if isMaintenanceWhitelist(path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 读取 session cookie 并验证
|
||||
sid, err := c.Cookie(common.SessionCookieName)
|
||||
if err != nil || sid == "" {
|
||||
blockAccess(c, path)
|
||||
return
|
||||
}
|
||||
|
||||
s, err := mm.sessionManager.Validate(sid)
|
||||
if err != nil || s == nil {
|
||||
blockAccess(c, path)
|
||||
return
|
||||
}
|
||||
|
||||
if !model.HasMinRole(s.Role, model.RoleOwner) {
|
||||
blockAccess(c, path)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isMaintenanceWhitelist 判断路径是否在维护白名单中
|
||||
func isMaintenanceWhitelist(path string) bool {
|
||||
if path == "/auth/login" || strings.HasPrefix(path, "/api/auth/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/static/") || strings.HasPrefix(path, "/shared/") {
|
||||
return true
|
||||
}
|
||||
if path == "/favicon.ico" || path == "/favicon.svg" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// blockAccess 根据请求类型阻止访问
|
||||
func blockAccess(c *gin.Context, path string) {
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/admin/api/") {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "社区正在维护中,仅站长可访问",
|
||||
})
|
||||
return
|
||||
}
|
||||
common.RedirectToLogin(c)
|
||||
}
|
||||
24
internal/middleware/rate.go
Normal file
24
internal/middleware/rate.go
Normal file
@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SensitiveRateLimit 敏感操作(改密/注销/签到)IP 维度限流中间件。
|
||||
// 1 分钟最多 5 次请求,超限后封禁 5 分钟。
|
||||
func SensitiveRateLimit(rl *RateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result := rl.AllowSensitive(c.ClientIP())
|
||||
if result.Blocked {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"success": false,
|
||||
"error": result.Message,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@ -21,12 +21,19 @@ func NewRateLimiter() *RateLimiter {
|
||||
return rl
|
||||
}
|
||||
|
||||
// AllowAccount 检查账户维度是否允许登录尝试
|
||||
func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) {
|
||||
return rl.check(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
|
||||
// AllowAccount 原子检查+递增帐户维度。未被封禁则递增失败计数并返回允许。
|
||||
// 调用方在操作成功(如登录成功)时应调用 Clear 清除计数。
|
||||
func (rl *RateLimiter) AllowAccount(email string) RateLimitResult {
|
||||
return rl.try(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
|
||||
}
|
||||
|
||||
// AllowIP 检查 IP 维度是否允许登录尝试
|
||||
func (rl *RateLimiter) AllowIP(ip string) (RateLimitResult, func()) {
|
||||
return rl.check(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
|
||||
// AllowIP 原子检查+递增 IP 维度。未被封禁则递增失败计数并返回允许。
|
||||
// 调用方在操作成功时应调用 Clear 清除计数。
|
||||
func (rl *RateLimiter) AllowIP(ip string) RateLimitResult {
|
||||
return rl.try(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
|
||||
}
|
||||
|
||||
// AllowSensitive 敏感操作限流(改密/注销/签到),基于 IP,1 分钟最多 5 次,超限封禁 5 分钟。
|
||||
func (rl *RateLimiter) AllowSensitive(ip string) RateLimitResult {
|
||||
return rl.try(rl.ipFailures, "sensitive:"+ip, sensitiveWindow, sensitiveBlockDur, sensitiveMaxRequests, false)
|
||||
}
|
||||
|
||||
@ -10,6 +10,13 @@ func (rl *RateLimiter) Clear(email, ip string) {
|
||||
delete(rl.ipFailures, ip)
|
||||
}
|
||||
|
||||
// ClearIP 按 IP key 清除失败计数(用于注册等仅 IP 维度的限流)
|
||||
func (rl *RateLimiter) ClearIP(ipKey string) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
delete(rl.ipFailures, ipKey)
|
||||
}
|
||||
|
||||
// cleanupLoop 定期清理过期条目
|
||||
func (rl *RateLimiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
|
||||
@ -21,6 +21,11 @@ const (
|
||||
ipBlockDur = 1 * time.Minute
|
||||
cleanupInterval = 2 * time.Minute
|
||||
maxEntries = 10000
|
||||
|
||||
// 敏感操作限流:改密/注销/签到
|
||||
sensitiveWindow = 1 * time.Minute
|
||||
sensitiveMaxRequests = 5
|
||||
sensitiveBlockDur = 5 * time.Minute
|
||||
)
|
||||
|
||||
// windowState 单个维度的限流状态
|
||||
@ -30,8 +35,10 @@ type windowState struct {
|
||||
blockedUntil time.Time
|
||||
}
|
||||
|
||||
// check 核心检查逻辑
|
||||
func (rl *RateLimiter) check(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) (RateLimitResult, func()) {
|
||||
// try 原子检查+递增:在持锁状态下判断是否被限流,若允许则递增计数。
|
||||
// 检查与递增在同一临界区内完成,无竞态窗口。
|
||||
// 调用方需在操作成功后调用 Clear/或 ClearIP 清除计数。
|
||||
func (rl *RateLimiter) try(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) RateLimitResult {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
@ -44,38 +51,28 @@ func (rl *RateLimiter) check(m map[string]*windowState, key string, window, bloc
|
||||
}
|
||||
}
|
||||
|
||||
// 已被封禁中
|
||||
if !state.blockedUntil.IsZero() && now.Before(state.blockedUntil) {
|
||||
retry := int(state.blockedUntil.Sub(now).Seconds()) + 1
|
||||
msg := "请求过于频繁,请稍后重试"
|
||||
if lowKey {
|
||||
msg = fmt.Sprintf("该账号登录尝试过于频繁,请 %d 秒后重试", retry)
|
||||
}
|
||||
return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}, nil
|
||||
return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}
|
||||
}
|
||||
|
||||
// 窗口过期 → 重置
|
||||
if now.Sub(state.windowStart) > window {
|
||||
state.count = 0
|
||||
state.windowStart = now
|
||||
state.blockedUntil = time.Time{}
|
||||
}
|
||||
|
||||
recordFail := func() {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
s := m[key]
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if now.Sub(s.windowStart) > window {
|
||||
s.count = 1
|
||||
s.windowStart = now
|
||||
return
|
||||
}
|
||||
s.count++
|
||||
if s.count >= maxFails {
|
||||
s.blockedUntil = now.Add(blockDur)
|
||||
}
|
||||
// 原子递增计数(本次尝试已记录)
|
||||
state.count++
|
||||
if state.count >= maxFails {
|
||||
state.blockedUntil = now.Add(blockDur)
|
||||
}
|
||||
|
||||
return RateLimitResult{Blocked: false}, recordFail
|
||||
return RateLimitResult{Blocked: false}
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
package middleware
|
||||
|
||||
// tokenVersionStore 最小接口:仅暴露 AuthMiddleware 需要的 1 个方法
|
||||
type tokenVersionStore interface {
|
||||
FindTokenVersion(userID uint) (int, error)
|
||||
}
|
||||
@ -1,24 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"metazone.cc/metalab/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// generateNonce 生成 16 字节随机 nonce(base64 编码)
|
||||
func generateNonce() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// 降级:使用固定长度回退(极度罕见,仅 /dev/urandom 故障时)
|
||||
panic("failed to generate CSP nonce: " + err.Error())
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// SecurityHeaders 添加安全相关 HTTP 响应头
|
||||
// 作为纵深防御,不影响业务逻辑,纯附加
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 生成一次性 nonce,用于 CSP 策略和模板内 <script>/<style> 标签
|
||||
nonce := generateNonce()
|
||||
c.Set("csp_nonce", nonce)
|
||||
|
||||
// Content-Security-Policy
|
||||
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js)
|
||||
// style-src: 本站 + esm.sh (Tiptap CSS) + cdnjs (highlight.js 主题)
|
||||
// connect-src: 本站 + esm.sh (source map 请求)
|
||||
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
|
||||
// script-src/style-src: 本站 + nonce(替代 unsafe-inline)
|
||||
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
||||
c.Header("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
||||
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data: blob:; "+
|
||||
"connect-src 'self' https://esm.sh")
|
||||
"connect-src 'self'")
|
||||
|
||||
// 禁止 MIME 类型嗅探
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
@ -29,6 +46,11 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
// 引用策略
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// HSTS:仅在 release 模式启用,避免开发环境 localhost 证书问题
|
||||
if config.App != nil && config.App.Server.Mode == "release" {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,12 +7,13 @@ import (
|
||||
)
|
||||
|
||||
// InjectSiteInfo 注入站点展示信息到请求上下文(供 BuildPageData 使用)
|
||||
// 应用于引擎级全局中间件,确保所有 SSR 页面都能读取 Framework/CopyrightStart
|
||||
// 应用于引擎级全局中间件,确保所有 SSR 页面都能读取 Framework/CopyrightStart/IsMaintenance
|
||||
func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
info := siteSettings.SiteInfo()
|
||||
c.Set("site_framework", info.Framework)
|
||||
c.Set("site_copyright_start", info.CopyrightStart)
|
||||
c.Set("is_maintenance", siteSettings.IsMaintenanceEnabled())
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
65
internal/model/comment.go
Normal file
65
internal/model/comment.go
Normal file
@ -0,0 +1,65 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Comment 评论模型
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
||||
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
|
||||
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL)
|
||||
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
||||
LikesCount int `gorm:"default:0" json:"likes_count"`
|
||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 非数据库字段(联表查询填充)
|
||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
|
||||
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
|
||||
// ReplyToName 被回复者当前显示名(JOIN users 获取,动态解析,改名后自动更新)
|
||||
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
|
||||
// RepliesCount 回复数(非DB字段,查询填充)
|
||||
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
||||
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}(非DB字段,批量查询填充)
|
||||
// key=创建时的原始@用户名,value={uid,当前显示名},用户改名后链接仍有效且显示新名
|
||||
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
|
||||
}
|
||||
|
||||
// CommentMention @提及映射(绑定UID + 原始用户名,支持改名后自动更新显示名)
|
||||
type CommentMention struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
|
||||
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
|
||||
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
|
||||
}
|
||||
|
||||
// MentionInfo @提及显示信息(前端渲染用)
|
||||
type MentionInfo struct {
|
||||
UID uint `json:"uid"`
|
||||
Name string `json:"name"` // 当前显示名
|
||||
}
|
||||
|
||||
// UserSearchResult @搜索用户结果
|
||||
type UserSearchResult struct {
|
||||
UID uint `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
Level int `json:"level"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// AdminCommentRow 后台评论列表行
|
||||
type AdminCommentRow struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
Body string `json:"body"`
|
||||
AuthorName string `json:"author_name"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@ -8,7 +8,7 @@ import (
|
||||
|
||||
// BaseModel 所有模型的公共字段
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primarykey;column:uid" json:"uid"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
10
internal/model/community_fund.go
Normal file
10
internal/model/community_fund.go
Normal file
@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// CommunityFund 公户余额(单行表,id=1)
|
||||
type CommunityFund struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数)
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
13
internal/model/daily_exp_summary.go
Normal file
13
internal/model/daily_exp_summary.go
Normal file
@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// DailyExpSummary 每日通过赋能获得的经验汇总(判断是否达 50 上限)
|
||||
type DailyExpSummary struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_date;not null" json:"user_id"`
|
||||
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_date;not null" json:"date"` // Asia/Shanghai 自然日
|
||||
ExpEarned int `gorm:"default:0" json:"exp_earned"` // 当日通过赋能获得的经验值
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
14
internal/model/daily_like_summary.go
Normal file
14
internal/model/daily_like_summary.go
Normal file
@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
|
||||
type DailyLikeSummary struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
|
||||
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DD(Asia/Shanghai)
|
||||
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
|
||||
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@ -20,4 +20,42 @@ type CheckEmailRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// ---- Post DTOs ----
|
||||
|
||||
// PostListResult 帖子列表查询结果
|
||||
type PostListResult struct {
|
||||
Items []Post `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// PostCreateRequest 发帖请求
|
||||
type PostCreateRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||
Body string `json:"body" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// PostUpdateRequest 编辑请求
|
||||
type PostUpdateRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||
Body string `json:"body" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// PostRejectRequest 退回请求
|
||||
type PostRejectRequest struct {
|
||||
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
||||
}
|
||||
|
||||
// PostLockRequest 锁定请求
|
||||
type PostLockRequest struct {
|
||||
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
||||
}
|
||||
|
||||
// PostListQuery 列表查询参数
|
||||
type PostListQuery struct {
|
||||
Keyword string `form:"keyword"`
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
38
internal/model/energy_log.go
Normal file
38
internal/model/energy_log.go
Normal file
@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 域能流水类型常量
|
||||
const (
|
||||
EnergyTypeSignIn = "sign_in"
|
||||
EnergyTypeRename = "rename"
|
||||
EnergyTypeRenameRefund = "rename_refund"
|
||||
EnergyTypeEnergize = "energize"
|
||||
EnergyTypeEnergized = "energized"
|
||||
EnergyTypeDeletePost = "delete_post"
|
||||
EnergyTypeAdminAdjust = "admin_adjust"
|
||||
)
|
||||
|
||||
// EnergyLog 域能流水记录
|
||||
type EnergyLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减)
|
||||
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
|
||||
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
|
||||
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||
Description string `gorm:"type:varchar(500);default:''" json:"description,omitempty"`
|
||||
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"` // 操作者UID(后台操作时必填)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// EnergyLogDisplayNames 流水类型 → 中文名
|
||||
var EnergyLogDisplayNames = map[string]string{
|
||||
EnergyTypeSignIn: "每日签到",
|
||||
EnergyTypeRename: "改名消耗",
|
||||
EnergyTypeRenameRefund: "改名退款",
|
||||
EnergyTypeEnergize: "赋能消耗",
|
||||
EnergyTypeEnergized: "被赋能",
|
||||
EnergyTypeDeletePost: "删稿消耗",
|
||||
EnergyTypeAdminAdjust: "后台调整",
|
||||
}
|
||||
30
internal/model/folder.go
Normal file
30
internal/model/folder.go
Normal file
@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Folder 收藏夹
|
||||
type Folder struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||
Description string `gorm:"type:varchar(200);default:''" json:"description"`
|
||||
IsPublic bool `gorm:"default:false" json:"is_public"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 联表查询填充(非数据库字段)
|
||||
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
|
||||
}
|
||||
|
||||
// FolderItem 收藏记录
|
||||
type FolderItem struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
FolderID uint `gorm:"index;not null" json:"folder_id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// 联表查询填充
|
||||
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
|
||||
}
|
||||
35
internal/model/fund_log.go
Normal file
35
internal/model/fund_log.go
Normal file
@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 公户流水类型常量
|
||||
const (
|
||||
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
|
||||
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
|
||||
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
|
||||
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
|
||||
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
|
||||
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
|
||||
)
|
||||
|
||||
// FundLog 公户流水记录
|
||||
type FundLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10)
|
||||
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
|
||||
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
|
||||
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"`
|
||||
Description string `gorm:"type:text;not null" json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FundLogDisplayNames 公户流水类型 → 中文名
|
||||
var FundLogDisplayNames = map[string]string{
|
||||
FundTypeEnergizeTax: "赋能税收",
|
||||
FundTypeRenameDeduction: "改名扣费",
|
||||
FundTypeRenameRefund: "改名退款",
|
||||
FundTypeDeletePost: "删稿扣费",
|
||||
FundTypeAdminTransfer: "管理转出",
|
||||
FundTypeSystemOperation: "系统操作",
|
||||
}
|
||||
25
internal/model/level.go
Normal file
25
internal/model/level.go
Normal file
@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LevelThresholds 等级阈值表(经验值区间下限)
|
||||
// 索引即等级,如 thresholds[3] = 1500 表示 Lv3 至少需要 1500 经验值
|
||||
var LevelThresholds = []int{0, 1, 200, 1500, 4500, 10800, 28800}
|
||||
|
||||
// GetLevelByExp 根据经验值计算当前等级
|
||||
func GetLevelByExp(exp int) int {
|
||||
for i := len(LevelThresholds) - 1; i >= 0; i-- {
|
||||
if exp >= LevelThresholds[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetLevelName 根据等级返回展示文本(如 "Lv3")
|
||||
func GetLevelName(level int) string {
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
return fmt.Sprintf("Lv%d", level)
|
||||
}
|
||||
@ -2,8 +2,18 @@ package model
|
||||
|
||||
// 消息/通知类型常量
|
||||
const (
|
||||
NotifyAuditApproved = "audit_approved"
|
||||
NotifyAuditRejected = "audit_rejected"
|
||||
NotifyAuditApproved = "audit_approved"
|
||||
NotifyAuditRejected = "audit_rejected"
|
||||
NotifyPostApproved = "post_approved"
|
||||
NotifyPostRejected = "post_rejected"
|
||||
NotifyPostLocked = "post_locked"
|
||||
NotifyPostUnlocked = "post_unlocked"
|
||||
NotifyLevelUp = "level_up"
|
||||
NotifyComment = "comment"
|
||||
NotifyCommentReply = "comment_reply"
|
||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||
NotifyFollow = "follow" // 关注通知
|
||||
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||
)
|
||||
|
||||
// Notification 消息/通知模型
|
||||
@ -15,13 +25,40 @@ type Notification struct {
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
IsRead bool `gorm:"index:idx_user_read,priority:2;default:false;not null" json:"is_read"`
|
||||
RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID
|
||||
RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID(文章/评论等)
|
||||
CommentID *uint `gorm:"default:null" json:"comment_id,omitempty"` // 评论 ID(用于跳转高亮目标评论)
|
||||
}
|
||||
|
||||
// NotifyTypeNames 通知类型 → 中文名
|
||||
var NotifyTypeNames = map[string]string{
|
||||
NotifyAuditApproved: "系统通知",
|
||||
NotifyAuditRejected: "系统通知",
|
||||
NotifyAuditApproved: "资料审核",
|
||||
NotifyAuditRejected: "资料审核",
|
||||
NotifyPostApproved: "稿件审核",
|
||||
NotifyPostRejected: "稿件审核",
|
||||
NotifyPostLocked: "稿件审核",
|
||||
NotifyPostUnlocked: "稿件审核",
|
||||
NotifyLevelUp: "等级提升",
|
||||
NotifyComment: "评论",
|
||||
NotifyCommentReply: "回复",
|
||||
NotifyCommentMention: "@ 提及",
|
||||
NotifyLikeAggregated: "点赞",
|
||||
NotifyFollow: "关注",
|
||||
}
|
||||
|
||||
// NotifyCategory 通知类型所属分类 TAB
|
||||
var NotifyCategory = map[string]string{
|
||||
NotifyAuditApproved: "system",
|
||||
NotifyAuditRejected: "system",
|
||||
NotifyPostApproved: "system",
|
||||
NotifyPostRejected: "system",
|
||||
NotifyPostLocked: "system",
|
||||
NotifyPostUnlocked: "system",
|
||||
NotifyLevelUp: "system",
|
||||
NotifyComment: "mention",
|
||||
NotifyCommentReply: "mention",
|
||||
NotifyCommentMention: "mention",
|
||||
NotifyLikeAggregated: "like",
|
||||
NotifyFollow: "follow",
|
||||
}
|
||||
|
||||
// NotificationListResult 消息列表查询结果
|
||||
|
||||
@ -8,17 +8,27 @@ import (
|
||||
|
||||
// Post 帖子/文章模型
|
||||
type Post struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"`
|
||||
BodyHTML string `gorm:"type:text" json:"body_html"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
|
||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
||||
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
|
||||
Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
|
||||
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
||||
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
|
||||
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
||||
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
||||
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
||||
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
||||
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
||||
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 非数据库字段(联表查询填充)
|
||||
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
|
||||
@ -34,45 +44,3 @@ const (
|
||||
PostStatusRejected = "rejected"
|
||||
PostStatusLocked = "locked"
|
||||
)
|
||||
|
||||
// PostStatusDisplayNames 状态 → 中文名
|
||||
var PostStatusDisplayNames = map[string]string{
|
||||
PostStatusDraft: "草稿",
|
||||
PostStatusPending: "待审核",
|
||||
PostStatusApproved: "已发布",
|
||||
PostStatusRejected: "已退回",
|
||||
PostStatusLocked: "已锁定",
|
||||
}
|
||||
|
||||
// PostListResult 帖子列表查询结果
|
||||
type PostListResult struct {
|
||||
Items []Post `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// PostCreateRequest 发帖请求
|
||||
type PostCreateRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||
Body string `json:"body" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// PostUpdateRequest 编辑请求
|
||||
type PostUpdateRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||
Body string `json:"body" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// PostRejectRequest 退回请求
|
||||
type PostRejectRequest struct {
|
||||
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
||||
}
|
||||
|
||||
// PostListQuery 列表查询参数
|
||||
type PostListQuery struct {
|
||||
Keyword string `form:"keyword"`
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
10
internal/model/post_dislike.go
Normal file
10
internal/model/post_dislike.go
Normal file
@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// PostDislike 踩记录(仅后台可见)
|
||||
type PostDislike struct {
|
||||
UserID uint `gorm:"primaryKey;not null" json:"user_id"`
|
||||
PostID uint `gorm:"primaryKey;not null" json:"post_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
12
internal/model/post_energize_log.go
Normal file
12
internal/model/post_energize_log.go
Normal file
@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// PostEnergizeLog 赋能操作记录(校验单用户单文章赋能总量 ≤ 2 域能)
|
||||
type PostEnergizeLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"` // 赋能者
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
Amount int `gorm:"not null" json:"amount"` // 赋能域能量(乘10,10或20)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
10
internal/model/post_like.go
Normal file
10
internal/model/post_like.go
Normal file
@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// PostLike 点赞记录
|
||||
type PostLike struct {
|
||||
UserID uint `gorm:"primaryKey;not null" json:"user_id"`
|
||||
PostID uint `gorm:"primaryKey;not null" json:"post_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
20
internal/model/post_read_log.go
Normal file
20
internal/model/post_read_log.go
Normal file
@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// PostReadLog 文章阅读记录(已登录用户每篇文章只记录一次)
|
||||
type PostReadLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"user_id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"post_id"`
|
||||
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
|
||||
}
|
||||
|
||||
// PostGuestReadLog 访客阅读记录(未登录用户,基于 Cookie 标识去重)
|
||||
type PostGuestReadLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
VisitorID string `gorm:"uniqueIndex:idx_visitor_post_read;type:varchar(64);not null" json:"visitor_id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"`
|
||||
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
|
||||
}
|
||||
|
||||
92
internal/model/shortcode.go
Normal file
92
internal/model/shortcode.go
Normal file
@ -0,0 +1,92 @@
|
||||
package model
|
||||
|
||||
// =============================================================================
|
||||
// Shortcode — Markdown 扩展语法
|
||||
//
|
||||
// 用法:在 MD 正文中插入 [zone:类型:参数],渲染时替换为对应 UI 卡片。
|
||||
//
|
||||
// 支持的 shortcode:
|
||||
// [zone:event:活动ID] → 官方活动卡片(标题、时间、封面、链接)
|
||||
// [zone:game:游戏slug] → 游戏信息卡片(名称、封面、类型)
|
||||
// [zone:poll:投票ID] → 投票卡片(※后端 API 开发中)
|
||||
// [zone:resource:资源ID] → 资源卡片(※后端 API 开发中)
|
||||
//
|
||||
// 扩展现有类型:在 ShortcodeRegistry 中注册新类型 + 实现 Renderer 即可。
|
||||
// =============================================================================
|
||||
|
||||
import "regexp"
|
||||
|
||||
// =============================================================================
|
||||
// 语法常量
|
||||
// =============================================================================
|
||||
|
||||
// ShortcodePattern 匹配所有 [zone:type:params] 模式的 shortcode
|
||||
// 捕获组:$1=类型, $2=参数
|
||||
var ShortcodePattern = regexp.MustCompile(`\[zone:(\w+):([^\]]+)\]`)
|
||||
|
||||
// =============================================================================
|
||||
// 已注册的 Shortcode 类型
|
||||
// =============================================================================
|
||||
|
||||
// ShortcodeType 定义一种 shortcode 的类型标识
|
||||
type ShortcodeType string
|
||||
|
||||
const (
|
||||
ShortcodeEvent ShortcodeType = "event" // [zone:event:活动ID]
|
||||
ShortcodeGame ShortcodeType = "game" // [zone:game:游戏slug]
|
||||
ShortcodePoll ShortcodeType = "poll" // [zone:poll:投票ID] ※后端 API 开发中
|
||||
ShortcodeResource ShortcodeType = "resource" // [zone:resource:资源ID] ※后端 API 开发中
|
||||
)
|
||||
|
||||
// allTypes 所有已定义的 shortcode 类型,添加新类型时在这里追加
|
||||
var allTypes = []ShortcodeType{
|
||||
ShortcodeEvent,
|
||||
ShortcodeGame,
|
||||
ShortcodePoll,
|
||||
ShortcodeResource,
|
||||
}
|
||||
|
||||
// IsValidType 检查给定类型是否已注册
|
||||
func IsValidType(t string) bool {
|
||||
for _, st := range allTypes {
|
||||
if string(st) == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 解析结果
|
||||
// =============================================================================
|
||||
|
||||
// Shortcode 一次解析的结果
|
||||
type Shortcode struct {
|
||||
Raw string // 原始文本,如 "[zone:event:abc123]"
|
||||
Type ShortcodeType // 类型
|
||||
Params string // 参数(ID/slug 等)
|
||||
}
|
||||
|
||||
// ShortcodeResult 整个 body 的解析结果
|
||||
type ShortcodeResult struct {
|
||||
// ProcessedBody 替换后的 body(shortcode → HTML 占位符),可直接传给模板渲染
|
||||
ProcessedBody string
|
||||
// Shortcodes 本次解析到的所有 shortcode 列表
|
||||
Shortcodes []Shortcode
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 卡片渲染数据 DTO
|
||||
// =============================================================================
|
||||
|
||||
// ShortcodeCard 渲染一张卡片的通用数据
|
||||
// 前端根据 Type 决定具体 UI 组件,字段按需填充
|
||||
type ShortcodeCard struct {
|
||||
Type ShortcodeType `json:"type"`
|
||||
ID string `json:"id"` // 业务 ID(活动ID / 游戏slug)
|
||||
Title string `json:"title,omitempty"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Extra string `json:"extra,omitempty"` // 扩展字段(时间、标签等,JSON string)
|
||||
}
|
||||
@ -12,15 +12,27 @@ type User struct {
|
||||
Avatar string `gorm:"type:varchar(500);default:''" json:"avatar"`
|
||||
Bio string `gorm:"type:text" json:"bio"`
|
||||
|
||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
|
||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||
|
||||
// 经验值与域能
|
||||
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
|
||||
Energy int `gorm:"default:0" json:"energy"` // 域能余额(可为负数,乘10存储)
|
||||
|
||||
// 关注系统
|
||||
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
|
||||
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
|
||||
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
|
||||
FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开
|
||||
|
||||
// 通知偏好 (JSON string)
|
||||
NotifyPrefs string `gorm:"type:text;default:'{\"comment\":true,\"comment_reply\":true,\"like_aggregated\":true,\"follow\":true}'" json:"-"`
|
||||
|
||||
// 安全与审计
|
||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
||||
LastLoginAt *time.Time `gorm:"default:null" json:"last_login_at,omitempty"` // 最后登录时间
|
||||
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
|
||||
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
|
||||
}
|
||||
|
||||
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)
|
||||
|
||||
11
internal/model/user_checkin.go
Normal file
11
internal/model/user_checkin.go
Normal file
@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserCheckIn 用户签到记录(自然日维度,严格防重)
|
||||
type UserCheckIn struct {
|
||||
BaseModel
|
||||
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_checkin_date,priority:1;not null" json:"user_id"`
|
||||
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_checkin_date,priority:2;not null" json:"date"` // Asia/Shanghai 自然日
|
||||
}
|
||||
18
internal/model/user_follow.go
Normal file
18
internal/model/user_follow.go
Normal file
@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserFollow 关注关系
|
||||
type UserFollow struct {
|
||||
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
|
||||
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// 联表查询填充(非数据库字段)
|
||||
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`
|
||||
FolloweeUsername string `gorm:"-:migration;<-:false;column:followee_username" json:"followee_username,omitempty"`
|
||||
FollowerAvatar string `gorm:"-:migration;<-:false;column:follower_avatar" json:"follower_avatar,omitempty"`
|
||||
FolloweeAvatar string `gorm:"-:migration;<-:false;column:followee_avatar" json:"followee_avatar,omitempty"`
|
||||
FollowerBio string `gorm:"-:migration;<-:false;column:follower_bio" json:"follower_bio,omitempty"`
|
||||
FolloweeBio string `gorm:"-:migration;<-:false;column:followee_bio" json:"followee_bio,omitempty"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user