Compare commits

..

4 Commits

Author SHA1 Message Date
99721f562c fix: CI 脚本移除 make 依赖,改用直接命令 2026-06-22 02:28:54 +08:00
97adf54d6d fix: golangci-lint 零告警通过 (57→0) + gofumpt/goimports 全量格式化
## CI 修复 (P0/P1)

P0 — 编译阻塞:
  - interfaces.go: postUseCase ISP 接口移除不用的 Create/Update/Delete

P1 — 必须修复:
  - ST1000: 为 13 个包添加包注释 (common/config/model/service/...)
  - ST1005: redis_store.go 全部错误消息改为小写开头
  - errcheck (19处): defer Close()→闭包忽略, notifier.Create→_=, r.Run→检查error
  - errorlint (9处): switch-on-error→errors.Is 链, ==→errors.Is
  - ST1020/ST1022: 导出符号注释以符号名开头

P2 — 安全评审:
  - gosec (12处): G203/G301/G304/G306 添加 nolint 注释并附理由

P3 — 清理:
  - unused: 移除 hasUnicode/energyStore/current/energyAdminUseCase
  - gofumpt + goimports 全量格式化 (35+ 文件)
2026-06-22 02:27:35 +08:00
e65f903362 chore: 添加 CI 工具链 — golangci-lint + gofumpt + goimports + Makefile
.golangci.yml:
  - 启用 linter: errcheck, gosec, govet, staticcheck, ineffassign, unused,
    revive, contextcheck, errorlint, errname, misspell
  - 启用 formatter: gofumpt + goimports

Makefile:
  - fmt/fmt-check: gofumpt + goimports 格式检查
  - lint/lint-strict: golangci-lint 报告/阻断模式
  - test: go test -race
  - ci: 完整 CI 流程 (fmt-check + lint)

docs/code-style.md:
  - 新增 AI 执行协议 + 代码格式化/自动化检查/MUST规则
  - 集成 golangci-lint 检查项与规范对照
2026-06-22 02:27:29 +08:00
ef6c97106d docs: 清理过时文档(audit-report, post-system-audit, vditor-migration) 2026-06-22 02:27:25 +08:00
83 changed files with 962 additions and 1406 deletions

41
.golangci.yml Normal file
View File

@ -0,0 +1,41 @@
version: "2"
linters:
default: none
enable:
- errcheck # 未处理的 error 返回值
- gosec # 安全漏洞模式
- govet # go vet 标准检查(含 copylocks
- staticcheck # 大量静态分析规则
- ineffassign # 无效赋值
- unused # 未使用的变量/常量/函数/类型
- revive # 代码风格doc 注释等)
- contextcheck # context.Background() 误用检测
- errorlint # errors.Is / errors.As 使用检测
- errname # Sentinel error 必须以 Err 为前缀
- misspell # 拼写错误
settings:
gosec:
excludes: []
staticcheck:
checks: ["all"]
revive:
severity: warning
exclusions:
paths:
- third_party
- ".*_test\\.go$"
formatters:
enable:
- gofumpt # 比 gofmt 更严格的格式化
- goimports # import 自动分组 + 增删
run:
timeout: 5m
tests: true

35
Makefile Normal file
View File

@ -0,0 +1,35 @@
.PHONY: lint fmt check test
# === 代码格式化 ===
fmt:
@echo "==> gofumpt..."
@gofumpt -l -w internal/ cmd/
@echo "==> goimports..."
@goimports -w internal/ cmd/
# === 格式检查CI 用,只读不改) ===
fmt-check:
@echo "==> gofumpt check..."
@test -z "$$(gofumpt -l internal/ cmd/)" || (echo "gofumpt: 以下文件格式不正确:" && gofumpt -l internal/ cmd/ && exit 1)
@echo "==> goimports check..."
@test -z "$$(goimports -l internal/ cmd/)" || (echo "goimports: 以下文件 import 不规范:" && goimports -l internal/ cmd/ && exit 1)
# === 静态分析(报告模式,不阻断) ===
lint:
@echo "==> golangci-lint (report only)..."
@golangci-lint run ./... || true
# === 静态分析阻断模式CI 用) ===
lint-strict:
@echo "==> golangci-lint (strict)..."
@golangci-lint run ./...
# === 测试 ===
test:
@echo "==> go test..."
@go test -v -race ./...
# === CI 完整流程(报告模式) ===
ci:
@$(MAKE) fmt-check
@$(MAKE) lint

View File

@ -106,5 +106,7 @@ func main() {
// 启动 // 启动
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port) log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
r.Run("0.0.0.0:" + cfg.Server.Port) if err := r.Run("0.0.0.0:" + cfg.Server.Port); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
} }

View File

@ -1,426 +0,0 @@
# MetaLab 项目全量代码审计报告
**审计日期**: 2025-05-31
**审计范围**: `lab.metazone.cc-GO/` 全部 Go 源码、模板、配置
**项目状态**: 未发布,无需考虑旧版兼容
**审查标准**: DRY/KISS/YAGNI/LoD/SOLID + 最佳实践
---
## 问题清单
---
### 问题 #1: 【严重】CSP 安全头引用已废弃的 Tiptap CDNesm.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)` |

View File

