Compare commits
4 Commits
47efd7921b
...
99721f562c
| Author | SHA1 | Date | |
|---|---|---|---|
| 99721f562c | |||
| 97adf54d6d | |||
| e65f903362 | |||
| ef6c97106d |
41
.golangci.yml
Normal file
41
.golangci.yml
Normal 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
35
Makefile
Normal 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
|
||||
@ -106,5 +106,7 @@ func main() {
|
||||
|
||||
// 启动
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,426 +0,0 @@
|
||||
# MetaLab 项目全量代码审计报告
|
||||
|
||||
**审计日期**: 2025-05-31
|
||||
**审计范围**: `lab.metazone.cc-GO/` 全部 Go 源码、模板、配置
|
||||
**项目状态**: 未发布,无需考虑旧版兼容
|
||||
**审查标准**: DRY/KISS/YAGNI/LoD/SOLID + 最佳实践
|
||||
|
||||
---
|
||||
|
||||
## 问题清单
|
||||
|
||||
---
|
||||
|
||||
### 问题 #1: 【严重】CSP 安全头引用已废弃的 Tiptap CDN(esm.sh)
|
||||
|
||||
**类型**: 死代码 / 残留配置
|
||||
**位置**: `internal/middleware/security.go:12-21`
|
||||
**违反原则**: KISS(引用了不存在的依赖)
|
||||
|
||||
```go
|
||||
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js)
|
||||
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
||||
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
||||
"connect-src 'self' https://esm.sh"
|
||||
```
|
||||
|
||||
**问题**: 项目已迁移到 Vditor 编辑器,但 CSP 头仍保留 Tiptap 时代的 esm.sh CDN 白名单。三个指令(script-src、style-src、connect-src)都包含未使用的 `https://esm.sh`。
|
||||
|
||||
**风险**:
|
||||
- 扩大了不必要的 CSP 白名单,引入额外信任域
|
||||
- connect-src 允许到 esm.sh 的连接,可能泄露页面信息
|
||||
|
||||
**建议**: 移除所有 `https://esm.sh` 引用,highlight.js 主题已本地化为 73 个静态文件(`static/vditor/dist/js/highlight.js/styles/`),CSP 可完全收紧为 `'self'`。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #2: 【严重】Login() 中封禁状态检查缺少防时序攻击保护
|
||||
|
||||
**类型**: 安全缺陷
|
||||
**位置**: `internal/service/auth_service.go:151-154`
|
||||
**违反原则**: 最佳安全实践
|
||||
|
||||
```go
|
||||
// 封禁
|
||||
if user.Status == model.StatusBanned {
|
||||
return nil, common.ErrUserBanned // ← 没有 bcrypt dummy hash 比对
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 其他分支(维护模式、用户不存在、密码错误、StatusLocked)都有 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)` 防时序攻击,唯独 StatusBanned 分支缺失。攻击者可以通过响应时间差异判断被封禁的账号是否存在。
|
||||
|
||||
**建议**: 在返回 `ErrUserBanned` 前增加 dummy hash 比对:
|
||||
```go
|
||||
if user.Status == model.StatusBanned {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return nil, common.ErrUserBanned
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 #3: 【严重】自定义 constantTimeEq 不如 crypto/subtle 安全
|
||||
|
||||
**类型**: 安全缺陷 / 最佳实践
|
||||
**位置**: `internal/middleware/csrf_token.go:44-53`(被 `csrf.go:47` 调用)
|
||||
**违反原则**: 安全最佳实践
|
||||
|
||||
```go
|
||||
// constantTimeEq 恒定时间字符串比较(防时序攻击)
|
||||
func constantTimeEq(a, b string) bool {
|
||||
if len(a) != len(b) { // ← 长度不等时提前返回,泄露长度信息
|
||||
return false
|
||||
}
|
||||
var result byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
result |= a[i] ^ b[i]
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:
|
||||
1. `constantTimeEq` 在同包内被 `csrf.go:47` 调用,**非死代码**
|
||||
2. 但其手写实现存在两个安全隐患:
|
||||
- **长度提前返回泄露信息**:`len(a) != len(b)` 时立即返回 false,攻击者可通过响应时间判断 CSRF token 长度
|
||||
- **无编译器优化防护**:`crypto/subtle.ConstantTimeCompare` 内部使用了特殊的编译器屏障防止被优化,手写版本可能被 Go 编译器优化掉 XOR 结果检查
|
||||
3. 函数定义在 `csrf_token.go`(token 生成文件)而非 `csrf.go`(校验文件),逻辑归属不当
|
||||
|
||||
**建议**: 删除 `constantTimeEq`,改用 `crypto/subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1`。这也是 Go 官方推荐的做法。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #4: 【高】Admin 控制器锁定操作消息显示错误
|
||||
|
||||
**类型**: Bug
|
||||
**位置**: `internal/controller/admin/admin_controller.go:93-96`
|
||||
**违反原则**: 无(纯 bug)
|
||||
|
||||
```go
|
||||
action := "封禁"
|
||||
if req.Status == model.StatusActive {
|
||||
action = "解封"
|
||||
} else if req.Status == model.StatusLocked {
|
||||
action = "已删除" // ← BUG: 应该是 "已锁定"
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 当管理员执行锁定操作时,成功提示消息显示"已删除成功",而不是"已锁定成功"。Locked 与 Deleted 是两个完全不同的状态。
|
||||
|
||||
**建议**: 改为 `action = "已锁定"`
|
||||
|
||||
---
|
||||
|
||||
### 问题 #5: 【高】Login() 中密码验证方式不一致
|
||||
|
||||
**类型**: 代码一致性问题
|
||||
**位置**: `internal/service/auth_service.go:138,152`
|
||||
**违反原则**: KISS(同一逻辑用了两种实现)
|
||||
|
||||
```go
|
||||
// StatusDeleted 分支:
|
||||
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
|
||||
|
||||
// StatusBanned 之后的主路径:
|
||||
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
|
||||
```
|
||||
|
||||
而 `CheckPassword` 内部只是封装了单行:
|
||||
```go
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 虽然功能上没有 bug,但在同一函数中既有 `common.CheckPassword()` 调用,也有其他分支使用 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)`(直接调用 bcrypt)。风格不统一,且 `CheckPassword` 的封装价值极低(仅包装一行标准库调用,不如直接使用 bcrypt 调用更直观)。
|
||||
|
||||
**建议**:
|
||||
- 选项A: 删除 `common.CheckPassword`,统一使用 `bcrypt.CompareHashAndPassword`
|
||||
- 选项B: 在 `CheckPassword` 中增加防时序的一致性包装,统一入口
|
||||
|
||||
---
|
||||
|
||||
### 问题 #6: 【中】大量空模板目录(YAGNI 违规)
|
||||
|
||||
**类型**: YAGNI 违规
|
||||
**位置**:
|
||||
- `templates/MetaLab-2026/html/post/`(空)
|
||||
- `templates/MetaLab-2026/html/comment/`(空)
|
||||
- `templates/MetaLab-2026/html/partials/`(空)
|
||||
- `templates/MetaLab-2026/html/search/`(空)
|
||||
- `templates/MetaLab-2026/html/error/`(空)
|
||||
- `templates/MetaLab-2026/html/admin/`(空)
|
||||
- `templates/system/email/`(空)
|
||||
|
||||
**问题**: 7 个空目录,标注为"预留"。项目尚未发布,这些目录的创建时机应该和实际功能开发同步,而非提前占位。
|
||||
|
||||
**建议**: 删除所有空目录。需要时随功能一起创建。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #7: 【中】Shortcode 预留类型(poll/resource)无后端实现
|
||||
|
||||
**类型**: YAGNI 违规
|
||||
**位置**: `internal/model/shortcode.go:37-38`, `internal/service/shortcode_service.go:126-137`
|
||||
**违反原则**: YAGNI
|
||||
|
||||
```go
|
||||
ShortcodePoll ShortcodeType = "poll" // ※预留(后端API未实现)
|
||||
ShortcodeResource ShortcodeType = "resource" // ※预留(后端API未实现)
|
||||
```
|
||||
|
||||
**问题**: poll 和 resource 两个 shortcode 类型的后端 API 未实现,前端也未实现(shortcode.js 中可能也未实现对应渲染),但代码中已注册了完整的解析和占位 HTML 生成逻辑。用户实际上可以使用 `[zone:poll:xxx]` 语法,但会得到一个永远"加载中..."的卡片。
|
||||
|
||||
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。前端渲染逻辑将随 API 同步实现。
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 问题 #8: 【中】redis_store.go 全注释的"预留实现"
|
||||
|
||||
**类型**: YAGNI / 死代码
|
||||
**位置**: `internal/session/redis_store.go`
|
||||
**违反原则**: YAGNI
|
||||
|
||||
**问题**: 整个文件是注释掉的代码,没有实际可执行逻辑。如果未来需要 Redis 支持,到时再创建即可。
|
||||
|
||||
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。Redis 支持将在后续迭代中实现。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #9: 【中】tokenCtrl 是 authCtrl 的无意义别名
|
||||
|
||||
**类型**: 不必要的字段重复
|
||||
**位置**: `internal/router/deps_core.go:30`, `internal/router/deps_extra.go:54`
|
||||
**违反原则**: KISS
|
||||
|
||||
```go
|
||||
// deps_core.go
|
||||
type dependencies struct {
|
||||
// ...
|
||||
tokenCtrl *controller.AuthController // ← 与 authCtrl 类型完全相同
|
||||
}
|
||||
|
||||
// deps_extra.go
|
||||
return &dependencies{
|
||||
// ...
|
||||
tokenCtrl: authCtrl, // ← 赋的是同一个对象
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: `tokenCtrl` 和 `authCtrl` 指向同一个 `*controller.AuthController` 实例,`tokenCtrl` 仅在 `api.go` 的路由中使用(`d.tokenCtrl.CheckEmail/Register/Login/...`)。这个别名不带来任何好处,反而增加理解成本。
|
||||
|
||||
**建议**: 删除 `tokenCtrl` 字段,`api.go` 中直接使用 `d.authCtrl`。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #10: 【中】RateLimiter.check() 存在竞态条件
|
||||
|
||||
**类型**: 并发缺陷
|
||||
**位置**: `internal/middleware/ratelimit_core.go:34-81`
|
||||
|
||||
```go
|
||||
func (rl *RateLimiter) check(...) (RateLimitResult, func()) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
// ... 读取 state ...
|
||||
|
||||
recordFail := func() {
|
||||
rl.mu.Lock() // ← 重新获取锁
|
||||
defer rl.mu.Unlock()
|
||||
// ... 修改 state ...
|
||||
}
|
||||
return RateLimitResult{Blocked: false}, recordFail
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: `check()` 持锁检查后释放锁,返回的 `recordFail` 闭包在**锁外**执行,重新获取锁后再修改状态。
|
||||
|
||||
**修复方案**: 将 `check()` + `recordFail` 闭包模式重构为 `try()` 原子操作模式——在持锁状态下一次性完成检查+递增,消除竞态窗口。API 改为 `AllowAccount() RateLimitResult` / `AllowIP() RateLimitResult`(不再返回闭包)。调用方在失败时不再需要显式调用 `recordFail()`,成功时仍调用 `Clear()` 清除计数。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #11: 【低】audit_service.go review() 注释编号跳跃
|
||||
|
||||
**类型**: 文档瑕疵
|
||||
**位置**: `internal/service/audit_service.go:152-166`
|
||||
|
||||
```go
|
||||
// 3. 查审核人信息
|
||||
reviewer, err := s.userRepo.FindByID(reviewerID)
|
||||
// ...
|
||||
|
||||
// 5. 标记审核结果 ← 跳过了 4
|
||||
submission.ReviewedBy = &reviewerID
|
||||
```
|
||||
|
||||
**问题**: 注释编号从"3."直接跳到"5.",缺少"4."。
|
||||
|
||||
**建议**: 修正编号为连续递增。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #12: 【低】编译产物未纳入 .gitignore(误报,实际不存在)
|
||||
|
||||
**类型**: 仓库整洁性
|
||||
**位置**: `server`(根目录), `cmd/server/server`
|
||||
**状态**: 误报。经核实,`.gitignore` 已配置 `server` 规则且从未被 git 跟踪,`git rm --cached` 实际为空操作。此项已从修复表中移除。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #13: 【低】项目零测试覆盖
|
||||
|
||||
**类型**: 质量保障缺失
|
||||
**位置**: 整个项目(`*_test.go` 搜索结果: 0)
|
||||
|
||||
**问题**: 项目没有任何单元测试或集成测试。对于包含认证、权限、审核、数据持久化等复杂逻辑的系统,零测试意味着每次重构和修改都有回归风险。
|
||||
|
||||
**建议**: 至少为核心模块添加测试:
|
||||
1. `model/user.go` - HasMinRole/CanOperateRole(纯函数,易测)
|
||||
2. `service/auth_service.go` - 注册/登录/密码验证逻辑
|
||||
3. `service/admin_service.go` - checkAndOperate 权限矩阵
|
||||
4. `middleware/ratelimit_core.go` - 限流逻辑
|
||||
|
||||
---
|
||||
|
||||
### 问题 #14: 【低】全项目使用 log.Printf 无结构化日志
|
||||
|
||||
**类型**: 可维护性 / 可观测性
|
||||
**位置**: 10 个文件,约 17 处 `log.Printf` 调用
|
||||
|
||||
**问题**: 项目大量使用标准库 `log.Printf`,无日志级别、无结构化字段、无上下文追踪。在生产环境中排查问题困难,无法按级别过滤日志。
|
||||
|
||||
**建议**: 引入轻量结构化日志库(如 `slog`,Go 1.21+ 标准库),按级别区分 Info/Warn/Error,关键路径添加 trace/request ID。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #15: 【低】err != nil 返回时上下文信息丢失
|
||||
|
||||
**类型**: 可调试性
|
||||
**位置**: 多处,例如 `internal/repository/user_repo.go`
|
||||
|
||||
```go
|
||||
func (r *UserRepo) Create(user *model.User) error {
|
||||
return r.db.Create(user).Error // ← 无上下文
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 数据库操作失败时,调用方只知道"出错了",无法快速定位是哪个操作、哪个实体、哪个 ID 导致的失败。
|
||||
|
||||
**建议**: 使用 `fmt.Errorf("创建用户失败: %w", err)` 包装错误,在保持错误链的同时添加操作上下文。
|
||||
|
||||
---
|
||||
|
||||
### 问题 #16: 【低】common.CheckPassword 封装价值极低
|
||||
|
||||
**类型**: KISS 违规
|
||||
**位置**: `internal/common/crypto.go:12-15`
|
||||
|
||||
```go
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
```
|
||||
|
||||
**问题**: 仅包装一行标准库调用,无额外逻辑。与同一文件中封装了 bcrypt 成本参数的 `HashPassword` 不同,`CheckPassword` 没有提供抽象价值。反而因为隐藏了 `bcrypt.CompareHashAndPassword` 的调用,在需要 `dummyHash` 比对时(如 auth_service.go 的时序攻击防护)不得不绕过它直接调用 bcrypt。
|
||||
|
||||
**建议**:
|
||||
- 如果保留 `CheckPassword`,将 dummy hash 比对也内置进去
|
||||
- 或者删除此函数,直接在各处显式调用 `bcrypt.CompareHashAndPassword`
|
||||
|
||||
---
|
||||
|
||||
### 问题 #17: 【低】deps_core.go 中文注释错别字
|
||||
|
||||
**类型**: 文档瑕疵
|
||||
**位置**: `internal/router/deps_core.go:45`
|
||||
|
||||
```go
|
||||
// 启动后台过清理 goroutine
|
||||
```
|
||||
|
||||
**问题**: "过清理"应为"过期清理",少了一个"期"字。
|
||||
|
||||
**建议**: 修正为 `启动后台过期清理 goroutine`
|
||||
|
||||
---
|
||||
|
||||
## 汇总统计
|
||||
|
||||
| 严重程度 | 数量 | 问题编号 |
|
||||
|---------|------|---------|
|
||||
| 严重 | 3 | #1, #2, #3 |
|
||||
| 高 | 2 | #4, #5 |
|
||||
| 中 | 5 | #6, #7, #8, #9, #10 |
|
||||
| 低 | 7 | #11, #12, #13, #14, #15, #16, #17 |
|
||||
|
||||
**总计: 17 个问题**
|
||||
|
||||
### 按原则分类
|
||||
|
||||
| 原则 | 问题编号 |
|
||||
|------------|---------|
|
||||
| 安全 | #1, #2, #3 |
|
||||
| YAGNI | #6, #7, #8 |
|
||||
| KISS | #5, #9, #16 |
|
||||
| Bug | #4, #10 |
|
||||
| 质量/可维护性 | #12, #13, #14, #15 |
|
||||
| 文档 | #11, #17 |
|
||||
|
||||
---
|
||||
|
||||
## 整体评价
|
||||
|
||||
项目的分层架构设计合理,严格遵循单向依赖(router→controller→service→repository→model),接口隔离原则(ISP)执行到位,每层都通过最小接口依赖下层。依赖注入清晰,无循环依赖。
|
||||
|
||||
主要问题集中在三个方面:
|
||||
1. **安全防护需加强** - CSP 配置残留、时序攻击防护不完整
|
||||
2. **代码清理不及时** - 存在死代码、预留目录、未使用函数
|
||||
3. **工程基础设施薄弱** - 零测试、无结构化日志、错误上下文丢失
|
||||
|
||||
建议优先处理严重级别问题(#1~#3),然后按批次逐步处理其余问题。
|
||||
|
||||
---
|
||||
|
||||
## 修复记录
|
||||
|
||||
**修复日期**: 2025-05-31
|
||||
**执行方式**: 全量修复(commit: `fix: 审计问题全量修复(安全/YAGNI/Bug/代码质量)`)
|
||||
|
||||
### 已修复(13 项)
|
||||
|
||||
| # | 修复内容 | 变更文件 |
|
||||
|---|---------|---------|
|
||||
| 1 | 移除 CSP 中 esm.sh (Tiptap 残留) 和 cdnjs.cloudflare.com(主题已本地化),CSP 全面收紧为 'self' | `middleware/security.go` |
|
||||
| 2 | StatusBanned 分支增加 dummy hash 防时序攻击 | `service/auth_service.go` |
|
||||
| 3 | 删除手写 constantTimeEq,改用 `crypto/subtle.ConstantTimeCompare` | `middleware/csrf_token.go`, `middleware/csrf.go` |
|
||||
| 4 | 锁定操作消息修正"已删除"→"已锁定" | `controller/admin/admin_controller.go` |
|
||||
| 5 | 删除 common.CheckPassword 薄封装,统一改用 bcrypt.CompareHashAndPassword | `common/crypto.go`, `service/auth_service.go` |
|
||||
| 6 | 删除 12 个空预留目录(含第二轮追加 5 个) | `templates/MetaLab-2026/html/{post,comment,partials,search,error,admin,topic,user}`, `templates/{system,system/email}`, `templates/MetaLab-2026/static/{img,vendor}` |
|
||||
| 9 | 删除 tokenCtrl 别名字段,统一使用 authCtrl | `router/deps_core.go`, `router/deps_extra.go`, `router/api.go` |
|
||||
| 10 | 将 check()+recordFail 闭包重构为 try() 原子操作,消除竞态 | `middleware/ratelimit_core.go`, `middleware/ratelimit.go`, `middleware/ratelimit_cleanup.go`, `controller/auth_api_login.go`, `controller/auth_api_register.go` |
|
||||
| 11 | 修正 review() 注释编号 3→5→4 | `service/audit_service.go` |
|
||||
| 17 | 修正"过清理"→"过期清理" | `router/deps_core.go` |
|
||||
|
||||
### 已回滚(误删,已排期开发,保留)
|
||||
|
||||
| # | 回滚内容 | 变更文件 |
|
||||
|---|---------|---------|
|
||||
| 7 | 恢复 poll/resource shortcode 类型(误删) | `model/shortcode.go`, `service/shortcode_service.go` |
|
||||
| 8 | 恢复 redis_store.go 预留实现(误删) | `session/redis_store.go` |
|
||||
|
||||
### 误报(实际不存在)
|
||||
|
||||
| # | 说明 |
|
||||
|---|------|
|
||||
| 12 | 编译产物从未被 git 跟踪,`.gitignore` 已生效,`git rm --cached` 为空操作 |
|
||||
|
||||
### 暂缓修复(3 项)
|
||||
|
||||
| # | 暂缓原因 | 后续计划 |
|
||||
|---|---------|---------|
|
||||
| 13 | 零测试 — 需要建立测试框架、mock 策略,工作量大 | 核心模块优先:hasMinRole、checkAndOperate、RateLimiter |
|
||||
| 14 | 无结构化日志 — 需评估 slog vs zap,全量替换 log.Printf | Go 1.21+ 使用标准库 slog 渐进替换 |
|
||||
| 15 | 错误上下文丢失 — 涉及全部 repository 层,工作量大 | 按文件逐批添加 `fmt.Errorf("...: %w", err)` |
|
||||
@ -1,5 +1,103 @@
|
||||
# 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 行 | 单一职责 |
|
||||
| Router | ≤ 60 行 | 只做路由映射 |
|
||||
|
||||
> **注意**:以上为建议上限,不是硬性指标。**是否拆分取决于职责是否内聚**,而非单纯看行数。拆分决策矩阵:
|
||||
> **注意**:行数上限为**警告线**而非硬截断。拆分决策遵循 SRP(一个文件只有一个变更原因):
|
||||
|
||||
| 该拆 | 不该拆 |
|
||||
|------|--------|
|
||||
| 多个独立功能塞一个文件(如 7 个 Tab 的 JS 全混居 + 5 个 IIFE) | 页面内区域共享大量样式,拆了反而碎片化(如文章详情/列表共用 `article-card`) |
|
||||
| 一个文件混了两个及以上业务域(如 `commentService` + `likeService` 塞同一个文件) | 只有单一业务域,但因逻辑复杂产生了大量私有辅助函数 — 保持内聚 |
|
||||
| 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) |
|
||||
| CSS 随 HTML 模板拆分自然跟随 | 公共工具函数拆成碎片没有意义 |
|
||||
| 路由文件按业务域自然分割(与 admin.go 对齐) | 文件超线但职责单一、内聚良好 — 维持现状 |
|
||||
| 路由文件按业务域自然分割 | 文件超线但职责单一、内聚良好 — 维持现状 |
|
||||
|
||||
> **判定口诀**:多域混居 → 拆;单域内聚 → 留。如果"辅助函数太多"到了怀疑域本身是否单一的 程度,那问题不是该拆文件,而是该把域拆小(如 `PostService` → `CommentService` + `LikeService`),按变更原因重新切分。
|
||||
|
||||
## 瘦控制器铁律
|
||||
|
||||
@ -60,13 +159,120 @@ func (c *PostController) Create(ctx *gin.Context) {
|
||||
|
||||
业务判断全部放在 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>` 标签拿到不同 Nonce,CSP 校验失败。
|
||||
- **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 禁止事项
|
||||
|
||||
- ❌ 直接调用 Repository
|
||||
- ❌ 调用另一个 Controller
|
||||
- ❌ 写 if-else 业务分支
|
||||
- ❌ 在 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`
|
||||
- 模板文件:`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(...)`
|
||||
- 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 状态码直接传入:
|
||||
@ -99,7 +346,7 @@ common.Error(c, http.StatusInternalServerError, "服务器错误") // 500
|
||||
|
||||
### 模板变量
|
||||
|
||||
使用驼峰命名,与模板文件保持一致:
|
||||
使用驼峰命名,变量名与 Go 模板中 `{{.VariableName}}` 的引用名一致:
|
||||
|
||||
```go
|
||||
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 的依赖通过构造函数传入:
|
||||
@ -134,6 +404,128 @@ type siteSettingUseCase interface {
|
||||
|
||||
各 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)
|
||||
@ -165,6 +557,13 @@ Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
|
||||
|
||||
### CMS 化
|
||||
|
||||
所有面向最终用户的硬编码文本(品牌名、标语、邮箱、空状态提示、系统消息等)应纳入 `site_settings` 表配置化,而非硬编码在模板或 Go 代码中。管理后台需提供对应输入项。
|
||||
**纳入 CMS 配置化的内容**(站点级可配置文案,需管理后台提供对应输入项):
|
||||
- 品牌名、标语、版权声明、联系邮箱
|
||||
- 页面标题前缀/后缀、SEO 描述
|
||||
- 空状态占位文案、系统级通知横幅
|
||||
- 其他面向最终用户展示的站点文本
|
||||
|
||||
例外:CSS 注释、JS debug 日志、代码中的技术常量不受此规则约束。
|
||||
**不受此规则约束的例外**(无需配置化):
|
||||
- 表单校验错误消息(如"用户名不能为空")— 属于应用逻辑,不是站点配置
|
||||
- `common.Error()` 的通用错误提示(如"参数错误""服务器错误")— 程序内部语义
|
||||
- CSS 注释、JS debug 日志、代码中的技术常量
|
||||
|
||||
@ -1,231 +0,0 @@
|
||||
# 帖子系统代码审计报告
|
||||
|
||||
> 审计日期:2026-05-30
|
||||
> 审计范围:`internal/` 下所有 Post 相关文件(router / controller / service / repository / model)
|
||||
> 审计标准:DRY / KISS / YAGNI / LoD / SOLID + 分层架构规范
|
||||
|
||||
---
|
||||
|
||||
## 一、架构合规性总览
|
||||
|
||||
| 检查项 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 分层依赖方向 | ✅ | router→controller→service→repo→model,严格单向 |
|
||||
| Controller 接口文件 | ✅ | `controller/interfaces.go` + `admin/interfaces.go` |
|
||||
| Service Repository 接口 | ✅ | `service/repository.go`,8 个方法,无冗余 |
|
||||
| ISP 接口隔离 | ✅ | 前台 `postUseCase` 与后台 `adminPostUseCase` 分离 |
|
||||
| DIP 依赖倒置 | ✅ | Controller→接口, Service→接口 |
|
||||
| 依赖注入 | ✅ | 全部构造函数注入,无硬编码 |
|
||||
| KISS | ✅ | 无过度设计 |
|
||||
| YAGNI | ✅ | 无超前功能 |
|
||||
| LoD | ✅ | 无跨层直接依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 二、问题清单
|
||||
|
||||
### 🔴 中等问题(4 个)
|
||||
|
||||
#### 问题 1:Repository 方法代码重复(~70%)
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/repository/post_repo.go` |
|
||||
| **位置** | `FindPageable`(行 49-73)vs `FindAdminPageable`(行 76-102) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | 两个方法的核心查询逻辑(Table + Select + Joins + keyword LIKE + Count + Offset/Limit + ORDER BY)几乎完全一致,唯一区别是 `FindPageable` 固定过滤 `status = 'approved'`,`FindAdminPageable` 支持可选的 status 参数。两段代码约 70% 重复。 |
|
||||
|
||||
**现状:**
|
||||
```go
|
||||
// FindPageable (行 49-73)
|
||||
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||
query := r.db.Table("posts").
|
||||
Select("posts.*, users.username as author_name").
|
||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||
Where("posts.deleted_at IS NULL").
|
||||
Where("posts.status = ?", model.PostStatusApproved) // ← 唯一差异
|
||||
|
||||
if keyword != "" { /* LIKE 过滤 */ }
|
||||
// ... Count + Offset/Limit + Find
|
||||
}
|
||||
|
||||
// FindAdminPageable (行 76-102)
|
||||
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||
query := r.db.Table("posts").
|
||||
Select("posts.*, users.username as author_name").
|
||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||
Where("posts.deleted_at IS NULL")
|
||||
// ← 无硬编码 status,由参数控制
|
||||
|
||||
if keyword != "" { /* LIKE 过滤 */ }
|
||||
if status != "" { query = query.Where("posts.status = ?", status) }
|
||||
// ... Count + Offset/Limit + Find
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 提取私有方法 `findPageableCommon`,两个公开方法调用它并传入各自的 WHERE 条件。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 2:Service 方法重复
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/service/post_service.go` |
|
||||
| **位置** | `List`(行 148-156)vs `ListAdmin`(行 159-167) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | 两个方法的 nil 检查 + 分页调用模式完全一致,唯一区别是调用的 repo 方法不同。 |
|
||||
|
||||
**现状:**
|
||||
```go
|
||||
// List (行 148-156)
|
||||
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
posts, total, err := s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
|
||||
if posts == nil { posts = []model.Post{} }
|
||||
return posts, total, err
|
||||
}
|
||||
|
||||
// ListAdmin (行 159-167)
|
||||
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
posts, total, err := s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
|
||||
if posts == nil { posts = []model.Post{} }
|
||||
return posts, total, err
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 提取公共的 Pagination 创建 + nil 检查逻辑。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 3:Controller 权限检查重复 6 处
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/post_controller.go` |
|
||||
| **位置** | `ShowPage`(行 82-90)、`EditPage`(行 144-150)、`Update`(行 202-211)、`Delete`(行 239-248)、`Submit`(行 272-281)、`ShowAPI`(行 333-339) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | 以下模式在 6 个方法中逐字重复: |
|
||||
|
||||
```go
|
||||
post, err := ctrl.postService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
if !model.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 提取私有方法 `getPostAndCheckAccess(id, c)` 返回 `(*model.Post, bool)`。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 4:Model 层职责过重
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/model/post.go` |
|
||||
| **位置** | 行 48-83 |
|
||||
| **违反原则** | SRP(单一职责) |
|
||||
| **描述** | 一个文件混合了多种职责: |
|
||||
|
||||
| 内容 | 类型 | 应在位置 |
|
||||
|------|------|---------|
|
||||
| `Post` struct | 数据模型 | ✅ Model 层 |
|
||||
| `PostStatusDraft` 等常量 | 状态常量 | ✅ Model 层 |
|
||||
| `PostStatusDisplayNames` | 视图映射 | ❌ 应移到 View/Controller 层 |
|
||||
| `PostListResult` | DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `PostCreateRequest` / `PostUpdateRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `PostRejectRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `PostListQuery` | 查询 DTO | ❌ 应移到 `model/dto.go` |
|
||||
| `IsPostAccessible` | 业务权限逻辑 | ❌ 应移到 Service 层 |
|
||||
|
||||
**建议修复:** DTO 结构体移到 `model/dto.go`,`PostStatusDisplayNames` 移到 common 或 controller,`IsPostAccessible` 移到 service 层或独立权限模块。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 轻微问题(3 个)
|
||||
|
||||
#### 问题 5:工具函数位置不当
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/post_controller.go` |
|
||||
| **位置** | `saveUploadedFile`(行 410-430) |
|
||||
| **违反原则** | SRP / LoD |
|
||||
| **描述** | `saveUploadedFile` 是通用文件 I/O 工具函数(创建目录 + 32KB buffer 循环写入),与 HTTP 处理无关,不依赖 controller 的任何字段。放在 controller 文件中职责不匹配。 |
|
||||
|
||||
**建议修复:** 移到 `internal/common/` 或新建 `internal/util/` 包。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 6:Controller 层分页重复初始化
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/post_controller.go` |
|
||||
| **位置** | `ListPage`(行 47-49)和 `ListAPI`(行 307-308) |
|
||||
| **违反原则** | DRY |
|
||||
| **描述** | Controller 层为模板渲染再次创建 `Pagination` 对象并调用 `DefaultPagination()`,而 Service 层 `List()` / `ListAdmin()` 内部已经做过一次分页参数校验。Controller 层可以信任 Service 返回的 total 直接计算。 |
|
||||
|
||||
```go
|
||||
// controller 层重复的:
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
// ... 然后调用 service.List(),service 里又做了一遍
|
||||
```
|
||||
|
||||
**建议修复:** Controller 层直接用 `common.Pagination.PageCount(total, pageSize)` 计算总页数,不再重复调用 `DefaultPagination()`。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 7:Admin Restore 错误分类缺失
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/controller/admin/admin_post_controller.go` |
|
||||
| **位置** | `Restore`(行 153-155) |
|
||||
| **违反原则** | 一致性 |
|
||||
| **描述** | 同文件内其他方法(`Approve`/`Reject`/`Unlock`/`Lock`)都对 `ErrPostNotFound` 做 `errors.Is` 精确匹配返回 400,但 `Restore` 没有,所有错误统一返回 500。 |
|
||||
|
||||
```go
|
||||
// Restore — 缺少错误分类
|
||||
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||
// ...
|
||||
if err := ctrl.postService.Restore(id); err != nil {
|
||||
// ← 此处应匹配 ErrPostNotFound,返回 400 而非 500
|
||||
common.Error(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**建议修复:** 添加 `errors.Is(err, common.ErrPostNotFound)` 判断,返回 400。
|
||||
|
||||
---
|
||||
|
||||
## 三、亮点
|
||||
|
||||
1. **状态机设计清晰**:Post 状态流转(draft → pending → approved/rejected → locked)每个转换有明确前置条件检查和专用错误哨兵
|
||||
2. **接口设计优秀**:`postUseCase` vs `adminPostUseCase` 的分离体现良好的关注点分离
|
||||
3. **错误哨兵统一管理**:`common/errors.go` 集中定义所有 Post 相关错误
|
||||
4. **审核开关灵活**:通过 `SiteSettings.IsAuditEnabled()` 运行时控制
|
||||
5. **Shortcode 扩展性好**:新增类型只需添加常量和 case 分支
|
||||
6. **软删除 + 恢复**:完善的软删除和恢复机制
|
||||
|
||||
---
|
||||
|
||||
## 四、优先级建议
|
||||
|
||||
| 优先级 | 问题编号 | 原因 |
|
||||
|--------|---------|------|
|
||||
| P0 | — | 无阻塞性问题 |
|
||||
| P1 | 1, 3 | Repository/Controller 重复影响维护成本 |
|
||||
| P2 | 2, 4 | Service 重复 + Model 职责拆分 |
|
||||
| P3 | 5, 6, 7 | 轻微优化项 |
|
||||
@ -1,319 +0,0 @@
|
||||
# Vditor 整合 + MD 存储迁移方案
|
||||
|
||||
> **状态:已实施 ✓** `2026-05-30`
|
||||
|
||||
## 决策与结果
|
||||
|
||||
| 决策 | 结论 | 结果 |
|
||||
|------|------|------|
|
||||
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
|
||||
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
|
||||
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
|
||||
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
|
||||
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist,5.8MB(仅必需插件) |
|
||||
|
||||
---
|
||||
|
||||
## 最终数据流
|
||||
|
||||
```
|
||||
编辑器: Vditor(wysiwyg) → vditor.getValue() → MD 纯文本
|
||||
↓
|
||||
存储: 后端直接存 MD(纯文本无 XSS 风险,无需 sanitize)
|
||||
└─ generateExcerpt() 生成 plain text 摘要
|
||||
↓
|
||||
显示: Vditor.preview() 客户端 MD → HTML + github-dark 代码主题
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 变更文件清单(11 个)
|
||||
|
||||
| 文件 | 改动 | 详情 |
|
||||
|------|------|------|
|
||||
| `internal/model/post.go` | 修改 | `Body` → MD 存储,去 `BodyHTML`,加 `Excerpt`(varchar 500) |
|
||||
| `internal/service/post_service.go` | 重写 | 去 bluemonday,加 `generateExcerpt()`(纯 Go regex,无外部依赖) |
|
||||
| `internal/controller/post_controller.go` | 修改 | `UploadImage` → Vditor 原生格式,`ShowPage` 去 BodyHTML,去 `html/template` import |
|
||||
| `internal/common/response.go` | 新增函数 | `VditorUploadOk()` — Vditor 图片上传成功响应 |
|
||||
| `templates/.../posts/new.html` | 重写 | Tiptap → Vditor,去除 importmap/工具栏/语言选择器 |
|
||||
| `templates/.../posts/show.html` | 重写 | Vditor 客户端 MD 渲染,去 highlight.js CDN,去代码标签注入脚本 |
|
||||
| `templates/.../posts/index.html` | 微调 | `Body` 截断 → `Excerpt`(带降级回退) |
|
||||
| `templates/.../editor.js` | 重写 | Vditor 初始化 + CSRF + upload + draft + word count + submit |
|
||||
| `templates/.../posts.css` | 删减 | 去 Tiptap 样式(~150 行)+ 工具栏样式(~40 行),加 Vditor 微调 |
|
||||
| `static/vditor/dist/` | 新增 | 本地 Vditor 文件(5.8MB) |
|
||||
| `go.mod / go.sum` | 清理 | 移除 bluemonday 依赖 |
|
||||
|
||||
**不涉及的路由/中间件/仓储:零改动。**
|
||||
|
||||
---
|
||||
|
||||
## 各层详细实现
|
||||
|
||||
### 1. Model `internal/model/post.go`
|
||||
|
||||
```go
|
||||
type Post struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary
|
||||
// ... 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Service `internal/service/post_service.go`
|
||||
|
||||
- 删除 `sanitizePolicy`(bluemonday UGC)和 `sanitizeHTML()`
|
||||
- 新增 `generateExcerpt(md string) string`:
|
||||
- 按行逐条去掉 MD 语法(代码块、图片、标题 #、粗体/斜体、引用 >、列表标记等)
|
||||
- 链接保留文字 `[text](url)` → `text`
|
||||
- 按 rune 截断 300 字符,末尾加 `...`
|
||||
- 纯 Go `regexp` 实现,零外部依赖
|
||||
- `Create()` / `Update()` 生成 `Excerpt`,MD 直存无消毒
|
||||
|
||||
### 3. Controller `internal/controller/post_controller.go`
|
||||
|
||||
**UploadImage — Vditor 原生响应格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"errFiles": [],
|
||||
"succMap": {
|
||||
"微信截图_2026.png": "/uploads/posts/1_1717071234567.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**ShowPage — 去掉 `PostBodyHTML`**,MD 由前端渲染。
|
||||
|
||||
### 4. Common `internal/common/response.go`
|
||||
|
||||
```go
|
||||
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": gin.H{
|
||||
"errFiles": []string{},
|
||||
"succMap": succMap,
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 编辑器 `posts/new.html` + `editor.js`
|
||||
|
||||
**Vditor 初始化关键配置:**
|
||||
|
||||
```javascript
|
||||
new Vditor('vditor', {
|
||||
mode: 'wysiwyg',
|
||||
cdn: '/static/vditor', // 所有动态加载走本地
|
||||
height: '100%',
|
||||
lang: 'zh_CN',
|
||||
toolbar: [
|
||||
'headings', 'bold', 'italic', 'strike', '|',
|
||||
'line', 'code', 'inline-code', 'link', 'quote', '|',
|
||||
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
|
||||
'upload', 'table', '|',
|
||||
'undo', 'redo', '|',
|
||||
'fullscreen', 'code-theme', '|',
|
||||
'outline', 'preview', 'devtools',
|
||||
],
|
||||
upload: {
|
||||
url: '/api/posts/upload-image',
|
||||
fieldName: 'file',
|
||||
max: 5 * 1024 * 1024,
|
||||
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
|
||||
setHeaders() { // 每次请求重新读取 CSRF
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' };
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
||||
hljs: { style: 'github-dark', enable: true },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**CSRF 处理**:`upload.setHeaders` 为函数,每次上传前动态读取 `meta[name="csrf-token"]`,不会因 token 过期而失败。
|
||||
|
||||
**草稿机制**:
|
||||
- 每 2 秒自动保存 `title` + `md` 到 `localStorage`
|
||||
- 键名:`draft_post_title` / `draft_post_body_md`(与旧 Tiptap HTML 草稿隔离)
|
||||
- 新帖模式下自动恢复草稿
|
||||
|
||||
**表单提交**:`vditor.getValue()` 获取 MD,`POST /api/posts` 或 `PUT /api/posts/:id`
|
||||
|
||||
### 6. 详情页 `posts/show.html`
|
||||
|
||||
```html
|
||||
<!-- MD 内容通过隐藏 textarea 安全传递给 JS -->
|
||||
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
|
||||
|
||||
<!-- Vditor 仅需 method.min.js(47KB),不需要完整编辑器 -->
|
||||
<script src="/static/vditor/dist/method.min.js"></script>
|
||||
<script>
|
||||
Vditor.preview(document.getElementById('postContent'), md, {
|
||||
cdn: '/static/vditor',
|
||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
||||
hljs: { style: 'github-dark', enable: true },
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 7. 列表页 `posts/index.html`
|
||||
|
||||
```html
|
||||
<!-- Excerpt 优先,降级回退 Body 截断(兼容旧数据) -->
|
||||
<p class="post-card-summary">
|
||||
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
|
||||
</p>
|
||||
```
|
||||
|
||||
### 8. 样式 `posts.css`
|
||||
|
||||
- 移除:`.tiptap` 及所有子选择器(~100 行)、highlight.js 硬编码主题色(~35 行)、手动工具栏样式(~40 行)、JS 注入代码标签 CSS
|
||||
- 保留:`.editor-layout` / `.editor-header` / `.editor-main` / `.editor-pane` / `.editor-statusbar`(布局)
|
||||
- 新增:`#vditor` flex 适配、`.vditor-toolbar` / `.vditor-content` 主题微调
|
||||
|
||||
---
|
||||
|
||||
## Vditor 本地文件结构
|
||||
|
||||
```
|
||||
templates/MetaLab-2026/static/vditor/dist/
|
||||
├── index.css # 43KB 编辑器 + 预览全部样式
|
||||
├── index.min.js # 292KB 编辑器核心(wysiwyg/sv/ir + 工具栏 + 上传)
|
||||
├── method.min.js # 47KB 独立方法(Vditor.preview 等,详情页用)
|
||||
├── css/content-theme/
|
||||
│ ├── light.css # 内容区明亮主题
|
||||
│ ├── dark.css # 内容区暗色主题
|
||||
│ ├── ant-design.css # Ant Design 主题
|
||||
│ └── wechat.css # 微信主题
|
||||
├── images/
|
||||
│ ├── img-loading.svg # 上传进度指示
|
||||
│ ├── logo.png # Vditor logo
|
||||
│ └── emoji/ # 内置表情包(b3log/octocat/doge 等)
|
||||
├── js/
|
||||
│ ├── highlight.js/
|
||||
│ │ ├── highlight.min.js # 1.4MB 代码高亮核心
|
||||
│ │ ├── third-languages.js # 额外语言支持
|
||||
│ │ └── styles/*.min.css # 60+ 代码主题
|
||||
│ ├── lute/
|
||||
│ │ └── lute.min.js # 3.9MB WASM MD 解析器(Vditor 核心依赖)
|
||||
│ ├── i18n/
|
||||
│ │ └── zh_CN.js # 8KB 中文语言包
|
||||
│ └── icons/
|
||||
│ ├── material.js # Material Design 图标
|
||||
│ └── ant.js # Ant Design 图标
|
||||
```
|
||||
|
||||
**已剔除的插件**(节省 16MB+):
|
||||
|
||||
| 插件 | 大小 | 功能 | 对技术论坛无用 |
|
||||
|------|------|------|---------------|
|
||||
| mathjax | 6.5MB | LaTeX 数学公式 | ❌ |
|
||||
| mermaid | 2.6MB | 流程图/时序图 | ❌ |
|
||||
| graphviz | 2.0MB | 图谱渲染 | ❌ |
|
||||
| katex | 1.5MB | 数学公式引擎 | ❌ |
|
||||
| echarts | 1.0MB | 图表渲染 | ❌ |
|
||||
| markmap | 836KB | 思维导图 | ❌ |
|
||||
| abcjs | 356KB | 五线谱 | ❌ |
|
||||
| plantuml | 32KB | PlantUML | ❌ |
|
||||
| flowchart.js / smiles-drawer | ~400KB | 流程图 / 化学分子式 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Shortcode 扩展(`[zone:type:params]`)
|
||||
|
||||
### 语法
|
||||
|
||||
在 Markdown 正文中嵌入特殊卡片,语法为 `[zone:类型:参数]`:
|
||||
|
||||
```markdown
|
||||
# 周末活动汇总
|
||||
|
||||
下面是我们本周的推荐活动:
|
||||
|
||||
[zone:event:summer2026]
|
||||
|
||||
更多内容请关注官方动态...
|
||||
```
|
||||
|
||||
### 已注册类型
|
||||
|
||||
| 语法 | 渲染结果 | 状态 |
|
||||
|------|---------|------|
|
||||
| `[zone:event:活动ID]` | 活动卡片(🎪 图标 + ID + 跳转链接) | ✅ 已实现(占位) |
|
||||
| `[zone:game:游戏slug]` | 游戏卡片(🎮 图标 + slug + 跳转链接) | ✅ 已实现(占位) |
|
||||
| `[zone:poll:投票ID]` | 投票组件 | ⏳ 预留 |
|
||||
| `[zone:resource:资源ID]` | 资源推荐卡片 | ⏳ 预留 |
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
MD body "[zone:event:summer2026]"
|
||||
│
|
||||
▼
|
||||
ShortcodeService.Process() ← 后端:正则替换 [zone:...] → 占位 <div>
|
||||
│
|
||||
▼
|
||||
VDitor.preview() ← 前端:MD → HTML,占位 div 原样保留
|
||||
│
|
||||
▼
|
||||
renderShortcodes() ← 前端 shortcode.js:扫描 .zone-card,渲染 UI 卡片
|
||||
```
|
||||
|
||||
### 文件清单
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `internal/model/shortcode.go` | 类型常量 + 正则 + DTO |
|
||||
| `internal/service/shortcode_service.go` | 解析器 + 占位 HTML 生成器 |
|
||||
| `internal/controller/post_controller.go` | ShowPage/ShowAPI 调用 Process() |
|
||||
| `internal/router/deps_extra.go` | DI 注入 ShortcodeService |
|
||||
| `templates/.../js/shortcode.js` | 前端卡片渲染器 |
|
||||
| `templates/.../html/posts/show.html` | 引入 shortcode.js + 调用 renderShortcodes() |
|
||||
| `templates/.../static/css/posts.css` | 卡片样式 |
|
||||
| `templates/.../static/js/editor.js` | hint 自动补全提示 |
|
||||
|
||||
### 如何添加新类型
|
||||
|
||||
1. `internal/model/shortcode.go`:注册 `ShortcodeType` 常量
|
||||
2. `internal/service/shortcode_service.go`:在 `renderPlaceholder()` 添加 case
|
||||
3. `templates/.../js/shortcode.js`:在 `cardRenderers` 注册渲染函数
|
||||
4. `templates/.../static/js/editor.js`:在 `hint.extend` 添加补全提示
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 未注册或格式错误的 shortcode **不报错**,原文保留,避免破坏用户内容
|
||||
- 占位 div 结构为 `<div class="zone-card" data-zone-type="..." data-zone-id="...">`
|
||||
- 编辑器输入 `[zone:` 时自动弹出补全列表(Vditor hint.extend)
|
||||
- 后端 API 就绪后,将 `shortcode.js` 中的静态卡片替换为 `fetch()` 动态数据即可
|
||||
|
||||
---
|
||||
|
||||
## 不涉及的部分
|
||||
|
||||
- `internal/router/api.go` — 路由不变(`POST /api/posts/upload-image` 端点不变)
|
||||
- `internal/router/frontend.go` — SSR 页面路由不变
|
||||
- `internal/repository/post_repo.go` — 仓储不变(Model 字段变更由 GORM 自动映射)
|
||||
- `internal/middleware/` — CSRF / Auth 中间件不变
|
||||
- `internal/config/` — 配置不变
|
||||
- `cmd/server/main.go` — `Static()` 映射不变(`/static` → `templates/.../static`)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **DB Schema**:GORM AutoMigrate 自动添加 `excerpt` 列;旧的 `body_html` 列残留但不影响运行(GORM 不会 DROP,可手动清理)
|
||||
2. **已有帖子**:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
|
||||
3. **CSRF**:`upload.setHeaders` 为函数,每次上传前动态读取最新 token,不受 token 过期影响
|
||||
4. **时序**:Vditor 主文件先加载,lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
|
||||
5. **bluemonday**:已从 `go.mod` 中移除(`go mod tidy` 自动清理)
|
||||
1
internal/cache/home_cache.go
vendored
1
internal/cache/home_cache.go
vendored
@ -1,3 +1,4 @@
|
||||
// Package cache 提供首页等热点数据的内存缓存层。
|
||||
package cache
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
|
||||
package common
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
@ -37,4 +37,3 @@ func ClearSessionCookie(c *gin.Context, cfg *config.Config, siteSettings *config
|
||||
secure := siteSettings.CookieSecure()
|
||||
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ var (
|
||||
ErrEmailExists = errors.New("该邮箱已注册")
|
||||
ErrUsernameTaken = errors.New("该用户名已被占用")
|
||||
ErrUsernameInvalid = errors.New("用户名格式无效(支持中英文、数字、下划线、连字符,1-16个字符)")
|
||||
ErrBioTooLong = errors.New("个性签名不能超过 128 个字符")
|
||||
ErrBioTooLong = errors.New("个性签名不能超过 128 个字符")
|
||||
ErrInvalidCred = errors.New("邮箱或密码错误")
|
||||
ErrUserNotFound = errors.New("用户不存在")
|
||||
ErrUserBanned = errors.New("该账号已被封禁")
|
||||
@ -24,10 +24,10 @@ var (
|
||||
ErrPermissionDenied = errors.New("权限不足")
|
||||
|
||||
// 审核相关
|
||||
ErrAuditNotFound = errors.New("审核记录不存在")
|
||||
ErrAuditNotFound = errors.New("审核记录不存在")
|
||||
ErrAuditNotPending = errors.New("该审核记录已处理")
|
||||
ErrAuditDisabled = errors.New("审核功能未开启")
|
||||
ErrAuditTypeOff = errors.New("该类型审核未开启")
|
||||
ErrAuditDisabled = errors.New("审核功能未开启")
|
||||
ErrAuditTypeOff = errors.New("该类型审核未开启")
|
||||
|
||||
// 密码 & 注销相关
|
||||
ErrIncorrectPassword = errors.New("当前密码错误")
|
||||
@ -38,24 +38,24 @@ var (
|
||||
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
|
||||
|
||||
// 帖子相关
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
|
||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
|
||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||
|
||||
// 域能相关
|
||||
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
|
||||
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
|
||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
||||
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
|
||||
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
|
||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
||||
|
||||
// 评论相关
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrCommentEmpty = errors.New("评论内容不能为空")
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrCommentEmpty = errors.New("评论内容不能为空")
|
||||
)
|
||||
|
||||
@ -7,11 +7,11 @@ import (
|
||||
|
||||
// SaveUploadedFile 将 multipart.File 内容写入目标路径
|
||||
func SaveUploadedFile(file multipart.File, dst string) error {
|
||||
out, err := os.Create(dst)
|
||||
out, err := os.Create(dst) //nolint:gosec // dst 为服务器内部路径
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
|
||||
@ -35,7 +35,7 @@ func ComputeAssetHashes(templateDir string) {
|
||||
|
||||
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32,存入 assetHashes
|
||||
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() {
|
||||
return nil
|
||||
}
|
||||
@ -43,7 +43,7 @@ func walkStaticDir(dir, urlPrefix string) {
|
||||
if ext != ".js" && ext != ".css" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := os.ReadFile(path) //nolint:gosec // path 来自 filepath.WalkDir 遍历,非用户输入
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -57,5 +57,5 @@ func PageCount(total int64, pageSize int) int {
|
||||
return int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
|
||||
// 固定时间格式,前后端统一
|
||||
// TimeFormat 固定时间格式,前后端统一。
|
||||
const TimeFormat = time.RFC3339
|
||||
|
||||
@ -20,8 +20,8 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
|
||||
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s),配合 FallbackStore 快速降级
|
||||
ReadTimeout: 1 * time.Second,
|
||||
WriteTimeout: 1 * time.Second,
|
||||
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
|
||||
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS)
|
||||
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
|
||||
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS)
|
||||
})
|
||||
|
||||
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 {
|
||||
log.Printf("[Redis] 连接失败: %v,将降级为内存存储", err)
|
||||
return client, fmt.Errorf("Redis 连接失败: %w", err)
|
||||
return client, fmt.Errorf("redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 统一 JSON 响应
|
||||
|
||||
@ -7,8 +7,10 @@ import (
|
||||
)
|
||||
|
||||
// usernameChars 随机用户名字符集(小写字母 + 数字)
|
||||
const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const usernameLen = 10
|
||||
const (
|
||||
usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
usernameLen = 10
|
||||
)
|
||||
|
||||
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository)
|
||||
type UsernameChecker interface {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package config 管理应用配置的加载、解析和运行时访问。
|
||||
package config
|
||||
|
||||
import (
|
||||
@ -22,10 +23,10 @@ type Config struct {
|
||||
|
||||
// AuditConfig 审核系统配置(config.yaml 提供默认值,站点设置可运行时覆盖)
|
||||
type AuditConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
UsernameAudit bool `mapstructure:"username_audit"`
|
||||
AvatarAudit bool `mapstructure:"avatar_audit"`
|
||||
BioAudit bool `mapstructure:"bio_audit"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
UsernameAudit bool `mapstructure:"username_audit"`
|
||||
AvatarAudit bool `mapstructure:"avatar_audit"`
|
||||
BioAudit bool `mapstructure:"bio_audit"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@ -65,9 +66,9 @@ type BcryptConfig struct {
|
||||
|
||||
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml)
|
||||
type RolesConfig struct {
|
||||
Levels map[string]int `mapstructure:"levels"`
|
||||
Names map[string]string `mapstructure:"names"`
|
||||
Permissions RolesPermissions `mapstructure:"permissions"`
|
||||
Levels map[string]int `mapstructure:"levels"`
|
||||
Names map[string]string `mapstructure:"names"`
|
||||
Permissions RolesPermissions `mapstructure:"permissions"`
|
||||
}
|
||||
|
||||
// RolesPermissions 角色间操作权限
|
||||
@ -75,7 +76,7 @@ type RolesPermissions struct {
|
||||
OperableRoles map[string][]string `mapstructure:"operable_roles"`
|
||||
}
|
||||
|
||||
// 全局配置实例(初始化后只读)
|
||||
// App 全局配置实例(初始化后只读)。
|
||||
var App *Config
|
||||
|
||||
// Load 加载配置:config.yaml → .env 覆盖
|
||||
@ -111,11 +112,11 @@ func Load(configPath string) *Config {
|
||||
|
||||
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
|
||||
func loadEnvFile(path string) {
|
||||
f, err := os.Open(path)
|
||||
f, err := os.Open(path) //nolint:gosec // path 为内部 .env 文件路径
|
||||
if err != nil {
|
||||
return // .env 不存在,跳过
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
@ -130,7 +131,7 @@ func loadEnvFile(path string) {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
if os.Getenv(key) == "" {
|
||||
os.Setenv(key, val)
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,19 +112,19 @@ func (s *SiteSettings) IsAuditTypeEnabled(auditType string, defaultVal bool) boo
|
||||
|
||||
// SiteInfoDefaults 站点展示信息的默认值
|
||||
type SiteInfoDefaults struct {
|
||||
SiteName string // 站点名称,默认 "MetaLab"
|
||||
SiteTagline string // 站点标语,默认 "下一代开发者社区"
|
||||
ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com"
|
||||
Framework string // 附加标识,默认 "Framework: MetaGenesis"
|
||||
CopyrightStart string // 版权起始年份,默认 "2024"
|
||||
OperatorName string // 运营主体
|
||||
ICPNumber string // ICP 备案(HTML,含链接)
|
||||
ICPLicense string // ICP 经营许可证(HTML)
|
||||
PoliceNumber string // 公安备案(HTML)
|
||||
WenWangWen string // 文网文(HTML)
|
||||
SiteName string // 站点名称,默认 "MetaLab"
|
||||
SiteTagline string // 站点标语,默认 "下一代开发者社区"
|
||||
ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com"
|
||||
Framework string // 附加标识,默认 "Framework: MetaGenesis"
|
||||
CopyrightStart string // 版权起始年份,默认 "2024"
|
||||
OperatorName string // 运营主体
|
||||
ICPNumber string // ICP 备案(HTML,含链接)
|
||||
ICPLicense string // ICP 经营许可证(HTML)
|
||||
PoliceNumber string // 公安备案(HTML)
|
||||
WenWangWen string // 文网文(HTML)
|
||||
AlgorithmFiling string // 算法备案(HTML)
|
||||
PrivacyURL string // 隐私政策(HTML)
|
||||
TermsURL string // 服务条款(HTML)
|
||||
PrivacyURL string // 隐私政策(HTML)
|
||||
TermsURL string // 服务条款(HTML)
|
||||
}
|
||||
|
||||
// SiteInfo 获取站点展示信息(优先 DB 设置,fallback 默认值)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
// Package admin 提供管理后台的 HTTP 控制器:用户管理、审核、站点设置等。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@ -104,9 +106,10 @@ func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
|
||||
}
|
||||
|
||||
action := "封禁"
|
||||
if req.Status == model.StatusActive {
|
||||
switch req.Status {
|
||||
case model.StatusActive:
|
||||
action = "解封"
|
||||
} else if req.Status == model.StatusLocked {
|
||||
case model.StatusLocked:
|
||||
action = "已锁定"
|
||||
}
|
||||
common.OkMessage(c, action+"成功")
|
||||
@ -140,14 +143,13 @@ func parseUIDParam(c *gin.Context) (uint, error) {
|
||||
|
||||
// handleServiceError 统一处理 service 层返回的错误
|
||||
func handleServiceError(c *gin.Context, err error) {
|
||||
switch err {
|
||||
case common.ErrPermissionDenied:
|
||||
if errors.Is(err, common.ErrPermissionDenied) {
|
||||
common.Error(c, http.StatusForbidden, "权限不足")
|
||||
case common.ErrUserNotFound:
|
||||
} else if errors.Is(err, common.ErrUserNotFound) {
|
||||
common.Error(c, http.StatusNotFound, "用户不存在")
|
||||
case common.ErrEmailExists:
|
||||
} else if errors.Is(err, common.ErrEmailExists) {
|
||||
common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁")
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"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 == common.ErrInsufficientFund {
|
||||
if errors.Is(err, common.ErrInsufficientFund) {
|
||||
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
|
||||
return
|
||||
}
|
||||
@ -99,7 +100,7 @@ func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
common.Ok(c, gin.H{
|
||||
"balance": balance,
|
||||
"balance": balance,
|
||||
"balance_display": float64(balance) / 10,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@ -125,16 +126,15 @@ func parseIDParam(s string) (uint, error) {
|
||||
|
||||
// handleAuditError 统一处理审核接口的 service 层错误
|
||||
func handleAuditError(c *gin.Context, err error) {
|
||||
switch err {
|
||||
case common.ErrAuditNotFound:
|
||||
if errors.Is(err, common.ErrAuditNotFound) {
|
||||
common.Error(c, http.StatusNotFound, "审核记录不存在")
|
||||
case common.ErrAuditNotPending:
|
||||
} else if errors.Is(err, common.ErrAuditNotPending) {
|
||||
common.Error(c, http.StatusBadRequest, "该审核记录已处理")
|
||||
case common.ErrUserNotFound:
|
||||
} else if errors.Is(err, common.ErrUserNotFound) {
|
||||
common.Error(c, http.StatusNotFound, "用户不存在")
|
||||
case common.ErrUsernameTaken:
|
||||
} else if errors.Is(err, common.ErrUsernameTaken) {
|
||||
common.Error(c, http.StatusConflict, "该用户名已被其他用户占用,审批失败")
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
|
||||
// SiteSettingsPage 站点设置管理页面
|
||||
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "站点设置",
|
||||
"Title": "站点设置",
|
||||
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||
}))
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
@ -48,21 +49,20 @@ func (ac *AuthController) Login(c *gin.Context) {
|
||||
user, err := ac.authService.Login(req, ip)
|
||||
if err != nil {
|
||||
// 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
|
||||
switch err {
|
||||
case common.ErrInvalidCred:
|
||||
if errors.Is(err, common.ErrInvalidCred) {
|
||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
case common.ErrUserLocked:
|
||||
} else if errors.Is(err, common.ErrUserLocked) {
|
||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
case common.ErrMaintenanceMode:
|
||||
} else if errors.Is(err, common.ErrMaintenanceMode) {
|
||||
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
|
||||
case common.ErrNeedsConfirmRestore:
|
||||
} else if errors.Is(err, common.ErrNeedsConfirmRestore) {
|
||||
// 注销账号登录 → 需要二次确认恢复
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"action": "confirm_restore",
|
||||
"message": "你的账号正在注销中,登录将撤销注销并恢复账号",
|
||||
})
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
@ -93,10 +93,9 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
|
||||
|
||||
user, err := ac.authService.ConfirmRestore(req, clientIP(c))
|
||||
if err != nil {
|
||||
switch err {
|
||||
case common.ErrInvalidCred:
|
||||
if errors.Is(err, common.ErrInvalidCred) {
|
||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
@ -45,16 +46,15 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
user, err := ac.authService.Register(req, clientIP(c))
|
||||
if err != nil {
|
||||
// 失败计数已在 AllowIP 中原子递增,无需额外记录
|
||||
switch err {
|
||||
case common.ErrMaintenanceMode:
|
||||
if errors.Is(err, common.ErrMaintenanceMode) {
|
||||
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
|
||||
case common.ErrEmailExists:
|
||||
} else if errors.Is(err, common.ErrEmailExists) {
|
||||
common.Error(c, http.StatusConflict, "该邮箱已注册")
|
||||
case common.ErrWeakPassword:
|
||||
} else if errors.Is(err, common.ErrWeakPassword) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
case common.ErrRegistrationDisabled:
|
||||
} else if errors.Is(err, common.ErrRegistrationDisabled) {
|
||||
common.Error(c, http.StatusForbidden, "注册功能已关闭")
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
|
||||
@ -37,7 +37,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
||||
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
|
||||
var guidelines template.HTML
|
||||
if guidelinesHTML != "" {
|
||||
guidelines = template.HTML(guidelinesHTML)
|
||||
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
|
||||
} else {
|
||||
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
||||
if err != nil {
|
||||
@ -48,10 +48,12 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
||||
}
|
||||
// 替换准则中的硬编码站点名和邮箱
|
||||
siteInfo := ac.siteSettings.SiteInfo()
|
||||
guidelines = template.HTML(strings.ReplaceAll(
|
||||
strings.ReplaceAll(string(guidelines), "MetaLab", siteInfo.SiteName),
|
||||
"metazone@foxmail.com", siteInfo.ContactEmail,
|
||||
))
|
||||
guidelines = template.HTML( //nolint:gosec // 准则内容由管理员配置,站点名/邮箱来自 DB
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(string(guidelines), "MetaLab", siteInfo.SiteName),
|
||||
"metazone@foxmail.com", siteInfo.ContactEmail,
|
||||
),
|
||||
)
|
||||
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "注册",
|
||||
"ExtraCSS": "/static/css/auth.css",
|
||||
|
||||
@ -44,7 +44,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// 检查扩展名
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
@ -67,7 +67,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
||||
}
|
||||
|
||||
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, "存储初始化失败")
|
||||
return
|
||||
}
|
||||
@ -88,7 +88,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
||||
if webpData != nil && len(webpData) < len(data) {
|
||||
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
os.WriteFile(savePath, webpData, 0644)
|
||||
_ = os.WriteFile(savePath, webpData, 0o644) //nolint:gosec // 上传文件标准权限
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
}
|
||||
@ -101,16 +101,16 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
||||
if decErr == nil {
|
||||
var buf bytes.Buffer
|
||||
if ext == ".png" {
|
||||
png.Encode(&buf, decImg)
|
||||
_ = png.Encode(&buf, decImg)
|
||||
} else {
|
||||
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||
_ = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||
}
|
||||
dataOut = buf.Bytes()
|
||||
}
|
||||
}
|
||||
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
os.WriteFile(savePath, dataOut, 0644)
|
||||
_ = os.WriteFile(savePath, dataOut, 0o644) //nolint:gosec // 上传文件标准权限
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@ -39,18 +40,17 @@ func (ec *EnergyController) Energize(c *gin.Context) {
|
||||
|
||||
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case common.ErrPostNotFound:
|
||||
if errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusNotFound, "文章不存在")
|
||||
case common.ErrCannotEnergizeSelf:
|
||||
} else if errors.Is(err, common.ErrCannotEnergizeSelf) {
|
||||
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
|
||||
case common.ErrInsufficientEnergy:
|
||||
} else if errors.Is(err, common.ErrInsufficientEnergy) {
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
|
||||
case common.ErrEnergizeLimitReached:
|
||||
} else if errors.Is(err, common.ErrEnergizeLimitReached) {
|
||||
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
|
||||
case common.ErrInvalidEnergyAmount:
|
||||
} else if errors.Is(err, common.ErrInvalidEnergyAmount) {
|
||||
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "赋能失败")
|
||||
}
|
||||
return
|
||||
@ -136,13 +136,13 @@ func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
|
||||
|
||||
// EnergyLogView 前端展示用的域能流水视图
|
||||
type EnergyLogView struct {
|
||||
ID uint `json:"id"`
|
||||
Amount int `json:"amount"`
|
||||
ID uint `json:"id"`
|
||||
Amount int `json:"amount"`
|
||||
AmountDisplay float64 `json:"amount_display"`
|
||||
Type string `json:"type"`
|
||||
TypeName string `json:"type_name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Type string `json:"type"`
|
||||
TypeName string `json:"type_name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@ -24,7 +25,7 @@ func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
|
||||
|
||||
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
|
||||
if err != nil {
|
||||
if err == common.ErrPermissionDenied {
|
||||
if errors.Is(err, common.ErrPermissionDenied) {
|
||||
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
|
||||
return
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
||||
"ExtraCSS": "/static/css/follow.css",
|
||||
"Result": result,
|
||||
"TargetUID": targetUID,
|
||||
"Page": result.Page,
|
||||
"Page": result.Page,
|
||||
"TotalPages": result.TotalPages,
|
||||
"HasPrev": result.Page > 1,
|
||||
"HasNext": result.Page < result.TotalPages,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package controller 定义 HTTP 请求处理层,通过 ISP 接口隔离依赖 Service。
|
||||
package controller
|
||||
|
||||
import (
|
||||
@ -27,13 +28,10 @@ type rateLimiter interface {
|
||||
ClearIP(ipKey string)
|
||||
}
|
||||
|
||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法)
|
||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||
type postUseCase interface {
|
||||
Create(userID uint, title, body string) (*model.Post, error)
|
||||
GetByID(id uint) (*model.Post, 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
|
||||
RecordRead(userID, postID uint)
|
||||
RecordGuestRead(visitorID string, postID uint)
|
||||
@ -100,14 +98,6 @@ type energyRenameHandler interface {
|
||||
DeductOnRename(userID uint) error
|
||||
}
|
||||
|
||||
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:4 个方法)
|
||||
type energyAdminUseCase interface {
|
||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
GetFundBalance() (int, error)
|
||||
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||
}
|
||||
|
||||
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
||||
type commentUseCase interface {
|
||||
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
||||
|
||||
@ -34,7 +34,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// 检查扩展名
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
@ -59,7 +59,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
|
||||
// 存储路径
|
||||
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, "存储初始化失败")
|
||||
return
|
||||
}
|
||||
@ -83,7 +83,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
if webpData != nil && len(webpData) < len(data) {
|
||||
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
|
||||
if err := os.WriteFile(savePath, webpData, 0o644); err == nil { //nolint:gosec // 上传文件标准权限
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
}
|
||||
@ -127,7 +127,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
|
||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||
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, "保存图片失败")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@ -49,10 +50,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
||||
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||
if hasCompleted {
|
||||
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||
switch err {
|
||||
case common.ErrInsufficientEnergy:
|
||||
if errors.Is(err, common.ErrInsufficientEnergy) {
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
return
|
||||
@ -81,10 +81,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
||||
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||
if hasCompleted {
|
||||
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||
switch err {
|
||||
case common.ErrInsufficientEnergy:
|
||||
if errors.Is(err, common.ErrInsufficientEnergy) {
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
return
|
||||
@ -122,7 +121,7 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// 裁切参数(可选,来自前端裁切弹窗)
|
||||
cropX, _ := strconv.Atoi(c.PostForm("crop_x"))
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
@ -10,32 +11,30 @@ import (
|
||||
|
||||
// handleSettingsError 统一处理 settings 接口的 service 层错误
|
||||
func handleSettingsError(c *gin.Context, err error) {
|
||||
switch err {
|
||||
case common.ErrUsernameTaken:
|
||||
if errors.Is(err, common.ErrUsernameTaken) {
|
||||
common.Error(c, http.StatusConflict, err.Error())
|
||||
case common.ErrUsernameInvalid:
|
||||
} else if errors.Is(err, common.ErrUsernameInvalid) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
case common.ErrBioTooLong:
|
||||
} else if errors.Is(err, common.ErrBioTooLong) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
case common.ErrIncorrectPassword:
|
||||
} else if errors.Is(err, common.ErrIncorrectPassword) {
|
||||
common.Error(c, http.StatusForbidden, err.Error())
|
||||
case common.ErrOwnerCannotDelete:
|
||||
} else if errors.Is(err, common.ErrOwnerCannotDelete) {
|
||||
common.Error(c, http.StatusForbidden, err.Error())
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuditSubmitError 统一处理审核提交的错误
|
||||
func handleAuditSubmitError(c *gin.Context, err error) {
|
||||
switch err {
|
||||
case common.ErrAuditDisabled:
|
||||
if errors.Is(err, common.ErrAuditDisabled) {
|
||||
common.Error(c, http.StatusForbidden, "审核功能未开启")
|
||||
case common.ErrAuditTypeOff:
|
||||
} else if errors.Is(err, common.ErrAuditTypeOff) {
|
||||
common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员")
|
||||
case common.ErrUsernameTaken:
|
||||
} else if errors.Is(err, common.ErrUsernameTaken) {
|
||||
common.Error(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ import (
|
||||
|
||||
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
|
||||
type SpaceController struct {
|
||||
spaceService spaceUseCase
|
||||
followSvc followUseCase
|
||||
favoriteSvc favoriteUseCaseForSpace
|
||||
spaceService spaceUseCase
|
||||
followSvc followUseCase
|
||||
favoriteSvc favoriteUseCaseForSpace
|
||||
}
|
||||
|
||||
// NewSpaceController 构造函数
|
||||
@ -73,25 +73,29 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
||||
|
||||
// 分页参数
|
||||
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"))
|
||||
if pageSize < 1 || pageSize > 50 { pageSize = 18 }
|
||||
if pageSize < 1 || pageSize > 50 {
|
||||
pageSize = 18
|
||||
}
|
||||
|
||||
// 构建基础数据(含用户统计)
|
||||
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
|
||||
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
|
||||
|
||||
data := gin.H{
|
||||
"Title": spaceUser.Username + " 的空间",
|
||||
"SpaceUser": spaceUser,
|
||||
"IsOwnSpace": isOwnSpace,
|
||||
"ActiveTab": tab,
|
||||
"CurrentUID": currentUID,
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
"PostCount": postCount,
|
||||
"TotalLikes": totalLikes,
|
||||
"Title": spaceUser.Username + " 的空间",
|
||||
"SpaceUser": spaceUser,
|
||||
"IsOwnSpace": isOwnSpace,
|
||||
"ActiveTab": tab,
|
||||
"CurrentUID": currentUID,
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
"PostCount": postCount,
|
||||
"TotalLikes": totalLikes,
|
||||
"TotalFavorites": totalFavorites,
|
||||
"TotalReads": totalReads,
|
||||
"TotalReads": totalReads,
|
||||
}
|
||||
|
||||
// ---- 根据 Tab 加载对应数据 ----
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"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)
|
||||
if err != nil {
|
||||
if err == common.ErrScheduledTooSoon {
|
||||
if errors.Is(err, common.ErrScheduledTooSoon) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package middleware 提供 HTTP 中间件:认证、授权、CSRF、安全头、限流等。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
|
||||
@ -40,5 +40,3 @@ func generateCSRFToken() (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package model 定义数据模型、常量、角色体系和审核相关类型。
|
||||
package model
|
||||
|
||||
// 审核类型常量
|
||||
|
||||
@ -4,14 +4,14 @@ import "time"
|
||||
|
||||
// Category 文章分类(两级树形结构)
|
||||
type Category struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
|
||||
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
|
||||
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 非 DB 字段(树形填充)
|
||||
Children []Category `gorm:"-" json:"children,omitempty"`
|
||||
|
||||
@ -4,23 +4,23 @@ import "time"
|
||||
|
||||
// Comment 评论模型
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
||||
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
|
||||
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL)
|
||||
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
||||
LikesCount int `gorm:"default:0" json:"likes_count"`
|
||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
||||
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
|
||||
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL)
|
||||
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
||||
LikesCount int `gorm:"default:0" json:"likes_count"`
|
||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 非数据库字段(联表查询填充)
|
||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
|
||||
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
|
||||
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
|
||||
// ReplyToName 被回复者当前显示名(JOIN users 获取,动态解析,改名后自动更新)
|
||||
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
|
||||
// RepliesCount 回复数(非DB字段,查询填充)
|
||||
|
||||
@ -4,11 +4,11 @@ import "time"
|
||||
|
||||
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
|
||||
type DailyLikeSummary struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
|
||||
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DD(Asia/Shanghai)
|
||||
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
|
||||
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
|
||||
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DD(Asia/Shanghai)
|
||||
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
|
||||
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@ -74,4 +74,4 @@ type PostListQuery struct {
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,20 +4,20 @@ import "time"
|
||||
|
||||
// 域能流水类型常量
|
||||
const (
|
||||
EnergyTypeSignIn = "sign_in"
|
||||
EnergyTypeRename = "rename"
|
||||
EnergyTypeSignIn = "sign_in"
|
||||
EnergyTypeRename = "rename"
|
||||
EnergyTypeRenameRefund = "rename_refund"
|
||||
EnergyTypeEnergize = "energize"
|
||||
EnergyTypeEnergized = "energized"
|
||||
EnergyTypeDeletePost = "delete_post"
|
||||
EnergyTypeAdminAdjust = "admin_adjust"
|
||||
EnergyTypeEnergize = "energize"
|
||||
EnergyTypeEnergized = "energized"
|
||||
EnergyTypeDeletePost = "delete_post"
|
||||
EnergyTypeAdminAdjust = "admin_adjust"
|
||||
)
|
||||
|
||||
// EnergyLog 域能流水记录
|
||||
type EnergyLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减)
|
||||
Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减)
|
||||
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
|
||||
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
|
||||
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||
@ -28,11 +28,11 @@ type EnergyLog struct {
|
||||
|
||||
// EnergyLogDisplayNames 流水类型 → 中文名
|
||||
var EnergyLogDisplayNames = map[string]string{
|
||||
EnergyTypeSignIn: "每日签到",
|
||||
EnergyTypeRename: "改名消耗",
|
||||
EnergyTypeSignIn: "每日签到",
|
||||
EnergyTypeRename: "改名消耗",
|
||||
EnergyTypeRenameRefund: "改名退款",
|
||||
EnergyTypeEnergize: "赋能消耗",
|
||||
EnergyTypeEnergized: "被赋能",
|
||||
EnergyTypeDeletePost: "删稿消耗",
|
||||
EnergyTypeAdminAdjust: "后台调整",
|
||||
EnergyTypeEnergize: "赋能消耗",
|
||||
EnergyTypeEnergized: "被赋能",
|
||||
EnergyTypeDeletePost: "删稿消耗",
|
||||
EnergyTypeAdminAdjust: "后台调整",
|
||||
}
|
||||
|
||||
@ -4,18 +4,18 @@ import "time"
|
||||
|
||||
// 公户流水类型常量
|
||||
const (
|
||||
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
|
||||
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
|
||||
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
|
||||
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
|
||||
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
|
||||
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
|
||||
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
|
||||
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
|
||||
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
|
||||
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
|
||||
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
|
||||
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
|
||||
)
|
||||
|
||||
// FundLog 公户流水记录
|
||||
type FundLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10)
|
||||
Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10)
|
||||
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
|
||||
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
|
||||
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||
|
||||
@ -2,18 +2,18 @@ package model
|
||||
|
||||
// 消息/通知类型常量
|
||||
const (
|
||||
NotifyAuditApproved = "audit_approved"
|
||||
NotifyAuditRejected = "audit_rejected"
|
||||
NotifyPostApproved = "post_approved"
|
||||
NotifyPostRejected = "post_rejected"
|
||||
NotifyPostLocked = "post_locked"
|
||||
NotifyPostUnlocked = "post_unlocked"
|
||||
NotifyLevelUp = "level_up"
|
||||
NotifyComment = "comment"
|
||||
NotifyCommentReply = "comment_reply"
|
||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||
NotifyFollow = "follow" // 关注通知
|
||||
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||
NotifyAuditApproved = "audit_approved"
|
||||
NotifyAuditRejected = "audit_rejected"
|
||||
NotifyPostApproved = "post_approved"
|
||||
NotifyPostRejected = "post_rejected"
|
||||
NotifyPostLocked = "post_locked"
|
||||
NotifyPostUnlocked = "post_unlocked"
|
||||
NotifyLevelUp = "level_up"
|
||||
NotifyComment = "comment"
|
||||
NotifyCommentReply = "comment_reply"
|
||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||
NotifyFollow = "follow" // 关注通知
|
||||
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||
)
|
||||
|
||||
// Notification 消息/通知模型
|
||||
|
||||
@ -8,28 +8,28 @@ import (
|
||||
|
||||
// Post 帖子/文章模型
|
||||
type Post struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
||||
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
|
||||
Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
|
||||
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
||||
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
|
||||
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
||||
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
||||
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
|
||||
Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
|
||||
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
||||
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
|
||||
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
||||
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||
|
||||
// 文章属性
|
||||
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
|
||||
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
|
||||
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
|
||||
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
|
||||
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
|
||||
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
|
||||
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
|
||||
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
|
||||
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
|
||||
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
|
||||
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
|
||||
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
|
||||
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
|
||||
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
|
||||
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
|
||||
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
|
||||
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
||||
@ -37,7 +37,7 @@ type Post struct {
|
||||
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
||||
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
||||
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
||||
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@ -17,4 +17,3 @@ type PostGuestReadLog struct {
|
||||
PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"`
|
||||
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
|
||||
}
|
||||
|
||||
|
||||
@ -82,11 +82,11 @@ type ShortcodeResult struct {
|
||||
// ShortcodeCard 渲染一张卡片的通用数据
|
||||
// 前端根据 Type 决定具体 UI 组件,字段按需填充
|
||||
type ShortcodeCard struct {
|
||||
Type ShortcodeType `json:"type"`
|
||||
ID string `json:"id"` // 业务 ID(活动ID / 游戏slug)
|
||||
Title string `json:"title,omitempty"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Extra string `json:"extra,omitempty"` // 扩展字段(时间、标签等,JSON string)
|
||||
Type ShortcodeType `json:"type"`
|
||||
ID string `json:"id"` // 业务 ID(活动ID / 游戏slug)
|
||||
Title string `json:"title,omitempty"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Extra string `json:"extra,omitempty"` // 扩展字段(时间、标签等,JSON string)
|
||||
}
|
||||
|
||||
@ -20,9 +20,9 @@ type User struct {
|
||||
Energy int `gorm:"default:0" json:"energy"` // 域能余额(可为负数,乘10存储)
|
||||
|
||||
// 关注系统
|
||||
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
|
||||
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
|
||||
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
|
||||
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
|
||||
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
|
||||
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
|
||||
FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开
|
||||
|
||||
// 通知偏好 (JSON string)
|
||||
@ -52,9 +52,11 @@ const (
|
||||
)
|
||||
|
||||
// --- 以下由 InitRoles 从配置初始化,无硬编码默认值 ---
|
||||
var roleLevel map[string]int
|
||||
var operableRoles map[string][]string
|
||||
var RoleDisplayNames map[string]string
|
||||
var (
|
||||
roleLevel map[string]int
|
||||
operableRoles map[string][]string
|
||||
RoleDisplayNames map[string]string
|
||||
)
|
||||
|
||||
// InitRoles 从配置初始化角色系统(唯一数据源)
|
||||
// 必须在服务启动前调用,不提供默认值
|
||||
|
||||
@ -4,9 +4,9 @@ import "time"
|
||||
|
||||
// UserFollow 关注关系
|
||||
type UserFollow struct {
|
||||
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
|
||||
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
|
||||
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// 联表查询填充(非数据库字段)
|
||||
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package repository 提供基于 GORM 的数据访问层实现。
|
||||
package repository
|
||||
|
||||
import (
|
||||
|
||||
@ -73,7 +73,8 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, fo
|
||||
Username string
|
||||
Avatar string
|
||||
Exp int
|
||||
}, error) {
|
||||
}, error,
|
||||
) {
|
||||
type result struct {
|
||||
UID uint
|
||||
Username string
|
||||
|
||||
@ -161,7 +161,7 @@ func (r *CommentRepo) DecrCommentsCount(postID uint) error {
|
||||
UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
|
||||
}
|
||||
|
||||
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除)
|
||||
// CountRepliesByRoot 统计某条顶级评论的回复数(可选是否统计已删除)
|
||||
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
|
||||
var count int64
|
||||
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"metazone.cc/mce/internal/model"
|
||||
"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 {
|
||||
var existing model.DailyLikeSummary
|
||||
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{
|
||||
AuthorUID: authorUID,
|
||||
Date: date,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"metazone.cc/mce/internal/model"
|
||||
"metazone.cc/mce/internal/service"
|
||||
|
||||
@ -29,7 +31,7 @@ func (r *TagRepo) FindOrCreate(name, slug string) (*model.Tag, error) {
|
||||
if err == nil {
|
||||
return &tag, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
tag = model.Tag{Name: name, Slug: slug}
|
||||
|
||||
@ -151,4 +151,3 @@ func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error)
|
||||
err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
|
||||
@ -18,33 +18,33 @@ import (
|
||||
)
|
||||
|
||||
type dependencies struct {
|
||||
authCtrl *controller.AuthController
|
||||
authMdw *middleware.AuthMiddleware
|
||||
rateLimiter *middleware.RateLimiter
|
||||
maintenanceMdw *middleware.MaintenanceMiddleware
|
||||
settingsCtrl *controller.SettingsController
|
||||
messageCtrl *controller.MessageController
|
||||
postCtrl *controller.PostController
|
||||
postService *service.PostService
|
||||
spaceCtrl *controller.SpaceController
|
||||
studioCtrl *controller.StudioController
|
||||
adminPostCtrl *adminCtrl.AdminPostController
|
||||
adminCtrl *adminCtrl.AdminController
|
||||
auditCtrl *adminCtrl.AuditController
|
||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
||||
levelCtrl *controller.LevelController
|
||||
energyCtrl *controller.EnergyController
|
||||
adminEnergyCtrl *adminCtrl.AdminEnergyController
|
||||
energyService *service.EnergyService
|
||||
commentCtrl *controller.CommentController
|
||||
commentService *service.CommentService
|
||||
adminCommentCtrl *adminCtrl.AdminCommentController
|
||||
reactionCtrl *controller.ReactionController
|
||||
followCtrl *controller.FollowController
|
||||
favoriteCtrl *controller.FavoriteController
|
||||
favoriteService *service.FavoriteService
|
||||
trendsService *service.TrendsService
|
||||
homeCache *cache.HomePageCache
|
||||
authCtrl *controller.AuthController
|
||||
authMdw *middleware.AuthMiddleware
|
||||
rateLimiter *middleware.RateLimiter
|
||||
maintenanceMdw *middleware.MaintenanceMiddleware
|
||||
settingsCtrl *controller.SettingsController
|
||||
messageCtrl *controller.MessageController
|
||||
postCtrl *controller.PostController
|
||||
postService *service.PostService
|
||||
spaceCtrl *controller.SpaceController
|
||||
studioCtrl *controller.StudioController
|
||||
adminPostCtrl *adminCtrl.AdminPostController
|
||||
adminCtrl *adminCtrl.AdminController
|
||||
auditCtrl *adminCtrl.AuditController
|
||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
||||
levelCtrl *controller.LevelController
|
||||
energyCtrl *controller.EnergyController
|
||||
adminEnergyCtrl *adminCtrl.AdminEnergyController
|
||||
energyService *service.EnergyService
|
||||
commentCtrl *controller.CommentController
|
||||
commentService *service.CommentService
|
||||
adminCommentCtrl *adminCtrl.AdminCommentController
|
||||
reactionCtrl *controller.ReactionController
|
||||
followCtrl *controller.FollowController
|
||||
favoriteCtrl *controller.FavoriteController
|
||||
favoriteService *service.FavoriteService
|
||||
trendsService *service.TrendsService
|
||||
homeCache *cache.HomePageCache
|
||||
announcementService *service.AnnouncementService
|
||||
announcementCtrl *adminCtrl.AnnouncementController
|
||||
categoryCtrl *adminCtrl.CategoryController
|
||||
|
||||
@ -131,25 +131,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
studioCtrl: studioCtrl,
|
||||
adminPostCtrl: adminPostCtrl,
|
||||
adminCtrl: adminController, auditCtrl: auditController,
|
||||
siteSettingCtrl: siteSettingController,
|
||||
levelCtrl: levelCtrl,
|
||||
energyCtrl: energyCtrl,
|
||||
adminEnergyCtrl: adminEnergyCtrl,
|
||||
energyService: energyService,
|
||||
commentCtrl: commentCtrl,
|
||||
commentService: commentService,
|
||||
adminCommentCtrl: adminCommentCtrl,
|
||||
reactionCtrl: reactionCtrl,
|
||||
followCtrl: followCtrl,
|
||||
favoriteCtrl: favoriteCtrl,
|
||||
favoriteService: favoriteService,
|
||||
trendsService: trendsService,
|
||||
homeCache: homeCache,
|
||||
siteSettingCtrl: siteSettingController,
|
||||
levelCtrl: levelCtrl,
|
||||
energyCtrl: energyCtrl,
|
||||
adminEnergyCtrl: adminEnergyCtrl,
|
||||
energyService: energyService,
|
||||
commentCtrl: commentCtrl,
|
||||
commentService: commentService,
|
||||
adminCommentCtrl: adminCommentCtrl,
|
||||
reactionCtrl: reactionCtrl,
|
||||
followCtrl: followCtrl,
|
||||
favoriteCtrl: favoriteCtrl,
|
||||
favoriteService: favoriteService,
|
||||
trendsService: trendsService,
|
||||
homeCache: homeCache,
|
||||
announcementService: announcementService,
|
||||
announcementCtrl: announcementCtrl,
|
||||
categoryCtrl: categoryCtrl,
|
||||
categoryService: categoryService,
|
||||
tagCtrl: tagCtrl,
|
||||
tagService: tagService,
|
||||
announcementCtrl: announcementCtrl,
|
||||
categoryCtrl: categoryCtrl,
|
||||
categoryService: categoryService,
|
||||
tagCtrl: tagCtrl,
|
||||
tagService: tagService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package router 负责依赖注入组装和 HTTP 路由注册。
|
||||
package router
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package scheduler 提供定时任务调度器,管理定时发布等周期性任务。
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package service 包含核心业务逻辑实现,通过 Store 接口隔离数据访问层。
|
||||
package service
|
||||
|
||||
import (
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
"metazone.cc/mce/internal/model"
|
||||
|
||||
@ -12,7 +14,7 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
|
||||
// 1. 查审核记录
|
||||
submission, err := s.auditRepo.FindByID(submissionID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return common.ErrAuditNotFound
|
||||
}
|
||||
return err
|
||||
|
||||
@ -56,14 +56,14 @@ type avatarFileCleaner interface {
|
||||
|
||||
// AuditService 审核业务逻辑
|
||||
type AuditService struct {
|
||||
auditRepo auditRepo
|
||||
userRepo userProfileStore
|
||||
settings siteSettingsChecker
|
||||
defaultCfg config.AuditConfig
|
||||
notifier auditNotifier
|
||||
levelSvc auditTaskCompleter
|
||||
energyRefundSvc energyRenameRefunder
|
||||
avatarCleaner avatarFileCleaner
|
||||
auditRepo auditRepo
|
||||
userRepo userProfileStore
|
||||
settings siteSettingsChecker
|
||||
defaultCfg config.AuditConfig
|
||||
notifier auditNotifier
|
||||
levelSvc auditTaskCompleter
|
||||
energyRefundSvc energyRenameRefunder
|
||||
avatarCleaner avatarFileCleaner
|
||||
}
|
||||
|
||||
// NewAuditService 构造函数
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
"metazone.cc/mce/internal/model"
|
||||
@ -13,10 +12,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var pwLower = regexp.MustCompile(`[a-z]`)
|
||||
var pwUpper = regexp.MustCompile(`[A-Z]`)
|
||||
var pwDigit = regexp.MustCompile(`\d`)
|
||||
var pwSymbol = regexp.MustCompile(`[^a-zA-Z0-9]`)
|
||||
var (
|
||||
pwLower = regexp.MustCompile(`[a-z]`)
|
||||
pwUpper = regexp.MustCompile(`[A-Z]`)
|
||||
pwDigit = regexp.MustCompile(`\d`)
|
||||
pwSymbol = regexp.MustCompile(`[^a-zA-Z0-9]`)
|
||||
)
|
||||
|
||||
// 键盘水平序列(QWERTY 布局)
|
||||
var keyboardHoriz = []string{
|
||||
@ -181,16 +182,6 @@ func PasswordStrengthHint(level string) string {
|
||||
return "密码至少 8 位"
|
||||
}
|
||||
|
||||
// hasUnicode 检查是否包含非 ASCII 字符
|
||||
func hasUnicode(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录)
|
||||
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
|
||||
|
||||
@ -22,8 +22,8 @@ import (
|
||||
|
||||
// 头像处理常量
|
||||
const (
|
||||
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
|
||||
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
|
||||
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
|
||||
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
|
||||
avatarMinHeight = 128
|
||||
avatarMaxWidth = 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))
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("图片解码失败:%v", err)
|
||||
return "", fmt.Errorf("图片解码失败:%w", err)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
@ -129,11 +129,11 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
|
||||
// 6. 编码为 WebP,目标 ≤100KB
|
||||
webpData, err := encodeWebP(resized, avatarTargetKB*1024)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("图片编码失败:%v", err)
|
||||
return "", fmt.Errorf("图片编码失败:%w", err)
|
||||
}
|
||||
|
||||
// 7. 确保存储目录存在
|
||||
if err := os.MkdirAll(s.storageDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(s.storageDir, 0o755); err != nil { //nolint:gosec // 头像存储目录标准权限
|
||||
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)
|
||||
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
|
||||
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("写入临时文件失败")
|
||||
}
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
_ = os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("保存文件失败")
|
||||
}
|
||||
|
||||
@ -163,7 +163,7 @@ func (s *AvatarService) CleanOldAvatars(userID uint) {
|
||||
}
|
||||
for _, e := range entries {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
|
||||
|
||||
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
||||
if s.notifier != nil && userID != postAuthorID {
|
||||
s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||
_ = s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||
"新评论",
|
||||
fmt.Sprintf("%s 评论了你的文章", commenterName),
|
||||
&comment.PostID, &comment.ID)
|
||||
@ -112,7 +112,7 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
|
||||
|
||||
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
||||
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),
|
||||
&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] {
|
||||
notified[mentionedUID] = true
|
||||
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
|
||||
_ = s.notifier.Create(mentionedUID, model.NotifyCommentMention,
|
||||
"新提及",
|
||||
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
|
||||
&postID, &commentID)
|
||||
|
||||
@ -30,22 +30,22 @@ func (s *EnergyService) SetFundRepo(repo FundStore) {
|
||||
|
||||
// EnergyInfo 域能信息(含每日赋能经验状态)
|
||||
type EnergyInfo struct {
|
||||
Energy int // 域能余额(乘10存储)
|
||||
DailyEnergizeExp int // 当日已通过赋能获得的经验值
|
||||
DailyExpCap int // 每日赋能经验上限
|
||||
DailyExpCapReached bool // 是否已达上限
|
||||
PostEnergizeTotal int // 当前文章已赋能量(DB乘10,仅在传 postID 时返回)
|
||||
Energy int // 域能余额(乘10存储)
|
||||
DailyEnergizeExp int // 当日已通过赋能获得的经验值
|
||||
DailyExpCap int // 每日赋能经验上限
|
||||
DailyExpCapReached bool // 是否已达上限
|
||||
PostEnergizeTotal int // 当前文章已赋能量(DB乘10,仅在传 postID 时返回)
|
||||
}
|
||||
|
||||
// 域能相关常量(乘10存储,DB值)
|
||||
const (
|
||||
EnergyCheckIn = 10 // +1.0 域能
|
||||
EnergyRename = -60 // -6.0 域能
|
||||
EnergyRenameRefund = 60 // +6.0 域能
|
||||
EnergyDeletePost = -20 // -2.0 域能
|
||||
EnergyEnergize1 = 10 // 轻赋 1 域能
|
||||
EnergyEnergize2 = 20 // 重赋 2 域能
|
||||
EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验
|
||||
DailyEnergizeExpCap = 50 // 每日赋能经验上限
|
||||
MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能(×10)
|
||||
EnergyCheckIn = 10 // +1.0 域能
|
||||
EnergyRename = -60 // -6.0 域能
|
||||
EnergyRenameRefund = 60 // +6.0 域能
|
||||
EnergyDeletePost = -20 // -2.0 域能
|
||||
EnergyEnergize1 = 10 // 轻赋 1 域能
|
||||
EnergyEnergize2 = 20 // 重赋 2 域能
|
||||
EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验
|
||||
DailyEnergizeExpCap = 50 // 每日赋能经验上限
|
||||
MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能(×10)
|
||||
)
|
||||
|
||||
@ -19,7 +19,6 @@ func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error {
|
||||
}
|
||||
|
||||
return s.repo.Transaction(func(txRepo FavoriteStore) error {
|
||||
|
||||
// 如果该文章已在其他收藏夹中,先移除
|
||||
existing, err := txRepo.FindItemByUserAndPost(userID, postID)
|
||||
if err == nil && existing.FolderID != folderID {
|
||||
|
||||
@ -51,8 +51,8 @@ type FolderItemsResult struct {
|
||||
|
||||
// FavoriteStatus 某文章的收藏状态
|
||||
type FavoriteStatus struct {
|
||||
Favorited bool `json:"favorited"`
|
||||
FolderID *uint `json:"folder_id,omitempty"`
|
||||
Favorited bool `json:"favorited"`
|
||||
FolderID *uint `json:"folder_id,omitempty"`
|
||||
FolderName *string `json:"folder_name,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -9,10 +9,10 @@ import (
|
||||
|
||||
// LevelService 积分与等级业务逻辑
|
||||
type LevelService struct {
|
||||
userRepo userLevelStore
|
||||
checkRepo checkInStore
|
||||
taskRepo taskStore
|
||||
notifSvc levelNotifier
|
||||
userRepo userLevelStore
|
||||
checkRepo checkInStore
|
||||
taskRepo taskStore
|
||||
notifSvc levelNotifier
|
||||
energyCheckInSvc energyCheckIner
|
||||
}
|
||||
|
||||
|
||||
@ -15,11 +15,11 @@ import (
|
||||
|
||||
// PostService 帖子业务逻辑
|
||||
type PostService struct {
|
||||
repo postStore
|
||||
ss *config.SiteSettings
|
||||
notifier postNotifier
|
||||
userRepo userExpReader
|
||||
onHomePageChanged func()
|
||||
repo postStore
|
||||
ss *config.SiteSettings
|
||||
notifier postNotifier
|
||||
userRepo userExpReader
|
||||
onHomePageChanged func()
|
||||
}
|
||||
|
||||
// NewPostService 构造函数
|
||||
@ -146,12 +146,12 @@ func (s *PostService) ListByUser(userID uint, status string, page, pageSize int)
|
||||
|
||||
// StudioOverview 创作中心数据概览
|
||||
type StudioOverview struct {
|
||||
TotalPosts int64 `json:"total_posts"`
|
||||
DraftCount int64 `json:"draft_count"`
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
TotalPosts int64 `json:"total_posts"`
|
||||
DraftCount int64 `json:"draft_count"`
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
ApprovedCount int64 `json:"approved_count"`
|
||||
RejectedCount int64 `json:"rejected_count"`
|
||||
TotalViews int64 `json:"total_views"`
|
||||
TotalViews int64 `json:"total_views"`
|
||||
}
|
||||
|
||||
// GetOverview 获取创作中心数据概览(单次 GROUP BY 查询)
|
||||
|
||||
@ -11,8 +11,8 @@ import (
|
||||
type ReactionType string
|
||||
|
||||
const (
|
||||
ReactionNone ReactionType = "none"
|
||||
ReactionLiked ReactionType = "liked"
|
||||
ReactionNone ReactionType = "none"
|
||||
ReactionLiked ReactionType = "liked"
|
||||
ReactionDisliked ReactionType = "disliked"
|
||||
)
|
||||
|
||||
|
||||
@ -125,9 +125,6 @@ type EnergyStore interface {
|
||||
Transaction(fn func(txEnergy EnergyStore, txFund FundStore) error) error
|
||||
}
|
||||
|
||||
// energyStore 向后兼容别名
|
||||
type energyStore = EnergyStore
|
||||
|
||||
// FundStore 公户仓储接口(ISP)
|
||||
type FundStore interface {
|
||||
GetBalance() (int, error)
|
||||
|
||||
@ -98,10 +98,11 @@ func parseShortcode(raw string) (model.Shortcode, bool) {
|
||||
// renderPlaceholder 根据 shortcode 类型生成前端占位 HTML
|
||||
//
|
||||
// 占位 div 结构约定:
|
||||
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
|
||||
// data-zone-extra="附加信息">
|
||||
// <span class="zone-card-loading">卡片加载中...</span>
|
||||
// </div>
|
||||
//
|
||||
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
|
||||
// data-zone-extra="附加信息">
|
||||
// <span class="zone-card-loading">卡片加载中...</span>
|
||||
// </div>
|
||||
//
|
||||
// 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。
|
||||
// "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。
|
||||
|
||||
@ -168,14 +168,6 @@ func (fs *FallbackStore) ping() bool {
|
||||
return fs.client.Ping(ctx).Err() == nil
|
||||
}
|
||||
|
||||
// current 返回当前应使用的 Store:Redis 健康用 Redis,否则用 Memory
|
||||
func (fs *FallbackStore) current() Store {
|
||||
if fs.healthy.Load() {
|
||||
return fs.redis
|
||||
}
|
||||
return fs.memory
|
||||
}
|
||||
|
||||
// ---- Store 接口实现(Redis 操作失败时立即降级到内存)----
|
||||
// 不依赖周期性健康检查,操作级故障即时响应
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ func (m *Manager) Validate(sid string) (*Session, error) {
|
||||
user, err := m.userRepo.FindByIDForAuth(s.UserID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ func (ms *MemoryStore) Get(id string) (*Session, error) {
|
||||
timeout = ms.rememberTimeout
|
||||
}
|
||||
if s.IsExpired(timeout) {
|
||||
ms.Delete(id)
|
||||
_ = ms.Delete(id)
|
||||
return nil, nil
|
||||
}
|
||||
return s, nil
|
||||
|
||||
@ -3,6 +3,7 @@ package session
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
@ -51,16 +52,16 @@ func (rs *RedisStore) sessionKeys(sids []string) []string {
|
||||
func (rs *RedisStore) Get(id string) (*Session, error) {
|
||||
ctx := context.Background()
|
||||
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
|
||||
if err == redis.Nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, 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
|
||||
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
|
||||
}
|
||||
@ -71,7 +72,7 @@ func (rs *RedisStore) Set(s *Session) error {
|
||||
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Redis 序列化 session: %w", err)
|
||||
return fmt.Errorf("redis 序列化 session: %w", err)
|
||||
}
|
||||
|
||||
ttl := rs.idleTimeout
|
||||
@ -91,7 +92,7 @@ func (rs *RedisStore) Set(s *Session) error {
|
||||
|
||||
_, err = pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Redis SET session: %w", err)
|
||||
return fmt.Errorf("redis SET session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -102,11 +103,11 @@ func (rs *RedisStore) Delete(id string) error {
|
||||
|
||||
// 先读取 session 获取 UserID,再从集合中移除
|
||||
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
|
||||
if err == redis.Nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return 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
|
||||
@ -126,7 +127,7 @@ func (rs *RedisStore) DeleteByUID(uid uint) error {
|
||||
|
||||
sids, err := rs.client.SMembers(ctx, uidKey).Result()
|
||||
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 {
|
||||
@ -138,7 +139,7 @@ func (rs *RedisStore) DeleteByUID(uid uint) error {
|
||||
pipe.Del(ctx, uidKey)
|
||||
_, err = pipe.Exec(ctx)
|
||||
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))
|
||||
@ -152,7 +153,7 @@ func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
|
||||
|
||||
sids, err := rs.client.SMembers(ctx, uidKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err)
|
||||
return fmt.Errorf("redis SMEMBERS user_sessions: %w", err)
|
||||
}
|
||||
|
||||
var toDelete []string
|
||||
@ -176,7 +177,7 @@ func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
|
||||
}
|
||||
_, err = pipe.Exec(ctx)
|
||||
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)
|
||||
@ -190,7 +191,7 @@ func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) {
|
||||
|
||||
sids, err := rs.client.SMembers(ctx, uidKey).Result()
|
||||
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 {
|
||||
@ -199,7 +200,7 @@ func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) {
|
||||
|
||||
results, err := rs.client.MGet(ctx, rs.sessionKeys(sids)...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Redis MGET sessions: %w", err)
|
||||
return nil, fmt.Errorf("redis MGET sessions: %w", err)
|
||||
}
|
||||
|
||||
var sessions []*Session
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package session 提供服务端会话管理:创建、验证、销毁、Redis/内存双存储及自动降级。
|
||||
package session
|
||||
|
||||
// StoreMetrics 存储状态信息(供管理面板展示)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package theme 负责多主题模板的加载、渲染和静态内容管理。
|
||||
package theme
|
||||
|
||||
import (
|
||||
@ -21,13 +22,13 @@ type TemplateRoot struct {
|
||||
// 模板名 = prefix + 相对于 root.Dir 的相对路径
|
||||
func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"subtract": 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 },
|
||||
"formatTTL": formatTTL,
|
||||
"formatCount": formatCount,
|
||||
"str": str,
|
||||
"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 {
|
||||
return model.DeclarationLabels[declaration]
|
||||
},
|
||||
@ -62,7 +63,7 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
|
||||
if info.IsDir() || filepath.Ext(path) != ".html" {
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
content, err := os.ReadFile(path) //nolint:gosec // path 来自 filepath.Walk 遍历,非用户输入
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -81,11 +82,11 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
|
||||
// LoadContent 读取主题内容文件(准则等),返回不转义的 template.HTML。
|
||||
// 后期改为查数据库时,只需修改此函数实现,调用方不变。
|
||||
func LoadContent(path string) (template.HTML, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
content, err := os.ReadFile(path) //nolint:gosec // path 为内部主题文件路径
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return template.HTML(content), nil
|
||||
return template.HTML(content), nil //nolint:gosec // 主题内容由管理员维护,属信任输入
|
||||
}
|
||||
|
||||
// formatTTL 将剩余分钟数格式化为人类可读的时间(如 "23 小时"、"3 天"、"15 分钟")
|
||||
|
||||
47
scripts/ci.sh
Executable file
47
scripts/ci.sh
Executable 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
12
scripts/pre-push
Normal 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"
|
||||
Reference in New Issue
Block a user