docs: 清理过时文档(audit-report, post-system-audit, vditor-migration)
This commit is contained in:
@ -1,426 +0,0 @@
|
||||
# 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)` |
|
||||
@ -1,231 +0,0 @@
|
||||
# 帖子系统代码审计报告
|
||||
|
||||
> 审计日期: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 | 轻微优化项 |
|
||||
@ -1,319 +0,0 @@
|
||||
# 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` 自动清理)
|
||||
Reference in New Issue
Block a user