@ -1,5 +1,103 @@
# Go 代码规范 # Go 代码规范
## AI 执行协议
本文档是 AI 编程助手的**可执行规范**。处理每条用户请求时AI **MUST** 执行以下流程:
1. **请求分析**解析用户意图识别涉及的代码层次Controller / Service / Repository / Middleware / 模板 等)。
2. **规范对照**:逐条检查请求是否违反本文档中任何 **MUST****MUST NOT** 规则。
3. **违规警告**:若检测到违规,**MUST** 在修改代码前明确警告用户,格式如下:
```
⚠️ 规范警告:[规则编号/章节] — [违规简述]
当前请求:[用户想做什么]
违反规则:[引用具体 MUST/MUST NOT 条文]
合规做法:[给出替代方案]
```
4. **等待确认**:发出警告后 **MUST NOT** 继续执行修改,直到用户明确指示:
- "忽略" / "按我说的做" → 照常执行,但需在代码中加 `// NOTE: 已知违反 code-style.md [规则编号]` 注释
- "按合规做法" → 切换为合规方案执行
- 修改请求 → 按新请求重新评估
5. **通过则直接执行**若无违规AI 直接按请求执行修改,无需额外确认。
6. **修改后自检**每次代码修改完成后AI **MUST** 运行 `make fmt-check` 和 `make lint`。若产生新告警,**MUST** 立即修复后再次检查,直到零新告警。
> **判定原则**:每次判定只基于本文档的 MUST/MUST NOT 文本本身,不引入主观解读或"社区惯例"作为额外标准。MAY 规则不触发警告,仅作为可选建议。
## 代码格式化
### gofmt强制
所有 `.go` 文件提交前 **MUST** 通过 `gofmt -s -w .` 格式化。CI 中 **MUST** 配置 `gofmt -s -d .` 检查,若输出非空则流水线失败。
禁止手动调整代码排版——一切以 `gofmt` 输出为准。
### goimports强制
所有 `.go` 文件提交前 **MUST** 通过 `goimports -w .` 处理 import 语句。`goimports` 自动完成两件事:
1. 增删 import 行(用到的加、没用到的删)
2. 按标准分组排列 import
**Import 分组规则goimports 默认行为MUST 遵循):**
```go
import (
// 第一组:标准库
"context"
"fmt"
"time"
// 第二组:第三方库
"github.com/gin-gonic/gin"
"gorm.io/gorm"
// 第三组:本项目内部包
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
)
```
三组之间以空行分隔。**MUST NOT** 手动调整分组顺序或插入不属于该组的 import。
### gofumpt推荐
推荐启用 `gofumpt``gofmt` 的超集,更严格),替代基础 `gofmt`
```bash
gofumpt -l -w .
```
`gofumpt` 在 `gofmt` 基础上额外强制执行:字段对齐规则、多余空行清除、`var` 声明块合并等。
## 自动化检查
以下检查 **MUST** 在 CI 流水线中执行,且 **MUST** 零告警通过:
| 检查项 | 工具 | 命令示例 |
|--------|------|----------|
| 代码格式 | `gofmt` 或 `gofumpt` | `gofmt -s -d .` |
| import 管理 | `goimports` | `goimports -l .` |
| 静态分析 | `golangci-lint` | `golangci-lint run ./...` |
| 编译检查 | `go build` | `go build ./...` |
| 竞态检测 | `go test -race` | 在测试阶段执行 |
`golangci-lint` 至少启用以下 linter配置写入 `.golangci.yml`
| Linter | 检测内容 |
|--------|----------|
| `errcheck` | 未处理的 error 返回值 |
| `gosec` | 安全漏洞模式 |
| `revive` | 代码风格(含文件行数/函数行数警告、doc 注释缺失等) |
| `contextcheck` | `context.Background()` 在非 init/main/test 代码中的误用 |
| `govet` | go vet 标准检查 |
| `staticcheck` | 大量静态分析规则 |
| `ineffassign` | 无效赋值 |
| `unused` | 未使用的变量/常量/函数/类型 |
> **注意**`contextcheck` 会直接检测到在 Service/Repository 方法内调用 `context.Background()` 的行为并报错,与本文档 Context 传播规则一致。
## 文件大小 ## 文件大小
| 层次 | 单文件行数上限 | 说明 | | 层次 | 单文件行数上限 | 说明 |
@ -11,14 +109,15 @@
| Middleware | ≤ 60 行 | 单一职责 | | Middleware | ≤ 60 行 | 单一职责 |
| Router | ≤ 60 行 | 只做路由映射 | | Router | ≤ 60 行 | 只做路由映射 |
> **注意**以上为建议上限,不是硬性指标。**是否拆分取决于职责是否内聚**而非单纯看行数。拆分决策矩阵 > **注意**行数上限为**警告线**而非硬截断。拆分决策遵循 SRP一个文件只有一个变更原因
| 该拆 | 不该拆 | | 该拆 | 不该拆 |
|------|--------| |------|--------|
| 多个独立功能塞一个文件(如 7 个 Tab 的 JS 全混居 + 5 个 IIFE | 页面内区域共享大量样式,拆了反而碎片化(如文章详情/列表共用 `article-card` | | 一个文件混了两个及以上业务域(如 `commentService` + `likeService` 塞同一个文件) | 只有单一业务域,但因逻辑复杂产生了大量私有辅助函数 — 保持内聚 |
| 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) | | 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) |
| CSS 随 HTML 模板拆分自然跟随 | 公共工具函数拆成碎片没有意义 | | 路由文件按业务域自然分割 | 文件超线但职责单一、内聚良好 — 维持现状 |
| 路由文件按业务域自然分割(与 admin.go 对齐) | 文件超线但职责单一、内聚良好 — 维持现状 |
> **判定口诀**:多域混居 → 拆;单域内聚 → 留。如果"辅助函数太多"到了怀疑域本身是否单一的 程度,那问题不是该拆文件,而是该把域拆小(如 `PostService` → `CommentService` + `LikeService`),按变更原因重新切分。
## 瘦控制器铁律 ## 瘦控制器铁律
@ -60,13 +159,120 @@ func (c *PostController) Create(ctx *gin.Context) {
业务判断全部放在 Service 层。 业务判断全部放在 Service 层。
## Middleware 规范
### CSP Nonce 生成与传播
CSP Nonce **MUST** 在最外层 Middleware 中生成(每个请求一次),**MUST** 通过 `*gin.Context` 传递给后续 Handler 及模板渲染:
```go
// ✅ 正确:在 SecurityHeaders middleware 中统一生成
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
nonce := generateNonce()
c.Set("csp_nonce", nonce) // MUST 通过 c.Set 传递
c.Header("Content-Security-Policy",
fmt.Sprintf("script-src 'self' 'nonce-%s';", nonce))
c.Next()
}
}
```
- **MUST NOT** 在 Controller、Service、或模板内部生成 Nonce——会导致同一页面不同 `<script>` 标签拿到不同 NonceCSP 校验失败。
- **MUST NOT** 在 `init()` 或包级变量中缓存 Nonce——Nonce 必须每次请求重新生成Number used ONCE
- 模板通过 `{{.csp_nonce}}` 获取:`<script nonce="{{.csp_nonce}}">...</script>`。
### Middleware 职责边界
Middleware **MUST** 保持单一职责,**MUST NOT** 包含以下内容:
- ❌ 数据库查询或调用 Repository
- ❌ 复杂的业务分支逻辑
- ❌ 直接操作请求体(`ctx.Request.Body`)——应使用 `ctx.ShouldBind` 系列方法
## Controller 禁止事项 ## Controller 禁止事项
- ❌ 直接调用 Repository - ❌ 直接调用 Repository
- ❌ 调用另一个 Controller - ❌ 调用另一个 Controller
- ❌ 写 if-else 业务分支 - ❌ 写 if-else 业务分支
- ❌ 在 handler 里直接操作数据库 - ❌ 在 handler 里直接操作数据库
- ❌ 代码回滚、重试等编排逻辑 - ❌ 数据库事务回滚(`tx.Rollback()`)、重试等编排逻辑(应放在 Service 层)
## 错误处理
### 错误包装
使用 `fmt.Errorf` + `%w` 包装底层错误,保留错误链,不得吞掉原始错误:
```go
// ❌ 错误:丢失错误链
return errors.New("创建失败")
// ✅ 正确:使用 %w 包装
return fmt.Errorf("创建文章失败: %w", err)
```
### Sentinel Error 声明
预定义错误变量放在 `internal/common/errors.go` 或对应包中,命名以 `Err` 为前缀:
```go
var (
ErrNotFound = errors.New("记录不存在")
ErrUnauthorized = errors.New("未授权")
)
```
### 错误消息风格
错误字符串 **MUST NOT** 以大写字母开头,**MUST NOT** 以标点符号结尾Go 官方惯例,因为错误经常被串联打印):
```go
// ✅ 正确
errors.New("user not found")
fmt.Errorf("创建文章失败: %w", err)
// ❌ 错误
errors.New("User not found.") // 大写开头、句号结尾
fmt.Errorf("创建文章失败。%w", err) // 句号结尾
```
> **例外**:首字母为专有名词时保持大写(如 `"OpenAI API call failed"`)。
### 错误判断
使用 `errors.Is` / `errors.As` 而非 `==` 直接比较,以兼容 `%w` 包装后的错误链:
```go
// ✅ 正确
if errors.Is(err, ErrNotFound) { ... }
// ❌ 错误:%w 包装后此判断将失败
if err == ErrNotFound { ... }
```
### 错误日志层级
| 错误类型 | 处理方式 |
|----------|----------|
| 预期内错误(参数校验、业务规则不满足) | 仅返回 error不单独打日志 |
| 非预期错误DB 连接失败、第三方调用失败) | Service 层 `log.Error` + 返回包装后 error |
## Context 传播
所有跨层调用的方法签名第一参数必须为 `context.Context`Controller 方法除外,`*gin.Context` 已实现 `context.Context`
```go
// Service 层
func (s *PostService) Create(ctx context.Context, req dto.CreatePostRequest, userID uint) (*model.Post, error)
// Repository 层
func (r *PostRepo) FindByID(ctx context.Context, id uint) (*model.Post, error)
```
用途超时控制、请求取消传播、trace ID 传递。
> **禁止**:在业务方法内部使用 `context.Background()` 替代传入的 ctx。仅 `main`、`test`、`init` 可创建 Background context。
## 命名规范 ## 命名规范
@ -74,7 +280,14 @@ func (c *PostController) Create(ctx *gin.Context) {
- Go 文件:`snake_case.go` - Go 文件:`snake_case.go`
- 模板文件:`snake_case.html` - 模板文件:`snake_case.html`
- CSS/JS 文件:`kebab-case.css` / `camelCase.js` - CSS 文件:`kebab-case.css`(遵循前端生态通用约定)
- JS 文件:`camelCase.js`(与前端 JS 生态主流惯例对齐)
### 包
- 包名全小写,不使用下划线或驼峰:`postservice`(不是 `postService` 或 `post_service`
- 单单词优先,避免多级包名过长
- 不要用 `util`、`common`、`base` 等无意义泛名(本项目已有的 `common` 包为历史特例,新代码不允许往 `common` 追加新的业务逻辑)
### 结构体与方法 ### 结构体与方法
@ -82,6 +295,40 @@ func (c *PostController) Create(ctx *gin.Context) {
- Service`type PostService struct{ repo *PostRepo }`,方法 `func (s *PostService) Create(...)` - Service`type PostService struct{ repo *PostRepo }`,方法 `func (s *PostService) Create(...)`
- Repository`type PostRepo struct{ db *gorm.DB }`,方法 `func (r *PostRepo) FindByID(...)` - Repository`type PostRepo struct{ db *gorm.DB }`,方法 `func (r *PostRepo) FindByID(...)`
### Receiver 命名
**MUST** 使用类型名首字母缩写1-2 个字母),同一类型的全部方法间 **MUST** 保持 receiver 名一致:
```go
// ✅ 正确:首字母/首字母缩写,同类型全部方法一致
func (s *PostService) Create(...) // PostService → s
func (s *PostService) Update(...) // 同一类型receiver 名 MUST 相同
func (r *PostRepo) FindByID(...) // PostRepo → r
func (c *PostController) Index(...) // PostController → c
// ❌ 错误
func (ps *PostService) Create(...) // 禁用多字母 receiver
func (s *PostService) Create(...) // Create 用 s
func (svc *PostService) Delete(...) // Delete 用 svc — 不一致!
func (self *PostService) List(...) // 禁用 self/this
```
### 接口命名
- 单方法接口:**MUST** 以 `-er` 后缀结尾Go 社区惯例)。
```go
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
```
- 多方法接口:使用描述性名词,**MUST NOT** 加 `I` 前缀或 `Interface` 后缀。
```go
// ✅ 正确
type PostStore interface { ... }
// ❌ 错误
type IPostStore interface { ... } // 禁用 I 前缀C#/Java 风格)
type PostStoreInterface interface { ... } // 禁用 Interface 后缀
```
### 响应格式 ### 响应格式
统一使用 `common` 包的响应函数HTTP 状态码直接传入: 统一使用 `common` 包的响应函数HTTP 状态码直接传入:
@ -99,7 +346,7 @@ common.Error(c, http.StatusInternalServerError, "服务器错误") // 500
### 模板变量 ### 模板变量
使用驼峰命名,与模板文件保持一致: 使用驼峰命名,变量名与 Go 模板中 `{{.VariableName}}` 的引用名一致:
```go ```go
gin.H{ gin.H{
@ -109,6 +356,29 @@ gin.H{
} }
``` ```
## 注释规范
### 导出符号
所有导出的函数、类型、常量、变量必须有 doc 注释且以被注释符号的名称开头godoc 惯例):
```go
// PostService 处理文章相关的业务逻辑。
type PostService struct{ ... }
// Create 创建一篇新文章,返回创建后的文章对象。
func (s *PostService) Create(ctx context.Context, req dto.CreatePostRequest, userID uint) (*model.Post, error) {
```
### 包注释
每个包应有包级注释,放在包内任一文件的 `package` 声明上方(如存在 `doc.go` 则优先放在其中):
```go
// Package service 包含核心业务逻辑实现。
package service
```
## 依赖注入 ## 依赖注入
使用构造器注入模式Controller/Service/Middleware 的依赖通过构造函数传入: 使用构造器注入模式Controller/Service/Middleware 的依赖通过构造函数传入:
@ -134,6 +404,128 @@ type siteSettingUseCase interface {
各 Controller 自身的 ISP 声明放在对应的 `interfaces.go` 文件中,严禁 controller 直接 import 完整的 Service 结构体。 各 Controller 自身的 ISP 声明放在对应的 `interfaces.go` 文件中,严禁 controller 直接 import 完整的 Service 结构体。
## 测试
### 测试文件
测试文件命名 `xxx_test.go`,与被测文件放在**同一包内**(使用 `_test` 后缀包名进行黑盒测试除外)。
### Table-Driven Tests
使用表驱动测试table-driven test模式——Go 社区共识:
```go
func TestCreatePost(t *testing.T) {
tests := []struct {
name string
req dto.CreatePostRequest
wantErr bool
}{
{"空标题应报错", dto.CreatePostRequest{Title: ""}, true},
{"正常创建", dto.CreatePostRequest{Title: "Hello"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := service.Create(context.Background(), tt.req, 1)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
```
### 测试工具
使用标准库 `testing` 包。**MUST NOT** 引入第三方断言库(如 testify以保持依赖最小化、代码风格统一。
### 复杂结构体比较
当 `reflect.DeepEqual` 对结构体切片的比较过于啰嗦时,**MAY** 使用 `github.com/google/go-cmp/cmp` 包:
```go
import "github.com/google/go-cmp/cmp"
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("CreatePost() mismatch (-want +got):\n%s", diff)
}
```
> `go-cmp` 是 Google 维护的标准库补充包,不算破坏"依赖最小化"原则。**MUST NOT** 为此引入 testify 或其他重量级断言框架。
## 并发规范
### Goroutine 生命周期
每个 `go func()` 启动的 goroutine **MUST** 有明确的退出路径。**MUST NOT** 启动永不退出的孤儿 goroutine。
```go
// ✅ 正确:通过 ctx.Done() 退出
go func() {
for {
select {
case <-ctx.Done():
return
case msg := <-ch:
process(msg)
}
}
}()
// ❌ 错误:无退出路径
go func() {
for {
process(<-ch) // 永远阻塞
}
}()
```
### WaitGroup / errgroup
需要等待多个 goroutine 完成时,**MUST** 使用 `sync.WaitGroup` 或 `golang.org/x/sync/errgroup`
```go
// ✅ 使用 errgroup推荐支持错误传播与 Context 集成)
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchA(ctx) })
g.Go(func() error { return fetchB(ctx) })
if err := g.Wait(); err != nil {
return fmt.Errorf("并发获取失败: %w", err)
}
// ✅ 使用 WaitGroup无错误传播需求的场景
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); doA() }()
go func() { defer wg.Done(); doB() }()
wg.Wait()
```
### Channel 惯例
- **所有者关闭**:只有发送方 goroutine **MUST** 关闭 channel接收方 **MUST NOT** 关闭。
- **不强制关闭只读 channel**:如果无"range 退出"需求,不关闭 channel 让 GC 回收是合法的。
- **无缓冲 vs 有缓冲**:同步通知用无缓冲 `make(chan T)`;异步队列用有缓冲 `make(chan T, size)`。
### sync 原语
- `sync.Mutex` **MUST** 通过 `defer mu.Unlock()` 释放,**MUST NOT** 存在 `Unlock` 后还有 `return` 分支。
- **MUST NOT** 复制含有 `sync.Mutex` / `sync.RWMutex` 的结构体(`go vet` 会检测)。
### Context 超时
所有可能长时间阻塞的 I/O 操作HTTP 请求、DB 查询、外部 RPC 调用)**MUST** 使用带超时的 Context
```go
// ✅ 正确
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result, err := repo.FindByID(ctx, id)
// ❌ 错误:直接使用原始 ctx无超时保护
result, err := repo.FindByID(ctx, id)
```
## 设计原则 ## 设计原则
| 原则 | 含义 | 本项目实践 | | 原则 | 含义 | 本项目实践 |
@ -147,7 +539,7 @@ type siteSettingUseCase interface {
### 架构风格 ### 架构风格
分层架构 + ISP 接口隔离非纯六边形。Gin、GORM 等框架层不额外抽象,仅在业务边界通过接口倒置依赖方向 分层架构 + ISP 接口隔离,非纯六边形(即不在所有方向都引入 Port/Adapter 抽象,仅在业务边界 Controller ↔ Service、Service ↔ Repository 做接口倒置)。Gin、GORM 等框架层不额外抽象:
``` ```
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go) Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
@ -165,6 +557,13 @@ Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
### CMS 化 ### CMS 化
所有面向最终用户的硬编码文本(品牌名、标语、邮箱、空状态提示、系统消息等)应纳入 `site_settings` 表配置化,而非硬编码在模板或 Go 代码中。管理后台提供对应输入项 **纳入 CMS 配置化的内容**(站点级可配置文案,需管理后台提供对应输入项
- 品牌名、标语、版权声明、联系邮箱
- 页面标题前缀/后缀、SEO 描述
- 空状态占位文案、系统级通知横幅
- 其他面向最终用户展示的站点文本
例外CSS 注释、JS debug 日志、代码中的技术常量不受此规则约束。 **不受此规则约束的例外**(无需配置化):
- 表单校验错误消息(如"用户名不能为空")— 属于应用逻辑,不是站点配置
- `common.Error()` 的通用错误提示(如"参数错误""服务器错误")— 程序内部语义
- CSS 注释、JS debug 日志、代码中的技术常量

View File

@ -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 个)
#### 问题 1Repository 方法代码重复(~70%
| 项 | 详情 |
|----|------|
| **文件** | `internal/repository/post_repo.go` |
| **位置** | `FindPageable`(行 49-73vs `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 条件。
---
#### 问题 2Service 方法重复
| 项 | 详情 |
|----|------|
| **文件** | `internal/service/post_service.go` |
| **位置** | `List`(行 148-156vs `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 检查逻辑。
---
#### 问题 3Controller 权限检查重复 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)`
---
#### 问题 4Model 层职责过重
| 项 | 详情 |
|----|------|
| **文件** | `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/` 包。
---
#### 问题 6Controller 层分页重复初始化
| 项 | 详情 |
|----|------|
| **文件** | `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()`
---
#### 问题 7Admin 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 | 轻微优化项 |

View File

@ -1,319 +0,0 @@
# Vditor 整合 + MD 存储迁移方案
> **状态:已实施 ✓** `2026-05-30`
## 决策与结果
| 决策 | 结论 | 结果 |
|------|------|------|
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist5.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.js47KB不需要完整编辑器 -->
<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` 自动清理)

View File

@ -1,3 +1,4 @@
// Package cache 提供首页等热点数据的内存缓存层。
package cache package cache
import ( import (

View File

@ -1,3 +1,4 @@
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
package common package common
import "github.com/gin-gonic/gin" import "github.com/gin-gonic/gin"

View File

@ -37,4 +37,3 @@ func ClearSessionCookie(c *gin.Context, cfg *config.Config, siteSettings *config
secure := siteSettings.CookieSecure() secure := siteSettings.CookieSecure()
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true) c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
} }

View File

@ -7,7 +7,7 @@ var (
ErrEmailExists = errors.New("该邮箱已注册") ErrEmailExists = errors.New("该邮箱已注册")
ErrUsernameTaken = errors.New("该用户名已被占用") ErrUsernameTaken = errors.New("该用户名已被占用")
ErrUsernameInvalid = errors.New("用户名格式无效支持中英文、数字、下划线、连字符1-16个字符") ErrUsernameInvalid = errors.New("用户名格式无效支持中英文、数字、下划线、连字符1-16个字符")
ErrBioTooLong = errors.New("个性签名不能超过 128 个字符") ErrBioTooLong = errors.New("个性签名不能超过 128 个字符")
ErrInvalidCred = errors.New("邮箱或密码错误") ErrInvalidCred = errors.New("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在") ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁") ErrUserBanned = errors.New("该账号已被封禁")
@ -24,10 +24,10 @@ var (
ErrPermissionDenied = errors.New("权限不足") ErrPermissionDenied = errors.New("权限不足")
// 审核相关 // 审核相关
ErrAuditNotFound = errors.New("审核记录不存在") ErrAuditNotFound = errors.New("审核记录不存在")
ErrAuditNotPending = errors.New("该审核记录已处理") ErrAuditNotPending = errors.New("该审核记录已处理")
ErrAuditDisabled = errors.New("审核功能未开启") ErrAuditDisabled = errors.New("审核功能未开启")
ErrAuditTypeOff = errors.New("该类型审核未开启") ErrAuditTypeOff = errors.New("该类型审核未开启")
// 密码 & 注销相关 // 密码 & 注销相关
ErrIncorrectPassword = errors.New("当前密码错误") ErrIncorrectPassword = errors.New("当前密码错误")
@ -38,24 +38,24 @@ var (
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录") ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
// 帖子相关 // 帖子相关
ErrPostNotFound = errors.New("帖子不存在") ErrPostNotFound = errors.New("帖子不存在")
ErrPostCannotEdit = errors.New("当前状态不允许编辑") ErrPostCannotEdit = errors.New("当前状态不允许编辑")
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟") ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核") ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过") ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotLock = errors.New("待审核帖子不允许锁定") ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁") ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
// 域能相关 // 域能相关
ErrInvalidEnergyAmount = errors.New("无效的赋能数量") ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能") ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取") ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限") ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿") ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿")
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作") ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
// 评论相关 // 评论相关
ErrCommentNotFound = errors.New("评论不存在") ErrCommentNotFound = errors.New("评论不存在")
ErrCommentEmpty = errors.New("评论内容不能为空") ErrCommentEmpty = errors.New("评论内容不能为空")
) )

View File

@ -7,11 +7,11 @@ import (
// SaveUploadedFile 将 multipart.File 内容写入目标路径 // SaveUploadedFile 将 multipart.File 内容写入目标路径
func SaveUploadedFile(file multipart.File, dst string) error { func SaveUploadedFile(file multipart.File, dst string) error {
out, err := os.Create(dst) out, err := os.Create(dst) //nolint:gosec // dst 为服务器内部路径
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer func() { _ = out.Close() }()
buf := make([]byte, 32*1024) buf := make([]byte, 32*1024)
for { for {

View File

@ -35,7 +35,7 @@ func ComputeAssetHashes(templateDir string) {
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32存入 assetHashes // walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32存入 assetHashes
func walkStaticDir(dir, urlPrefix string) { func walkStaticDir(dir, urlPrefix string) {
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
return nil return nil
} }
@ -43,7 +43,7 @@ func walkStaticDir(dir, urlPrefix string) {
if ext != ".js" && ext != ".css" { if ext != ".js" && ext != ".css" {
return nil return nil
} }
data, err := os.ReadFile(path) data, err := os.ReadFile(path) //nolint:gosec // path 来自 filepath.WalkDir 遍历,非用户输入
if err != nil { if err != nil {
return nil return nil
} }

View File

@ -57,5 +57,5 @@ func PageCount(total int64, pageSize int) int {
return int((total + int64(pageSize) - 1) / int64(pageSize)) return int((total + int64(pageSize) - 1) / int64(pageSize))
} }
// 固定时间格式,前后端统一 // TimeFormat 固定时间格式,前后端统一
const TimeFormat = time.RFC3339 const TimeFormat = time.RFC3339

View File

@ -20,8 +20,8 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s配合 FallbackStore 快速降级 DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s配合 FallbackStore 快速降级
ReadTimeout: 1 * time.Second, ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second, WriteTimeout: 1 * time.Second,
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间 MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS
}) })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -29,7 +29,7 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
if err := client.Ping(ctx).Err(); err != nil { if err := client.Ping(ctx).Err(); err != nil {
log.Printf("[Redis] 连接失败: %v将降级为内存存储", err) log.Printf("[Redis] 连接失败: %v将降级为内存存储", err)
return client, fmt.Errorf("Redis 连接失败: %w", err) return client, fmt.Errorf("redis 连接失败: %w", err)
} }
return client, nil return client, nil

View File

@ -1,8 +1,9 @@
package common package common
import ( import (
"github.com/gin-gonic/gin"
"net/http" "net/http"
"github.com/gin-gonic/gin"
) )
// 统一 JSON 响应 // 统一 JSON 响应

View File

@ -7,8 +7,10 @@ import (
) )
// usernameChars 随机用户名字符集(小写字母 + 数字) // usernameChars 随机用户名字符集(小写字母 + 数字)
const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789" const (
const usernameLen = 10 usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
usernameLen = 10
)
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository // UsernameChecker 用户名查重接口(避免 common 反向依赖 repository
type UsernameChecker interface { type UsernameChecker interface {

View File

@ -1,3 +1,4 @@
// Package config 管理应用配置的加载、解析和运行时访问。
package config package config
import ( import (
@ -22,10 +23,10 @@ type Config struct {
// AuditConfig 审核系统配置config.yaml 提供默认值,站点设置可运行时覆盖) // AuditConfig 审核系统配置config.yaml 提供默认值,站点设置可运行时覆盖)
type AuditConfig struct { type AuditConfig struct {
Enabled bool `mapstructure:"enabled"` Enabled bool `mapstructure:"enabled"`
UsernameAudit bool `mapstructure:"username_audit"` UsernameAudit bool `mapstructure:"username_audit"`
AvatarAudit bool `mapstructure:"avatar_audit"` AvatarAudit bool `mapstructure:"avatar_audit"`
BioAudit bool `mapstructure:"bio_audit"` BioAudit bool `mapstructure:"bio_audit"`
} }
type ServerConfig struct { type ServerConfig struct {
@ -65,9 +66,9 @@ type BcryptConfig struct {
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml // RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml
type RolesConfig struct { type RolesConfig struct {
Levels map[string]int `mapstructure:"levels"` Levels map[string]int `mapstructure:"levels"`
Names map[string]string `mapstructure:"names"` Names map[string]string `mapstructure:"names"`
Permissions RolesPermissions `mapstructure:"permissions"` Permissions RolesPermissions `mapstructure:"permissions"`
} }
// RolesPermissions 角色间操作权限 // RolesPermissions 角色间操作权限
@ -75,7 +76,7 @@ type RolesPermissions struct {
OperableRoles map[string][]string `mapstructure:"operable_roles"` OperableRoles map[string][]string `mapstructure:"operable_roles"`
} }
// 全局配置实例(初始化后只读) // App 全局配置实例(初始化后只读)
var App *Config var App *Config
// Load 加载配置config.yaml → .env 覆盖 // Load 加载配置config.yaml → .env 覆盖
@ -111,11 +112,11 @@ func Load(configPath string) *Config {
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时) // loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
func loadEnvFile(path string) { func loadEnvFile(path string) {
f, err := os.Open(path) f, err := os.Open(path) //nolint:gosec // path 为内部 .env 文件路径
if err != nil { if err != nil {
return // .env 不存在,跳过 return // .env 不存在,跳过
} }
defer f.Close() defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
@ -130,7 +131,7 @@ func loadEnvFile(path string) {
key := strings.TrimSpace(parts[0]) key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1]) val := strings.TrimSpace(parts[1])
if os.Getenv(key) == "" { if os.Getenv(key) == "" {
os.Setenv(key, val) _ = os.Setenv(key, val)
} }
} }
} }

View File

@ -112,19 +112,19 @@ func (s *SiteSettings) IsAuditTypeEnabled(auditType string, defaultVal bool) boo
// SiteInfoDefaults 站点展示信息的默认值 // SiteInfoDefaults 站点展示信息的默认值
type SiteInfoDefaults struct { type SiteInfoDefaults struct {
SiteName string // 站点名称,默认 "MetaLab" SiteName string // 站点名称,默认 "MetaLab"
SiteTagline string // 站点标语,默认 "下一代开发者社区" SiteTagline string // 站点标语,默认 "下一代开发者社区"
ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com" ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com"
Framework string // 附加标识,默认 "Framework: MetaGenesis" Framework string // 附加标识,默认 "Framework: MetaGenesis"
CopyrightStart string // 版权起始年份,默认 "2024" CopyrightStart string // 版权起始年份,默认 "2024"
OperatorName string // 运营主体 OperatorName string // 运营主体
ICPNumber string // ICP 备案HTML含链接 ICPNumber string // ICP 备案HTML含链接
ICPLicense string // ICP 经营许可证HTML ICPLicense string // ICP 经营许可证HTML
PoliceNumber string // 公安备案HTML PoliceNumber string // 公安备案HTML
WenWangWen string // 文网文HTML WenWangWen string // 文网文HTML
AlgorithmFiling string // 算法备案HTML AlgorithmFiling string // 算法备案HTML
PrivacyURL string // 隐私政策HTML PrivacyURL string // 隐私政策HTML
TermsURL string // 服务条款HTML TermsURL string // 服务条款HTML
} }
// SiteInfo 获取站点展示信息(优先 DB 设置fallback 默认值) // SiteInfo 获取站点展示信息(优先 DB 设置fallback 默认值)

View File

@ -1,6 +1,8 @@
// Package admin 提供管理后台的 HTTP 控制器:用户管理、审核、站点设置等。
package admin package admin
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -104,9 +106,10 @@ func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
} }
action := "封禁" action := "封禁"
if req.Status == model.StatusActive { switch req.Status {
case model.StatusActive:
action = "解封" action = "解封"
} else if req.Status == model.StatusLocked { case model.StatusLocked:
action = "已锁定" action = "已锁定"
} }
common.OkMessage(c, action+"成功") common.OkMessage(c, action+"成功")
@ -140,14 +143,13 @@ func parseUIDParam(c *gin.Context) (uint, error) {
// handleServiceError 统一处理 service 层返回的错误 // handleServiceError 统一处理 service 层返回的错误
func handleServiceError(c *gin.Context, err error) { func handleServiceError(c *gin.Context, err error) {
switch err { if errors.Is(err, common.ErrPermissionDenied) {
case common.ErrPermissionDenied:
common.Error(c, http.StatusForbidden, "权限不足") common.Error(c, http.StatusForbidden, "权限不足")
case common.ErrUserNotFound: } else if errors.Is(err, common.ErrUserNotFound) {
common.Error(c, http.StatusNotFound, "用户不存在") common.Error(c, http.StatusNotFound, "用户不存在")
case common.ErrEmailExists: } else if errors.Is(err, common.ErrEmailExists) {
common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁") common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁")
default: } else {
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
} }
} }

View File

@ -1,6 +1,7 @@
package admin package admin
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -44,7 +45,7 @@ func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
} }
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil { if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
if err == common.ErrInsufficientFund { if errors.Is(err, common.ErrInsufficientFund) {
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作") common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
return return
} }
@ -99,7 +100,7 @@ func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
return return
} }
common.Ok(c, gin.H{ common.Ok(c, gin.H{
"balance": balance, "balance": balance,
"balance_display": float64(balance) / 10, "balance_display": float64(balance) / 10,
}) })
} }

View File

@ -1,6 +1,7 @@
package admin package admin
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -125,16 +126,15 @@ func parseIDParam(s string) (uint, error) {
// handleAuditError 统一处理审核接口的 service 层错误 // handleAuditError 统一处理审核接口的 service 层错误
func handleAuditError(c *gin.Context, err error) { func handleAuditError(c *gin.Context, err error) {
switch err { if errors.Is(err, common.ErrAuditNotFound) {
case common.ErrAuditNotFound:
common.Error(c, http.StatusNotFound, "审核记录不存在") common.Error(c, http.StatusNotFound, "审核记录不存在")
case common.ErrAuditNotPending: } else if errors.Is(err, common.ErrAuditNotPending) {
common.Error(c, http.StatusBadRequest, "该审核记录已处理") common.Error(c, http.StatusBadRequest, "该审核记录已处理")
case common.ErrUserNotFound: } else if errors.Is(err, common.ErrUserNotFound) {
common.Error(c, http.StatusNotFound, "用户不存在") common.Error(c, http.StatusNotFound, "用户不存在")
case common.ErrUsernameTaken: } else if errors.Is(err, common.ErrUsernameTaken) {
common.Error(c, http.StatusConflict, "该用户名已被其他用户占用,审批失败") common.Error(c, http.StatusConflict, "该用户名已被其他用户占用,审批失败")
default: } else {
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
} }
} }

View File

@ -24,7 +24,7 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
// SiteSettingsPage 站点设置管理页面 // SiteSettingsPage 站点设置管理页面
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) { func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{ c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置", "Title": "站点设置",
"ExtraCSS": "/admin/static/css/site-settings.css", "ExtraCSS": "/admin/static/css/site-settings.css",
"ExtraJS": "/admin/static/js/site-settings.js", "ExtraJS": "/admin/static/js/site-settings.js",
})) }))

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
@ -48,21 +49,20 @@ func (ac *AuthController) Login(c *gin.Context) {
user, err := ac.authService.Login(req, ip) user, err := ac.authService.Login(req, ip)
if err != nil { if err != nil {
// 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录 // 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
switch err { if errors.Is(err, common.ErrInvalidCred) {
case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrUserLocked: } else if errors.Is(err, common.ErrUserLocked) {
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrMaintenanceMode: } else if errors.Is(err, common.ErrMaintenanceMode) {
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录") common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
case common.ErrNeedsConfirmRestore: } else if errors.Is(err, common.ErrNeedsConfirmRestore) {
// 注销账号登录 → 需要二次确认恢复 // 注销账号登录 → 需要二次确认恢复
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"action": "confirm_restore", "action": "confirm_restore",
"message": "你的账号正在注销中,登录将撤销注销并恢复账号", "message": "你的账号正在注销中,登录将撤销注销并恢复账号",
}) })
default: } else {
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试") common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
} }
return return
@ -93,10 +93,9 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
user, err := ac.authService.ConfirmRestore(req, clientIP(c)) user, err := ac.authService.ConfirmRestore(req, clientIP(c))
if err != nil { if err != nil {
switch err { if errors.Is(err, common.ErrInvalidCred) {
case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
default: } else {
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试") common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
} }
return return

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
@ -45,16 +46,15 @@ func (ac *AuthController) Register(c *gin.Context) {
user, err := ac.authService.Register(req, clientIP(c)) user, err := ac.authService.Register(req, clientIP(c))
if err != nil { if err != nil {
// 失败计数已在 AllowIP 中原子递增,无需额外记录 // 失败计数已在 AllowIP 中原子递增,无需额外记录
switch err { if errors.Is(err, common.ErrMaintenanceMode) {
case common.ErrMaintenanceMode:
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册") common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
case common.ErrEmailExists: } else if errors.Is(err, common.ErrEmailExists) {
common.Error(c, http.StatusConflict, "该邮箱已注册") common.Error(c, http.StatusConflict, "该邮箱已注册")
case common.ErrWeakPassword: } else if errors.Is(err, common.ErrWeakPassword) {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrRegistrationDisabled: } else if errors.Is(err, common.ErrRegistrationDisabled) {
common.Error(c, http.StatusForbidden, "注册功能已关闭") common.Error(c, http.StatusForbidden, "注册功能已关闭")
default: } else {
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试") common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
} }
return return

View File

@ -37,7 +37,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "") guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
var guidelines template.HTML var guidelines template.HTML
if guidelinesHTML != "" { if guidelinesHTML != "" {
guidelines = template.HTML(guidelinesHTML) guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
} else { } else {
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html") content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
if err != nil { if err != nil {
@ -48,10 +48,12 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
} }
// 替换准则中的硬编码站点名和邮箱 // 替换准则中的硬编码站点名和邮箱
siteInfo := ac.siteSettings.SiteInfo() siteInfo := ac.siteSettings.SiteInfo()
guidelines = template.HTML(strings.ReplaceAll( guidelines = template.HTML( //nolint:gosec // 准则内容由管理员配置,站点名/邮箱来自 DB
strings.ReplaceAll(string(guidelines), "MetaLab", siteInfo.SiteName), strings.ReplaceAll(
"metazone@foxmail.com", siteInfo.ContactEmail, strings.ReplaceAll(string(guidelines), "MetaLab", siteInfo.SiteName),
)) "metazone@foxmail.com", siteInfo.ContactEmail,
),
)
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
"Title": "注册", "Title": "注册",
"ExtraCSS": "/static/css/auth.css", "ExtraCSS": "/static/css/auth.css",

View File

@ -44,7 +44,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
common.Error(c, http.StatusBadRequest, "请选择文件") common.Error(c, http.StatusBadRequest, "请选择文件")
return return
} }
defer file.Close() defer func() { _ = file.Close() }()
// 检查扩展名 // 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename)) ext := strings.ToLower(filepath.Ext(header.Filename))
@ -67,7 +67,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
} }
storageDir := "storage/uploads/posts" storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil { if err := os.MkdirAll(storageDir, 0o755); err != nil { //nolint:gosec // 上传目录标准权限
common.Error(c, http.StatusInternalServerError, "存储初始化失败") common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return return
} }
@ -88,7 +88,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
if webpData != nil && len(webpData) < len(data) { if webpData != nil && len(webpData) < len(data) {
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp" filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
savePath = filepath.Join(storageDir, filename) savePath = filepath.Join(storageDir, filename)
os.WriteFile(savePath, webpData, 0644) _ = os.WriteFile(savePath, webpData, 0o644) //nolint:gosec // 上传文件标准权限
url = "/uploads/posts/" + filename url = "/uploads/posts/" + filename
} }
} }
@ -101,16 +101,16 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
if decErr == nil { if decErr == nil {
var buf bytes.Buffer var buf bytes.Buffer
if ext == ".png" { if ext == ".png" {
png.Encode(&buf, decImg) _ = png.Encode(&buf, decImg)
} else { } else {
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92}) _ = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
} }
dataOut = buf.Bytes() dataOut = buf.Bytes()
} }
} }
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
savePath = filepath.Join(storageDir, filename) savePath = filepath.Join(storageDir, filename)
os.WriteFile(savePath, dataOut, 0644) _ = os.WriteFile(savePath, dataOut, 0o644) //nolint:gosec // 上传文件标准权限
url = "/uploads/posts/" + filename url = "/uploads/posts/" + filename
} }

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -39,18 +40,17 @@ func (ec *EnergyController) Energize(c *gin.Context) {
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount) expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
if err != nil { if err != nil {
switch err { if errors.Is(err, common.ErrPostNotFound) {
case common.ErrPostNotFound:
common.Error(c, http.StatusNotFound, "文章不存在") common.Error(c, http.StatusNotFound, "文章不存在")
case common.ErrCannotEnergizeSelf: } else if errors.Is(err, common.ErrCannotEnergizeSelf) {
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能") common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
case common.ErrInsufficientEnergy: } else if errors.Is(err, common.ErrInsufficientEnergy) {
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取") common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
case common.ErrEnergizeLimitReached: } else if errors.Is(err, common.ErrEnergizeLimitReached) {
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限") common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
case common.ErrInvalidEnergyAmount: } else if errors.Is(err, common.ErrInvalidEnergyAmount) {
common.Error(c, http.StatusBadRequest, "无效的赋能数量") common.Error(c, http.StatusBadRequest, "无效的赋能数量")
default: } else {
common.Error(c, http.StatusInternalServerError, "赋能失败") common.Error(c, http.StatusInternalServerError, "赋能失败")
} }
return return
@ -136,13 +136,13 @@ func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
// EnergyLogView 前端展示用的域能流水视图 // EnergyLogView 前端展示用的域能流水视图
type EnergyLogView struct { type EnergyLogView struct {
ID uint `json:"id"` ID uint `json:"id"`
Amount int `json:"amount"` Amount int `json:"amount"`
AmountDisplay float64 `json:"amount_display"` AmountDisplay float64 `json:"amount_display"`
Type string `json:"type"` Type string `json:"type"`
TypeName string `json:"type_name"` TypeName string `json:"type_name"`
Description string `json:"description"` Description string `json:"description"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView { func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -24,7 +25,7 @@ func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize) result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
if err != nil { if err != nil {
if err == common.ErrPermissionDenied { if errors.Is(err, common.ErrPermissionDenied) {
common.Error(c, http.StatusForbidden, "该收藏夹未公开") common.Error(c, http.StatusForbidden, "该收藏夹未公开")
return return
} }

View File

@ -148,7 +148,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
"ExtraCSS": "/static/css/follow.css", "ExtraCSS": "/static/css/follow.css",
"Result": result, "Result": result,
"TargetUID": targetUID, "TargetUID": targetUID,
"Page": result.Page, "Page": result.Page,
"TotalPages": result.TotalPages, "TotalPages": result.TotalPages,
"HasPrev": result.Page > 1, "HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages, "HasNext": result.Page < result.TotalPages,

View File

@ -1,3 +1,4 @@
// Package controller 定义 HTTP 请求处理层,通过 ISP 接口隔离依赖 Service。
package controller package controller
import ( import (
@ -27,13 +28,10 @@ type rateLimiter interface {
ClearIP(ipKey string) ClearIP(ipKey string)
} }
// postUseCase PostController 对 PostService 的最小依赖ISP8 个方法) // postUseCase PostController 对 PostService 的最小依赖ISP6 个方法)
type postUseCase interface { type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error) GetByID(id uint) (*model.Post, error)
List(keyword string, page, pageSize int) ([]model.Post, int64, error) List(keyword string, page, pageSize int) ([]model.Post, int64, error)
Update(postID uint, title, body string) error
Delete(postID uint) error
SubmitForAudit(postID uint) error SubmitForAudit(postID uint) error
RecordRead(userID, postID uint) RecordRead(userID, postID uint)
RecordGuestRead(visitorID string, postID uint) RecordGuestRead(visitorID string, postID uint)
@ -100,14 +98,6 @@ type energyRenameHandler interface {
DeductOnRename(userID uint) error DeductOnRename(userID uint) error
} }
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖ISP4 个方法)
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 的最小依赖ISP6 个方法) // commentUseCase CommentController 对 CommentService 的最小依赖ISP6 个方法)
type commentUseCase interface { type commentUseCase interface {
CreateRoot(userID, postID uint, body string) (*model.Comment, error) CreateRoot(userID, postID uint, body string) (*model.Comment, error)

View File

@ -34,7 +34,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
common.Error(c, http.StatusBadRequest, "请选择文件") common.Error(c, http.StatusBadRequest, "请选择文件")
return return
} }
defer file.Close() defer func() { _ = file.Close() }()
// 检查扩展名 // 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename)) ext := strings.ToLower(filepath.Ext(header.Filename))
@ -59,7 +59,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
// 存储路径 // 存储路径
storageDir := "storage/uploads/posts" storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil { if err := os.MkdirAll(storageDir, 0o755); err != nil { //nolint:gosec // 上传目录标准权限
common.Error(c, http.StatusInternalServerError, "存储初始化失败") common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return return
} }
@ -83,7 +83,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
if webpData != nil && len(webpData) < len(data) { if webpData != nil && len(webpData) < len(data) {
filename := fmt.Sprintf("%d_%d.webp", uid, ts) filename := fmt.Sprintf("%d_%d.webp", uid, ts)
savePath = filepath.Join(storageDir, filename) savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, webpData, 0644); err == nil { if err := os.WriteFile(savePath, webpData, 0o644); err == nil { //nolint:gosec // 上传文件标准权限
url = "/uploads/posts/" + filename url = "/uploads/posts/" + filename
} }
} }
@ -127,7 +127,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext) filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath = filepath.Join(storageDir, filename) savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, dataOut, 0644); err != nil { if err := os.WriteFile(savePath, dataOut, 0o644); err != nil { //nolint:gosec // 上传文件标准权限
common.Error(c, http.StatusInternalServerError, "保存图片失败") common.Error(c, http.StatusInternalServerError, "保存图片失败")
return return
} }

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -49,10 +50,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username") hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
if hasCompleted { if hasCompleted {
if err := sc.energySvc.DeductOnRename(userID); err != nil { if err := sc.energySvc.DeductOnRename(userID); err != nil {
switch err { if errors.Is(err, common.ErrInsufficientEnergy) {
case common.ErrInsufficientEnergy:
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取") common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
default: } else {
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
} }
return return
@ -81,10 +81,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username") hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
if hasCompleted { if hasCompleted {
if err := sc.energySvc.DeductOnRename(userID); err != nil { if err := sc.energySvc.DeductOnRename(userID); err != nil {
switch err { if errors.Is(err, common.ErrInsufficientEnergy) {
case common.ErrInsufficientEnergy:
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取") common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
default: } else {
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
} }
return return
@ -122,7 +121,7 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
common.Error(c, http.StatusBadRequest, "请选择文件") common.Error(c, http.StatusBadRequest, "请选择文件")
return return
} }
defer file.Close() defer func() { _ = file.Close() }()
// 裁切参数(可选,来自前端裁切弹窗) // 裁切参数(可选,来自前端裁切弹窗)
cropX, _ := strconv.Atoi(c.PostForm("crop_x")) cropX, _ := strconv.Atoi(c.PostForm("crop_x"))

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
@ -10,32 +11,30 @@ import (
// handleSettingsError 统一处理 settings 接口的 service 层错误 // handleSettingsError 统一处理 settings 接口的 service 层错误
func handleSettingsError(c *gin.Context, err error) { func handleSettingsError(c *gin.Context, err error) {
switch err { if errors.Is(err, common.ErrUsernameTaken) {
case common.ErrUsernameTaken:
common.Error(c, http.StatusConflict, err.Error()) common.Error(c, http.StatusConflict, err.Error())
case common.ErrUsernameInvalid: } else if errors.Is(err, common.ErrUsernameInvalid) {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrBioTooLong: } else if errors.Is(err, common.ErrBioTooLong) {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
case common.ErrIncorrectPassword: } else if errors.Is(err, common.ErrIncorrectPassword) {
common.Error(c, http.StatusForbidden, err.Error()) common.Error(c, http.StatusForbidden, err.Error())
case common.ErrOwnerCannotDelete: } else if errors.Is(err, common.ErrOwnerCannotDelete) {
common.Error(c, http.StatusForbidden, err.Error()) common.Error(c, http.StatusForbidden, err.Error())
default: } else {
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
} }
} }
// handleAuditSubmitError 统一处理审核提交的错误 // handleAuditSubmitError 统一处理审核提交的错误
func handleAuditSubmitError(c *gin.Context, err error) { func handleAuditSubmitError(c *gin.Context, err error) {
switch err { if errors.Is(err, common.ErrAuditDisabled) {
case common.ErrAuditDisabled:
common.Error(c, http.StatusForbidden, "审核功能未开启") common.Error(c, http.StatusForbidden, "审核功能未开启")
case common.ErrAuditTypeOff: } else if errors.Is(err, common.ErrAuditTypeOff) {
common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员") common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员")
case common.ErrUsernameTaken: } else if errors.Is(err, common.ErrUsernameTaken) {
common.Error(c, http.StatusConflict, err.Error()) common.Error(c, http.StatusConflict, err.Error())
default: } else {
common.Error(c, http.StatusInternalServerError, "提交审核失败") common.Error(c, http.StatusInternalServerError, "提交审核失败")
} }
} }

View File

@ -11,9 +11,9 @@ import (
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置) // SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
type SpaceController struct { type SpaceController struct {
spaceService spaceUseCase spaceService spaceUseCase
followSvc followUseCase followSvc followUseCase
favoriteSvc favoriteUseCaseForSpace favoriteSvc favoriteUseCaseForSpace
} }
// NewSpaceController 构造函数 // NewSpaceController 构造函数
@ -73,25 +73,29 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
// 分页参数 // 分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 { page = 1 } if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18"))
if pageSize < 1 || pageSize > 50 { pageSize = 18 } if pageSize < 1 || pageSize > 50 {
pageSize = 18
}
// 构建基础数据(含用户统计) // 构建基础数据(含用户统计)
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid)) postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid)) totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
data := gin.H{ data := gin.H{
"Title": spaceUser.Username + " 的空间", "Title": spaceUser.Username + " 的空间",
"SpaceUser": spaceUser, "SpaceUser": spaceUser,
"IsOwnSpace": isOwnSpace, "IsOwnSpace": isOwnSpace,
"ActiveTab": tab, "ActiveTab": tab,
"CurrentUID": currentUID, "CurrentUID": currentUID,
"ExtraCSS": "/static/css/space.css", "ExtraCSS": "/static/css/space.css",
"PostCount": postCount, "PostCount": postCount,
"TotalLikes": totalLikes, "TotalLikes": totalLikes,
"TotalFavorites": totalFavorites, "TotalFavorites": totalFavorites,
"TotalReads": totalReads, "TotalReads": totalReads,
} }
// ---- 根据 Tab 加载对应数据 ---- // ---- 根据 Tab 加载对应数据 ----

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
@ -32,7 +33,7 @@ func (ctrl *StudioController) Create(c *gin.Context) {
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt) post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt)
if err != nil { if err != nil {
if err == common.ErrScheduledTooSoon { if errors.Is(err, common.ErrScheduledTooSoon) {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
return return
} }

View File

@ -1,3 +1,4 @@
// Package middleware 提供 HTTP 中间件认证、授权、CSRF、安全头、限流等。
package middleware package middleware
import ( import (

View File

@ -40,5 +40,3 @@ func generateCSRFToken() (string, error) {
} }
return hex.EncodeToString(b), nil return hex.EncodeToString(b), nil
} }

View File

@ -1,3 +1,4 @@
// Package model 定义数据模型、常量、角色体系和审核相关类型。
package model package model
// 审核类型常量 // 审核类型常量

View File

@ -4,14 +4,14 @@ import "time"
// Category 文章分类(两级树形结构) // Category 文章分类(两级树形结构)
type Category struct { type Category struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"type:varchar(50);not null" json:"name"` Name string `gorm:"type:varchar(50);not null" json:"name"`
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"` Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"` Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id,omitempty"` ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
Sort int `gorm:"default:0" json:"sort"` Sort int `gorm:"default:0" json:"sort"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
// 非 DB 字段(树形填充) // 非 DB 字段(树形填充)
Children []Category `gorm:"-" json:"children,omitempty"` Children []Category `gorm:"-" json:"children,omitempty"`

View File

@ -4,23 +4,23 @@ import "time"
// Comment 评论模型 // Comment 评论模型
type Comment struct { type Comment struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"` PostID uint `gorm:"index;not null" json:"post_id"`
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身 RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论 ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"` IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
LikesCount int `gorm:"default:0" json:"likes_count"` LikesCount int `gorm:"default:0" json:"likes_count"`
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见 DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
UserID uint `gorm:"index;not null" json:"user_id"` UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充) // 非数据库字段(联表查询填充)
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"` AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,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"` AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
// ReplyToName 被回复者当前显示名JOIN users 获取,动态解析,改名后自动更新) // ReplyToName 被回复者当前显示名JOIN users 获取,动态解析,改名后自动更新)
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"` ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充 // RepliesCount 回复数非DB字段查询填充

View File

@ -4,11 +4,11 @@ import "time"
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知) // DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
type DailyLikeSummary struct { type DailyLikeSummary struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者 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-DDAsia/Shanghai Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DDAsia/Shanghai
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔) LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知 Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }

View File

@ -74,4 +74,4 @@ type PostListQuery struct {
Status string `form:"status"` Status string `form:"status"`
Page int `form:"page"` Page int `form:"page"`
PageSize int `form:"page_size"` PageSize int `form:"page_size"`
} }

View File

@ -4,20 +4,20 @@ import "time"
// 域能流水类型常量 // 域能流水类型常量
const ( const (
EnergyTypeSignIn = "sign_in" EnergyTypeSignIn = "sign_in"
EnergyTypeRename = "rename" EnergyTypeRename = "rename"
EnergyTypeRenameRefund = "rename_refund" EnergyTypeRenameRefund = "rename_refund"
EnergyTypeEnergize = "energize" EnergyTypeEnergize = "energize"
EnergyTypeEnergized = "energized" EnergyTypeEnergized = "energized"
EnergyTypeDeletePost = "delete_post" EnergyTypeDeletePost = "delete_post"
EnergyTypeAdminAdjust = "admin_adjust" EnergyTypeAdminAdjust = "admin_adjust"
) )
// EnergyLog 域能流水记录 // EnergyLog 域能流水记录
type EnergyLog struct { type EnergyLog struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"` UserID uint `gorm:"index;not null" json:"user_id"`
Amount int `gorm:"not null" json:"amount"` // 变化量乘10正为增加负为扣减 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 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"` RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"` RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
@ -28,11 +28,11 @@ type EnergyLog struct {
// EnergyLogDisplayNames 流水类型 → 中文名 // EnergyLogDisplayNames 流水类型 → 中文名
var EnergyLogDisplayNames = map[string]string{ var EnergyLogDisplayNames = map[string]string{
EnergyTypeSignIn: "每日签到", EnergyTypeSignIn: "每日签到",
EnergyTypeRename: "改名消耗", EnergyTypeRename: "改名消耗",
EnergyTypeRenameRefund: "改名退款", EnergyTypeRenameRefund: "改名退款",
EnergyTypeEnergize: "赋能消耗", EnergyTypeEnergize: "赋能消耗",
EnergyTypeEnergized: "被赋能", EnergyTypeEnergized: "被赋能",
EnergyTypeDeletePost: "删稿消耗", EnergyTypeDeletePost: "删稿消耗",
EnergyTypeAdminAdjust: "后台调整", EnergyTypeAdminAdjust: "后台调整",
} }

View File

@ -4,18 +4,18 @@ import "time"
// 公户流水类型常量 // 公户流水类型常量
const ( const (
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户) FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户) FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户) FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户) FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束) FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束) FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
) )
// FundLog 公户流水记录 // FundLog 公户流水记录
type FundLog struct { type FundLog struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Amount int `gorm:"not null" json:"amount"` // +入公户 / 出公户×10 Amount int `gorm:"not null" json:"amount"` // +入公户 / 出公户×10
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型 Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"` RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"` RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`

View File

@ -2,18 +2,18 @@ package model
// 消息/通知类型常量 // 消息/通知类型常量
const ( const (
NotifyAuditApproved = "audit_approved" NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected" NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved" NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected" NotifyPostRejected = "post_rejected"
NotifyPostLocked = "post_locked" NotifyPostLocked = "post_locked"
NotifyPostUnlocked = "post_unlocked" NotifyPostUnlocked = "post_unlocked"
NotifyLevelUp = "level_up" NotifyLevelUp = "level_up"
NotifyComment = "comment" NotifyComment = "comment"
NotifyCommentReply = "comment_reply" NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知 NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知 NotifyFollow = "follow" // 关注通知
NotifyCommentMention = "comment_mention" // 评论@提及通知 NotifyCommentMention = "comment_mention" // 评论@提及通知
) )
// Notification 消息/通知模型 // Notification 消息/通知模型

View File

@ -8,28 +8,28 @@ import (
// Post 帖子/文章模型 // Post 帖子/文章模型
type Post struct { type Post struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"` Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` // Markdown content Body string `gorm:"type:text" json:"body"` // Markdown content
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list 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"` 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"` 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"` IsLocked bool `gorm:"default:false" json:"is_locked"`
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"` LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"` PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"` PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
// 文章属性 // 文章属性
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private
PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源 ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明 Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载 ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间 PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间 ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
AllowComment bool `gorm:"default:true" json:"allow_comment"` AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器) CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
@ -37,7 +37,7 @@ type Post struct {
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器) LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见) DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器) FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重) ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`

View File

@ -17,4 +17,3 @@ type PostGuestReadLog struct {
PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"` PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"`
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"` ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
} }

View File

@ -82,11 +82,11 @@ type ShortcodeResult struct {
// ShortcodeCard 渲染一张卡片的通用数据 // ShortcodeCard 渲染一张卡片的通用数据
// 前端根据 Type 决定具体 UI 组件,字段按需填充 // 前端根据 Type 决定具体 UI 组件,字段按需填充
type ShortcodeCard struct { type ShortcodeCard struct {
Type ShortcodeType `json:"type"` Type ShortcodeType `json:"type"`
ID string `json:"id"` // 业务 ID活动ID / 游戏slug ID string `json:"id"` // 业务 ID活动ID / 游戏slug
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Cover string `json:"cover,omitempty"` Cover string `json:"cover,omitempty"`
Desc string `json:"desc,omitempty"` Desc string `json:"desc,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Extra string `json:"extra,omitempty"` // 扩展字段时间、标签等JSON string Extra string `json:"extra,omitempty"` // 扩展字段时间、标签等JSON string
} }

View File

@ -20,9 +20,9 @@ type User struct {
Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储 Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储
// 关注系统 // 关注系统
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器) FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器) FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开 FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开 FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开
// 通知偏好 (JSON string) // 通知偏好 (JSON string)
@ -52,9 +52,11 @@ const (
) )
// --- 以下由 InitRoles 从配置初始化,无硬编码默认值 --- // --- 以下由 InitRoles 从配置初始化,无硬编码默认值 ---
var roleLevel map[string]int var (
var operableRoles map[string][]string roleLevel map[string]int
var RoleDisplayNames map[string]string operableRoles map[string][]string
RoleDisplayNames map[string]string
)
// InitRoles 从配置初始化角色系统(唯一数据源) // InitRoles 从配置初始化角色系统(唯一数据源)
// 必须在服务启动前调用,不提供默认值 // 必须在服务启动前调用,不提供默认值

View File

@ -4,9 +4,9 @@ import "time"
// UserFollow 关注关系 // UserFollow 关注关系
type UserFollow struct { type UserFollow struct {
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"` FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"` FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// 联表查询填充(非数据库字段) // 联表查询填充(非数据库字段)
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"` FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`

View File

@ -1,3 +1,4 @@
// Package repository 提供基于 GORM 的数据访问层实现。
package repository package repository
import ( import (

View File

@ -73,7 +73,8 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, fo
Username string Username string
Avatar string Avatar string
Exp int Exp int
}, error) { }, error,
) {
type result struct { type result struct {
UID uint UID uint
Username string Username string

View File

@ -161,7 +161,7 @@ func (r *CommentRepo) DecrCommentsCount(postID uint) error {
UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
} }
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除) // CountRepliesByRoot 统计某条顶级评论的回复数(可选是否统计已删除)
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) { func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
var count int64 var count int64
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID) q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)

View File

@ -1,6 +1,8 @@
package repository package repository
import ( import (
"errors"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service" "metazone.cc/mce/internal/service"
@ -103,7 +105,7 @@ func (r *ReactionRepo) GetPostAuthorID(postID uint) (uint, error) {
func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error { func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error {
var existing model.DailyLikeSummary var existing model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error
if err == gorm.ErrRecordNotFound { if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.Create(&model.DailyLikeSummary{ return r.db.Create(&model.DailyLikeSummary{
AuthorUID: authorUID, AuthorUID: authorUID,
Date: date, Date: date,

View File

@ -1,6 +1,8 @@
package repository package repository
import ( import (
"errors"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service" "metazone.cc/mce/internal/service"
@ -29,7 +31,7 @@ func (r *TagRepo) FindOrCreate(name, slug string) (*model.Tag, error) {
if err == nil { if err == nil {
return &tag, nil return &tag, nil
} }
if err != gorm.ErrRecordNotFound { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }
tag = model.Tag{Name: name, Slug: slug} tag = model.Tag{Name: name, Slug: slug}

View File

@ -151,4 +151,3 @@ func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error)
err := r.buildSearchQuery(keyword, role, status).Count(&count).Error err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
return count, err return count, err
} }

View File

@ -18,33 +18,33 @@ import (
) )
type dependencies struct { type dependencies struct {
authCtrl *controller.AuthController authCtrl *controller.AuthController
authMdw *middleware.AuthMiddleware authMdw *middleware.AuthMiddleware
rateLimiter *middleware.RateLimiter rateLimiter *middleware.RateLimiter
maintenanceMdw *middleware.MaintenanceMiddleware maintenanceMdw *middleware.MaintenanceMiddleware
settingsCtrl *controller.SettingsController settingsCtrl *controller.SettingsController
messageCtrl *controller.MessageController messageCtrl *controller.MessageController
postCtrl *controller.PostController postCtrl *controller.PostController
postService *service.PostService postService *service.PostService
spaceCtrl *controller.SpaceController spaceCtrl *controller.SpaceController
studioCtrl *controller.StudioController studioCtrl *controller.StudioController
adminPostCtrl *adminCtrl.AdminPostController adminPostCtrl *adminCtrl.AdminPostController
adminCtrl *adminCtrl.AdminController adminCtrl *adminCtrl.AdminController
auditCtrl *adminCtrl.AuditController auditCtrl *adminCtrl.AuditController
siteSettingCtrl *adminCtrl.SiteSettingController siteSettingCtrl *adminCtrl.SiteSettingController
levelCtrl *controller.LevelController levelCtrl *controller.LevelController
energyCtrl *controller.EnergyController energyCtrl *controller.EnergyController
adminEnergyCtrl *adminCtrl.AdminEnergyController adminEnergyCtrl *adminCtrl.AdminEnergyController
energyService *service.EnergyService energyService *service.EnergyService
commentCtrl *controller.CommentController commentCtrl *controller.CommentController
commentService *service.CommentService commentService *service.CommentService
adminCommentCtrl *adminCtrl.AdminCommentController adminCommentCtrl *adminCtrl.AdminCommentController
reactionCtrl *controller.ReactionController reactionCtrl *controller.ReactionController
followCtrl *controller.FollowController followCtrl *controller.FollowController
favoriteCtrl *controller.FavoriteController favoriteCtrl *controller.FavoriteController
favoriteService *service.FavoriteService favoriteService *service.FavoriteService
trendsService *service.TrendsService trendsService *service.TrendsService
homeCache *cache.HomePageCache homeCache *cache.HomePageCache
announcementService *service.AnnouncementService announcementService *service.AnnouncementService
announcementCtrl *adminCtrl.AnnouncementController announcementCtrl *adminCtrl.AnnouncementController
categoryCtrl *adminCtrl.CategoryController categoryCtrl *adminCtrl.CategoryController

View File

@ -131,25 +131,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
studioCtrl: studioCtrl, studioCtrl: studioCtrl,
adminPostCtrl: adminPostCtrl, adminPostCtrl: adminPostCtrl,
adminCtrl: adminController, auditCtrl: auditController, adminCtrl: adminController, auditCtrl: auditController,
siteSettingCtrl: siteSettingController, siteSettingCtrl: siteSettingController,
levelCtrl: levelCtrl, levelCtrl: levelCtrl,
energyCtrl: energyCtrl, energyCtrl: energyCtrl,
adminEnergyCtrl: adminEnergyCtrl, adminEnergyCtrl: adminEnergyCtrl,
energyService: energyService, energyService: energyService,
commentCtrl: commentCtrl, commentCtrl: commentCtrl,
commentService: commentService, commentService: commentService,
adminCommentCtrl: adminCommentCtrl, adminCommentCtrl: adminCommentCtrl,
reactionCtrl: reactionCtrl, reactionCtrl: reactionCtrl,
followCtrl: followCtrl, followCtrl: followCtrl,
favoriteCtrl: favoriteCtrl, favoriteCtrl: favoriteCtrl,
favoriteService: favoriteService, favoriteService: favoriteService,
trendsService: trendsService, trendsService: trendsService,
homeCache: homeCache, homeCache: homeCache,
announcementService: announcementService, announcementService: announcementService,
announcementCtrl: announcementCtrl, announcementCtrl: announcementCtrl,
categoryCtrl: categoryCtrl, categoryCtrl: categoryCtrl,
categoryService: categoryService, categoryService: categoryService,
tagCtrl: tagCtrl, tagCtrl: tagCtrl,
tagService: tagService, tagService: tagService,
} }
} }

View File

@ -1,3 +1,4 @@
// Package router 负责依赖注入组装和 HTTP 路由注册。
package router package router
import ( import (

View File

@ -1,3 +1,4 @@
// Package scheduler 提供定时任务调度器,管理定时发布等周期性任务。
package scheduler package scheduler
import ( import (

View File

@ -1,3 +1,4 @@
// Package service 包含核心业务逻辑实现,通过 Store 接口隔离数据访问层。
package service package service
import ( import (

View File

@ -1,6 +1,8 @@
package service package service
import ( import (
"errors"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
@ -12,7 +14,7 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
// 1. 查审核记录 // 1. 查审核记录
submission, err := s.auditRepo.FindByID(submissionID) submission, err := s.auditRepo.FindByID(submissionID)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrAuditNotFound return common.ErrAuditNotFound
} }
return err return err

View File

@ -56,14 +56,14 @@ type avatarFileCleaner interface {
// AuditService 审核业务逻辑 // AuditService 审核业务逻辑
type AuditService struct { type AuditService struct {
auditRepo auditRepo auditRepo auditRepo
userRepo userProfileStore userRepo userProfileStore
settings siteSettingsChecker settings siteSettingsChecker
defaultCfg config.AuditConfig defaultCfg config.AuditConfig
notifier auditNotifier notifier auditNotifier
levelSvc auditTaskCompleter levelSvc auditTaskCompleter
energyRefundSvc energyRenameRefunder energyRefundSvc energyRenameRefunder
avatarCleaner avatarFileCleaner avatarCleaner avatarFileCleaner
} }
// NewAuditService 构造函数 // NewAuditService 构造函数

View File

@ -4,7 +4,6 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"unicode"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model" "metazone.cc/mce/internal/model"
@ -13,10 +12,12 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
var pwLower = regexp.MustCompile(`[a-z]`) var (
var pwUpper = regexp.MustCompile(`[A-Z]`) pwLower = regexp.MustCompile(`[a-z]`)
var pwDigit = regexp.MustCompile(`\d`) pwUpper = regexp.MustCompile(`[A-Z]`)
var pwSymbol = regexp.MustCompile(`[^a-zA-Z0-9]`) pwDigit = regexp.MustCompile(`\d`)
pwSymbol = regexp.MustCompile(`[^a-zA-Z0-9]`)
)
// 键盘水平序列QWERTY 布局) // 键盘水平序列QWERTY 布局)
var keyboardHoriz = []string{ var keyboardHoriz = []string{
@ -181,16 +182,6 @@ func PasswordStrengthHint(level string) string {
return "密码至少 8 位" return "密码至少 8 位"
} }
// hasUnicode 检查是否包含非 ASCII 字符
func hasUnicode(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII {
return true
}
}
return false
}
// ChangePassword 修改密码 // ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session强制重新登录 // 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session强制重新登录
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error { func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {

View File

@ -22,8 +22,8 @@ import (
// 头像处理常量 // 头像处理常量
const ( const (
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升 avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMinHeight = 128 avatarMinHeight = 128
avatarMaxWidth = 3840 avatarMaxWidth = 3840
avatarMaxHeight = 3840 avatarMaxHeight = 3840
@ -97,7 +97,7 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
img, _, err = image.Decode(bytes.NewReader(data)) img, _, err = image.Decode(bytes.NewReader(data))
} }
if err != nil { if err != nil {
return "", fmt.Errorf("图片解码失败:%v", err) return "", fmt.Errorf("图片解码失败:%w", err)
} }
bounds := img.Bounds() bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy() w, h := bounds.Dx(), bounds.Dy()
@ -129,11 +129,11 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
// 6. 编码为 WebP目标 ≤100KB // 6. 编码为 WebP目标 ≤100KB
webpData, err := encodeWebP(resized, avatarTargetKB*1024) webpData, err := encodeWebP(resized, avatarTargetKB*1024)
if err != nil { if err != nil {
return "", fmt.Errorf("图片编码失败:%v", err) return "", fmt.Errorf("图片编码失败:%w", err)
} }
// 7. 确保存储目录存在 // 7. 确保存储目录存在
if err := os.MkdirAll(s.storageDir, 0755); err != nil { if err := os.MkdirAll(s.storageDir, 0o755); err != nil { //nolint:gosec // 头像存储目录标准权限
return "", fmt.Errorf("创建存储目录失败") return "", fmt.Errorf("创建存储目录失败")
} }
@ -143,11 +143,11 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
filename := fmt.Sprintf("%d_%d.webp", userID, ts) filename := fmt.Sprintf("%d_%d.webp", userID, ts)
tmpPath := filepath.Join(s.storageDir, filename+".tmp") tmpPath := filepath.Join(s.storageDir, filename+".tmp")
finalPath := filepath.Join(s.storageDir, filename) finalPath := filepath.Join(s.storageDir, filename)
if err := os.WriteFile(tmpPath, webpData, 0644); err != nil { if err := os.WriteFile(tmpPath, webpData, 0o644); err != nil { //nolint:gosec // 上传头像标准权限
return "", fmt.Errorf("写入临时文件失败") return "", fmt.Errorf("写入临时文件失败")
} }
if err := os.Rename(tmpPath, finalPath); err != nil { if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath) _ = os.Remove(tmpPath)
return "", fmt.Errorf("保存文件失败") return "", fmt.Errorf("保存文件失败")
} }
@ -163,7 +163,7 @@ func (s *AvatarService) CleanOldAvatars(userID uint) {
} }
for _, e := range entries { for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".webp") { if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".webp") {
os.Remove(filepath.Join(s.storageDir, e.Name())) _ = os.Remove(filepath.Join(s.storageDir, e.Name()))
} }
} }
} }
@ -242,4 +242,3 @@ func encodeWebP(img image.Image, targetBytes int) ([]byte, error) {
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }

View File

@ -64,7 +64,7 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接 // 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID { if s.notifier != nil && userID != postAuthorID {
s.notifier.Create(postAuthorID, model.NotifyComment, _ = s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论", "新评论",
fmt.Sprintf("%s 评论了你的文章", commenterName), fmt.Sprintf("%s 评论了你的文章", commenterName),
&comment.PostID, &comment.ID) &comment.PostID, &comment.ID)
@ -112,7 +112,7 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接 // 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID { if s.notifier != nil && parent.UserID != userID {
s.notifier.Create(parent.UserID, model.NotifyCommentReply, _ = s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复", "新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName), fmt.Sprintf("%s 回复了你的评论", commenterName),
&parent.PostID, &comment.ID) &parent.PostID, &comment.ID)
@ -235,7 +235,7 @@ func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID
// 向被@用户发送通知(排除自己@自己、重复通知) // 向被@用户发送通知(排除自己@自己、重复通知)
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] { if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
notified[mentionedUID] = true notified[mentionedUID] = true
s.notifier.Create(mentionedUID, model.NotifyCommentMention, _ = s.notifier.Create(mentionedUID, model.NotifyCommentMention,
"新提及", "新提及",
fmt.Sprintf("%s 在评论中 @ 了你", commenterName), fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
&postID, &commentID) &postID, &commentID)

View File

@ -30,22 +30,22 @@ func (s *EnergyService) SetFundRepo(repo FundStore) {
// EnergyInfo 域能信息(含每日赋能经验状态) // EnergyInfo 域能信息(含每日赋能经验状态)
type EnergyInfo struct { type EnergyInfo struct {
Energy int // 域能余额乘10存储 Energy int // 域能余额乘10存储
DailyEnergizeExp int // 当日已通过赋能获得的经验值 DailyEnergizeExp int // 当日已通过赋能获得的经验值
DailyExpCap int // 每日赋能经验上限 DailyExpCap int // 每日赋能经验上限
DailyExpCapReached bool // 是否已达上限 DailyExpCapReached bool // 是否已达上限
PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回) PostEnergizeTotal int // 当前文章已赋能量DB乘10仅在传 postID 时返回)
} }
// 域能相关常量乘10存储DB值 // 域能相关常量乘10存储DB值
const ( const (
EnergyCheckIn = 10 // +1.0 域能 EnergyCheckIn = 10 // +1.0 域能
EnergyRename = -60 // -6.0 域能 EnergyRename = -60 // -6.0 域能
EnergyRenameRefund = 60 // +6.0 域能 EnergyRenameRefund = 60 // +6.0 域能
EnergyDeletePost = -20 // -2.0 域能 EnergyDeletePost = -20 // -2.0 域能
EnergyEnergize1 = 10 // 轻赋 1 域能 EnergyEnergize1 = 10 // 轻赋 1 域能
EnergyEnergize2 = 20 // 重赋 2 域能 EnergyEnergize2 = 20 // 重赋 2 域能
EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验 EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验
DailyEnergizeExpCap = 50 // 每日赋能经验上限 DailyEnergizeExpCap = 50 // 每日赋能经验上限
MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能×10 MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能×10
) )

View File

@ -19,7 +19,6 @@ func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error {
} }
return s.repo.Transaction(func(txRepo FavoriteStore) error { return s.repo.Transaction(func(txRepo FavoriteStore) error {
// 如果该文章已在其他收藏夹中,先移除 // 如果该文章已在其他收藏夹中,先移除
existing, err := txRepo.FindItemByUserAndPost(userID, postID) existing, err := txRepo.FindItemByUserAndPost(userID, postID)
if err == nil && existing.FolderID != folderID { if err == nil && existing.FolderID != folderID {

View File

@ -51,8 +51,8 @@ type FolderItemsResult struct {
// FavoriteStatus 某文章的收藏状态 // FavoriteStatus 某文章的收藏状态
type FavoriteStatus struct { type FavoriteStatus struct {
Favorited bool `json:"favorited"` Favorited bool `json:"favorited"`
FolderID *uint `json:"folder_id,omitempty"` FolderID *uint `json:"folder_id,omitempty"`
FolderName *string `json:"folder_name,omitempty"` FolderName *string `json:"folder_name,omitempty"`
} }

View File

@ -9,10 +9,10 @@ import (
// LevelService 积分与等级业务逻辑 // LevelService 积分与等级业务逻辑
type LevelService struct { type LevelService struct {
userRepo userLevelStore userRepo userLevelStore
checkRepo checkInStore checkRepo checkInStore
taskRepo taskStore taskRepo taskStore
notifSvc levelNotifier notifSvc levelNotifier
energyCheckInSvc energyCheckIner energyCheckInSvc energyCheckIner
} }

View File

@ -15,11 +15,11 @@ import (
// PostService 帖子业务逻辑 // PostService 帖子业务逻辑
type PostService struct { type PostService struct {
repo postStore repo postStore
ss *config.SiteSettings ss *config.SiteSettings
notifier postNotifier notifier postNotifier
userRepo userExpReader userRepo userExpReader
onHomePageChanged func() onHomePageChanged func()
} }
// NewPostService 构造函数 // NewPostService 构造函数
@ -146,12 +146,12 @@ func (s *PostService) ListByUser(userID uint, status string, page, pageSize int)
// StudioOverview 创作中心数据概览 // StudioOverview 创作中心数据概览
type StudioOverview struct { type StudioOverview struct {
TotalPosts int64 `json:"total_posts"` TotalPosts int64 `json:"total_posts"`
DraftCount int64 `json:"draft_count"` DraftCount int64 `json:"draft_count"`
PendingCount int64 `json:"pending_count"` PendingCount int64 `json:"pending_count"`
ApprovedCount int64 `json:"approved_count"` ApprovedCount int64 `json:"approved_count"`
RejectedCount int64 `json:"rejected_count"` RejectedCount int64 `json:"rejected_count"`
TotalViews int64 `json:"total_views"` TotalViews int64 `json:"total_views"`
} }
// GetOverview 获取创作中心数据概览(单次 GROUP BY 查询) // GetOverview 获取创作中心数据概览(单次 GROUP BY 查询)

View File

@ -11,8 +11,8 @@ import (
type ReactionType string type ReactionType string
const ( const (
ReactionNone ReactionType = "none" ReactionNone ReactionType = "none"
ReactionLiked ReactionType = "liked" ReactionLiked ReactionType = "liked"
ReactionDisliked ReactionType = "disliked" ReactionDisliked ReactionType = "disliked"
) )

View File

@ -125,9 +125,6 @@ type EnergyStore interface {
Transaction(fn func(txEnergy EnergyStore, txFund FundStore) error) error Transaction(fn func(txEnergy EnergyStore, txFund FundStore) error) error
} }
// energyStore 向后兼容别名
type energyStore = EnergyStore
// FundStore 公户仓储接口ISP // FundStore 公户仓储接口ISP
type FundStore interface { type FundStore interface {
GetBalance() (int, error) GetBalance() (int, error)

View File

@ -98,10 +98,11 @@ func parseShortcode(raw string) (model.Shortcode, bool) {
// renderPlaceholder 根据 shortcode 类型生成前端占位 HTML // renderPlaceholder 根据 shortcode 类型生成前端占位 HTML
// //
// 占位 div 结构约定: // 占位 div 结构约定:
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数" //
// data-zone-extra="附加信息"> // <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
// <span class="zone-card-loading">卡片加载中...</span> // data-zone-extra="附加信息">
// </div> // <span class="zone-card-loading">卡片加载中...</span>
// </div>
// //
// 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。 // 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。
// "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。 // "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。

View File

@ -168,14 +168,6 @@ func (fs *FallbackStore) ping() bool {
return fs.client.Ping(ctx).Err() == nil return fs.client.Ping(ctx).Err() == nil
} }
// current 返回当前应使用的 StoreRedis 健康用 Redis否则用 Memory
func (fs *FallbackStore) current() Store {
if fs.healthy.Load() {
return fs.redis
}
return fs.memory
}
// ---- Store 接口实现Redis 操作失败时立即降级到内存)---- // ---- Store 接口实现Redis 操作失败时立即降级到内存)----
// 不依赖周期性健康检查,操作级故障即时响应 // 不依赖周期性健康检查,操作级故障即时响应

View File

@ -70,7 +70,7 @@ func (m *Manager) Validate(sid string) (*Session, error) {
user, err := m.userRepo.FindByIDForAuth(s.UserID) user, err := m.userRepo.FindByIDForAuth(s.UserID)
if err != nil { if err != nil {
log.Printf("[SessionManager] Validate: user not found uid=%d err=%v", s.UserID, err) log.Printf("[SessionManager] Validate: user not found uid=%d err=%v", s.UserID, err)
m.store.Delete(sid) _ = m.store.Delete(sid)
return nil, nil return nil, nil
} }

View File

@ -39,7 +39,7 @@ func (ms *MemoryStore) Get(id string) (*Session, error) {
timeout = ms.rememberTimeout timeout = ms.rememberTimeout
} }
if s.IsExpired(timeout) { if s.IsExpired(timeout) {
ms.Delete(id) _ = ms.Delete(id)
return nil, nil return nil, nil
} }
return s, nil return s, nil

View File

@ -3,6 +3,7 @@ package session
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"time" "time"
@ -51,16 +52,16 @@ func (rs *RedisStore) sessionKeys(sids []string) []string {
func (rs *RedisStore) Get(id string) (*Session, error) { func (rs *RedisStore) Get(id string) (*Session, error) {
ctx := context.Background() ctx := context.Background()
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes() data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
if err == redis.Nil { if errors.Is(err, redis.Nil) {
return nil, nil return nil, nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("Redis GET session: %w", err) return nil, fmt.Errorf("redis GET session: %w", err)
} }
var s Session var s Session
if err := json.Unmarshal(data, &s); err != nil { if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("Redis 反序列化 session: %w", err) return nil, fmt.Errorf("redis 反序列化 session: %w", err)
} }
return &s, nil return &s, nil
} }
@ -71,7 +72,7 @@ func (rs *RedisStore) Set(s *Session) error {
data, err := json.Marshal(s) data, err := json.Marshal(s)
if err != nil { if err != nil {
return fmt.Errorf("Redis 序列化 session: %w", err) return fmt.Errorf("redis 序列化 session: %w", err)
} }
ttl := rs.idleTimeout ttl := rs.idleTimeout
@ -91,7 +92,7 @@ func (rs *RedisStore) Set(s *Session) error {
_, err = pipe.Exec(ctx) _, err = pipe.Exec(ctx)
if err != nil { if err != nil {
return fmt.Errorf("Redis SET session: %w", err) return fmt.Errorf("redis SET session: %w", err)
} }
return nil return nil
} }
@ -102,11 +103,11 @@ func (rs *RedisStore) Delete(id string) error {
// 先读取 session 获取 UserID再从集合中移除 // 先读取 session 获取 UserID再从集合中移除
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes() data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
if err == redis.Nil { if errors.Is(err, redis.Nil) {
return nil return nil
} }
if err != nil { if err != nil {
return fmt.Errorf("Redis GET session for delete: %w", err) return fmt.Errorf("redis GET session for delete: %w", err)
} }
var s Session var s Session
@ -126,7 +127,7 @@ func (rs *RedisStore) DeleteByUID(uid uint) error {
sids, err := rs.client.SMembers(ctx, uidKey).Result() sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil { if err != nil {
return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err) return fmt.Errorf("redis SMEMBERS user_sessions: %w", err)
} }
if len(sids) == 0 { if len(sids) == 0 {
@ -138,7 +139,7 @@ func (rs *RedisStore) DeleteByUID(uid uint) error {
pipe.Del(ctx, uidKey) pipe.Del(ctx, uidKey)
_, err = pipe.Exec(ctx) _, err = pipe.Exec(ctx)
if err != nil { if err != nil {
return fmt.Errorf("Redis DeleteByUID: %w", err) return fmt.Errorf("redis DeleteByUID: %w", err)
} }
log.Printf("[RedisStore] DeleteByUID uid=%d count=%d", uid, len(sids)) log.Printf("[RedisStore] DeleteByUID uid=%d count=%d", uid, len(sids))
@ -152,7 +153,7 @@ func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
sids, err := rs.client.SMembers(ctx, uidKey).Result() sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil { if err != nil {
return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err) return fmt.Errorf("redis SMEMBERS user_sessions: %w", err)
} }
var toDelete []string var toDelete []string
@ -176,7 +177,7 @@ func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
} }
_, err = pipe.Exec(ctx) _, err = pipe.Exec(ctx)
if err != nil { if err != nil {
return fmt.Errorf("Redis DeleteByUIDExclude: %w", err) return fmt.Errorf("redis DeleteByUIDExclude: %w", err)
} }
log.Printf("[RedisStore] DeleteByUIDExclude uid=%d removed=%d kept=%d", uid, len(toDelete), kept) log.Printf("[RedisStore] DeleteByUIDExclude uid=%d removed=%d kept=%d", uid, len(toDelete), kept)
@ -190,7 +191,7 @@ func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) {
sids, err := rs.client.SMembers(ctx, uidKey).Result() sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("Redis SMEMBERS user_sessions: %w", err) return nil, fmt.Errorf("redis SMEMBERS user_sessions: %w", err)
} }
if len(sids) == 0 { if len(sids) == 0 {
@ -199,7 +200,7 @@ func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) {
results, err := rs.client.MGet(ctx, rs.sessionKeys(sids)...).Result() results, err := rs.client.MGet(ctx, rs.sessionKeys(sids)...).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("Redis MGET sessions: %w", err) return nil, fmt.Errorf("redis MGET sessions: %w", err)
} }
var sessions []*Session var sessions []*Session

View File

@ -1,3 +1,4 @@
// Package session 提供服务端会话管理创建、验证、销毁、Redis/内存双存储及自动降级。
package session package session
// StoreMetrics 存储状态信息(供管理面板展示) // StoreMetrics 存储状态信息(供管理面板展示)

View File

@ -1,3 +1,4 @@
// Package theme 负责多主题模板的加载、渲染和静态内容管理。
package theme package theme
import ( import (
@ -21,13 +22,13 @@ type TemplateRoot struct {
// 模板名 = prefix + 相对于 root.Dir 的相对路径 // 模板名 = prefix + 相对于 root.Dir 的相对路径
func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) { func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
"subtract": func(a, b int) int { return a - b }, "subtract": func(a, b int) int { return a - b },
"formatTTL": formatTTL, "formatTTL": formatTTL,
"formatCount": formatCount, "formatCount": formatCount,
"str": str, "str": str,
"assetV": common.AssetV, "assetV": common.AssetV,
"safeHTML": func(s string) template.HTML { return template.HTML(s) }, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, //nolint:gosec // 模板函数,由调用方保证内容安全
"declarationLabel": func(declaration string) string { "declarationLabel": func(declaration string) string {
return model.DeclarationLabels[declaration] return model.DeclarationLabels[declaration]
}, },
@ -62,7 +63,7 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
if info.IsDir() || filepath.Ext(path) != ".html" { if info.IsDir() || filepath.Ext(path) != ".html" {
return nil return nil
} }
content, err := os.ReadFile(path) content, err := os.ReadFile(path) //nolint:gosec // path 来自 filepath.Walk 遍历,非用户输入
if err != nil { if err != nil {
return err return err
} }
@ -81,11 +82,11 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
// LoadContent 读取主题内容文件(准则等),返回不转义的 template.HTML。 // LoadContent 读取主题内容文件(准则等),返回不转义的 template.HTML。
// 后期改为查数据库时,只需修改此函数实现,调用方不变。 // 后期改为查数据库时,只需修改此函数实现,调用方不变。
func LoadContent(path string) (template.HTML, error) { func LoadContent(path string) (template.HTML, error) {
content, err := os.ReadFile(path) content, err := os.ReadFile(path) //nolint:gosec // path 为内部主题文件路径
if err != nil { if err != nil {
return "", err return "", err
} }
return template.HTML(content), nil return template.HTML(content), nil //nolint:gosec // 主题内容由管理员维护,属信任输入
} }
// formatTTL 将剩余分钟数格式化为人类可读的时间(如 "23 小时"、"3 天"、"15 分钟" // formatTTL 将剩余分钟数格式化为人类可读的时间(如 "23 小时"、"3 天"、"15 分钟"

47
scripts/ci.sh Executable file
View File

@ -0,0 +1,47 @@
#!/bin/bash
# CI 流水线脚本
# 调用方式bash scripts/ci.sh [--strict]
# --strict 阻断模式lint 失败则 exit 1
# 默认 报告模式lint 只输出不阻断)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export PATH="$HOME/go/bin:$PATH"
cd "$ROOT"
STRICT=false
if [[ "${1:-}" == "--strict" ]]; then
STRICT=true
fi
echo "========================================"
echo " CI Pipeline"
echo " Mode: $( $STRICT && echo 'STRICT' || echo 'REPORT' )"
echo "========================================"
# 1. 格式检查
echo ""
echo "==> 1/3 格式检查 (gofumpt + goimports)"
test -z "$(gofumpt -l internal/ cmd/)" || { echo "gofumpt: 以下文件格式不正确:"; gofumpt -l internal/ cmd/; exit 1; }
test -z "$(goimports -l internal/ cmd/)" || { echo "goimports: 以下文件 import 不规范:"; goimports -l internal/ cmd/; exit 1; }
# 2. 编译检查
echo ""
echo "==> 2/3 编译检查 (go build)"
go build ./...
# 3. 静态分析
echo ""
echo "==> 3/3 静态分析 (golangci-lint)"
if $STRICT; then
golangci-lint run ./...
else
golangci-lint run ./... || true
fi
echo ""
echo "========================================"
echo " CI Pipeline 完成"
echo "========================================"

12
scripts/pre-push Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Git pre-push hook — push 前跑 CI 检查
# 安装cp scripts/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo ""
echo "==> Pre-push CI check..."
bash "$ROOT/scripts/ci.sh" --strict
echo "==> OK"