Compare commits

..

21 Commits

Author SHA1 Message Date
b997528bad docs: README 注明维护暂停状态
Some checks failed
CI / Lint + Build (push) Has been cancelled
2026-06-30 19:16:23 +08:00
426bedda65 fix: ComputeAssetHashes 并发安全 + theme 页重复加载 JS 导致双写 panic
Some checks failed
CI / Lint + Build (push) Has been cancelled
- helper.go: 用 sync.RWMutex 保护 assetHashes,构建本地 map 后原子替换
- theme.html: 删除硬编码 script 标签(footer ExtraJS 已加载),
  修复 site-settings-theme.js 执行两次 → applyTheme 触发两次 → 并发写 map panic
2026-06-30 19:10:11 +08:00
d4a833f149 fix(admin): security 页 password.strength 下拉未从 API 恢复当前值 2026-06-30 19:08:11 +08:00
3f4d827926 fix(admin): 注册设置 toggle 读错数据源 — 从 audit 读 registration.* 导致状态丢失 2026-06-30 19:06:37 +08:00
1eb3cc7c86 debug: 添加站点设置持久化调试日志,追踪 Set/Get 链路
Some checks failed
CI / Lint + Build (push) Has been cancelled
2026-06-30 19:01:25 +08:00
20067f7640 fix(admin): 修复多页面设置加载失败 — renderOtherSettings 空指针崩溃
Some checks failed
CI / Lint + Build (push) Has been cancelled
根因:site-settings.js 的 renderOtherSettings() 无条件访问
#general-settings 元素,但 brand/security/registration 三个模板
无此容器,导致 TypeError 被 catch 吞掉 → '加载设置失败'。

额外修复:
- footer.html: ExtraJS 添加 ?v= 缓存版本哈希
- site-settings.js: API 失败时显式展示服务端错误消息(不再静默 return)
- site_settings.go: Set() 改用 UPSERT(clause.OnConflict),
  修复 GORM Save() 对不存在 key 首次保存时返回 ErrRecordNotFound
2026-06-30 18:56:42 +08:00
ecbae7c4fa fix(admin): 修复设置页面 CSP style-src 违规导致加载失败
Some checks failed
CI / Lint + Build (push) Has been cancelled
- 移除 site-settings 模板中的内联 style 属性(display:none, width:100px)
- 将内联样式迁移至 site-settings.css 的 CSS 类中
- site-settings-theme.js 用 className 替代 style.display 操作
2026-06-30 18:19:50 +08:00
b10828c851 chore: 排除自用主题 templates/jay-pub/ 版本控制 2026-06-30 18:06:35 +08:00
e7092254ab feat(theme): 管理后台新增模板选择器,支持运行时动态切换主题
Some checks failed
CI / Lint + Build (push) Has been cancelled
新增功能:
- 主题发现模块:扫描 templates/ 目录下含 theme.json 的目录
- 站点设置新增 site.theme 键,DB 持久化 + 内存缓存
- 运行时切换主题:保存后即时重载 Gin 模板 + 静态资源哈希
- 管理后台 '外观主题' 页面:列表展示、选择、一键切换
- 动态静态资源服务:根据当前主题目录提供 CSS/JS

修复:
- account.html 残留孤儿 {{end}} 模板语法错误
- 动态静态资源 handler 误判 Gin *filepath 路径前缀为绝对路径

变更文件(12 modified + 3 new):
- cmd/server/main.go: 动态主题加载 + 运行时重载 + 动态静态服务
- internal/theme/discovery.go: 新增主题扫描
- internal/config/site_settings.go: ThemeName()
- internal/service/site_setting_service.go: ThemeReloader + UpdateTheme
- internal/controller/admin/: ISP 扩展 + GetThemes/UpdateTheme/ThemePage
- internal/controller/auth_controller.go: 准则路径动态化
- internal/router/: InjectThemeReloader + 新路由
- templates/: 外观主题页面 + JS + CSS + 子导航
2026-06-30 17:43:08 +08:00
3d69dae799 docs: 更新 development.md 到 v0.1.0-rc1,覆盖全部 Phase 及审计修复
Some checks failed
CI / Lint + Build (push) Has been cancelled
2026-06-22 03:12:29 +08:00
c3c7390498 refactor: 统一 BaseModel 嵌入,修复 primaryKey 大小写不一致
Some checks failed
CI / Lint + Build (push) Has been cancelled
变更模型:
- Post: 嵌入 BaseModel,移除手动 ID/CreatedAt/UpdatedAt/DeletedAt
- Comment: 嵌入 BaseModel,保留 is_deleted 自定义软删除
- Announcement: 嵌入 BaseModel
- Category: 嵌入 BaseModel
- Folder + FolderItem: 嵌入 BaseModel
- CommunityFund: primaryKey → primarykey (统一小写)
2026-06-22 03:09:19 +08:00
2449a27eb5 refactor: CSP nonce替代unsafe-inline, HSTS始终启用, router/api拆分, 管理后台+用户设置页拆分, DB健康降级, 时区配置, UID统一解析, CI接入
Some checks failed
CI / Lint + Build (push) Has been cancelled
审计修复:
- CSP: unsafe-inline移除, nonce替代; unsafe-eval保留供Vditor使用
- HSTS: 始终启用(不再依赖release模式)
- Controller Exp: common.GetGinExp包装, 不再直接读context
- UID解析: common.ParseUIDParam统一, follow/admin controller改用

重构:
- router/api.go(198行)拆为7个域文件: auth/settings/posts/comments/reactions/social/studio
- 管理后台站点设置: 单页拆为4个子页(brand/security/registration/content)+子菜单
- 前台用户设置页: 1201行拆为6个独立模板+6个独立JS文件

新增:
- DB健康检测中间件(middleware/db_health.go): 5s ping+降级页面
- 时区配置: config.yaml server.timezone→time.Local初始化
- .gitea/workflows/ci.yml: strict模式CI流水线
2026-06-22 03:06:24 +08:00
e7185f58fb refactor: 连接配置统一迁移至 .env,config.yaml 仅作默认值
- 新增 .env.example 模板(DB/Redis/Server 全部连接信息)
- bindEnvOverride 补全 11 个环境变量绑定(原仅 2 个密码)
- README 和 deployment.md 改为 .env 优先流程
- 移除 systemd EnvironmentFile,统一读 WorkingDirectory/.env
2026-06-22 02:49:24 +08:00
786f463613 docs: 快速开始(含 DB 迁移步骤) + 生产部署指南
README.md:
  - 补充环境要求(Go 1.26 / PG 16 / Redis 7)
  - 添加数据库初始化迁移步骤
  - 说明 .env 敏感信息注入
  - 链接到部署文档

docs/deployment.md (新增):
  - 编译 + systemd 服务
  - Nginx HTTPS 反向代理配置
  - 生产环境 config.yaml 关键调整
  - 首次注册站长 + 备份建议
2026-06-22 02:46:49 +08:00
518a4248d0 refactor: 删除死代码 SaveUploadedFile(5bdf010 后已无调用方) 2026-06-22 02:41:39 +08:00
58d6a45b38 fix: Makefile 使用 GOBIN 绝对路径,避免 Git hook 环境 PATH 不一致 2026-06-22 02:30:24 +08:00
742b79475f chore: 安装 make,ci.sh 恢复调用 Makefile 目标 2026-06-22 02:29:58 +08:00
99721f562c fix: CI 脚本移除 make 依赖,改用直接命令 2026-06-22 02:28:54 +08:00
97adf54d6d fix: golangci-lint 零告警通过 (57→0) + gofumpt/goimports 全量格式化
## CI 修复 (P0/P1)

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

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

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

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

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

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

View File

@ -1,6 +1,24 @@
# 复制为 .env 后填入真实值
# .env 会覆盖 config.yaml 中的同名字段
# ==================================
# MCE 环境变量配置模板
# 复制为 .env 后填入实际值
# cp .env.example .env
# ==================================
DATABASE_PASSWORD=your_password_here
JWT_SECRET=generate-a-random-64-char-string-here
REDIS_PASSWORD=your_redis_password_here
# --- 数据库 ---
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_USER=metazone
DATABASE_PASSWORD=
DATABASE_DBNAME=metalab_dev
DATABASE_SSLMODE=disable
# --- Redisenabled=false 时以下可忽略) ---
REDIS_ENABLED=true
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# --- 服务 ---
SERVER_PORT=8080
SERVER_MODE=debug

36
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,36 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint + Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
restore-keys: ${{ runner.os }}-go-
- name: Install tools
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
go install mvdan.cc/gofumpt@latest
go install golang.org/x/tools/cmd/goimports@latest
- name: CI (strict)
run: bash scripts/ci.sh --strict

3
.gitignore vendored
View File

@ -23,6 +23,9 @@ storage/logs/
# 设计文档(本地使用,不纳入版本控制)
_Design/
# 自用模板(不纳入版本控制)
templates/jay-pub/
# IDE
.idea/
.vscode/

41
.golangci.yml Normal file
View File

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

40
Makefile Normal file
View File

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

View File

@ -11,6 +11,10 @@
<p align="center"><strong>MCE</strong> — 下一代开源社区引擎,为 BBS 3.0 而生</p>
> **⚠️ 维护状态**
> 因个人原因本项目暂时无法继续活跃维护。现有功能可正常使用Issues/PR 可能无法及时响应。
> 如有兴趣接手或继续开发,欢迎 Fork。
---
## 关于项目
@ -70,21 +74,69 @@ MCE 是 **MetaZone Community Engine** 的缩写,是 MetaZone 品牌下的社
## 快速开始
### 环境要求
| 依赖 | 最低版本 | 说明 |
|------|----------|------|
| Go | 1.26 | 编译运行 |
| PostgreSQL | 16 | 主数据库 |
| Redis | 7 | 会话存储(可选,关闭后使用内存存储) |
### 1. 克隆并配置
```bash
# 克隆仓库
git clone git@git.metazone.cc:MetaZone/mce.git
cd mce
```
# 配置数据库和 Redis
复制环境变量模板并填入实际值:
```bash
cp .env.example .env
# 编辑 .env 填入 PostgreSQL 和 Redis 连接信息
```
# 运行
编辑 `.env`,至少填入数据库密码:
```bash
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_USER=metazone
DATABASE_PASSWORD=your_password
DATABASE_DBNAME=metalab_dev
REDIS_ENABLED=true # 可选false 则使用内存存储
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
```
> 所有连接信息统一在 `.env` 管理。`config.yaml` 保留默认值兜底,通常无需修改。
### 2. 初始化数据库
```bash
# 按序执行迁移 SQL
psql -U metazone -d metalab_dev -f docs/migrations/001_uid_to_id.sql
psql -U metazone -d metalab_dev -f docs/migrations/002_announcements.sql
psql -U metazone -d metalab_dev -f docs/migrations/003_post_attributes.sql
psql -U metazone -d metalab_dev -f docs/migrations/004_categories_tags.sql
psql -U metazone -d metalab_dev -f docs/migrations/005_scheduled_posts.sql
```
首次部署后注册的第一个用户自动获得站长owner权限。
### 3. 启动
```bash
go run cmd/server/main.go
# 或编译后运行
go build -o mce cmd/server/main.go && ./mce
```
启动后访问 `http://localhost:8080`
> 生产环境部署详见 [`docs/deployment.md`](docs/deployment.md)。
## 项目结构
```

View File

@ -1,11 +1,17 @@
package main
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/repository"
"metazone.cc/mce/internal/router"
@ -22,6 +28,16 @@ func main() {
// 配置
cfg := config.Load("config.yaml")
// 设置时区
if cfg.Server.Timezone != "" {
loc, err := time.LoadLocation(cfg.Server.Timezone)
if err != nil {
log.Printf("警告:时区 %s 无效,使用系统时区: %v", cfg.Server.Timezone, err)
} else {
time.Local = loc
}
}
// 从配置初始化角色系统OCP新增角色无需修改源代码
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
@ -58,26 +74,49 @@ func main() {
r := gin.Default()
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变
common.ComputeAssetHashes("templates/MetaLab-2026")
// 站点设置管理器DB 持久化 + 内存缓存 — 必须在模板加载前初始化
siteSettings, err := config.NewSiteSettings(db)
if err != nil {
log.Fatalf("初始化站点设置失败: %v", err)
}
// 读取当前主题名称
themeName := siteSettings.ThemeName()
log.Printf("当前主题: %s", themeName)
// 计算静态资源哈希
common.ComputeAssetHashes("templates/" + themeName)
// 模板加载(前端主题 + 管理后台)
tmpl, err := theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
)
tmpl, err := loadThemeTemplates(themeName)
if err != nil {
panic("加载模板失败: " + err.Error())
log.Fatalf("加载模板失败: %v", err)
}
r.SetHTMLTemplate(tmpl)
// 静态资源
r.Static("/static", "./templates/MetaLab-2026/static")
// 静态资源 — 前台使用动态主题目录
r.GET("/static/*filepath", func(c *gin.Context) {
serveDynamicStatic(c, "templates", siteSettings, c.Param("filepath"))
})
r.Static("/admin/static", "./templates/admin/static")
r.Static("/shared/static", "./templates/shared/static")
r.Static("/uploads/avatars", "./storage/uploads/avatars")
r.Static("/uploads/posts", "./storage/uploads/posts")
// 主题重载函数(切换主题后实时生效)
themeReloader := func(newTheme string) error {
log.Printf("切换主题: %s", newTheme)
// 更新静态资源哈希
common.ComputeAssetHashes("templates/" + newTheme)
// 重新加载模板
newTmpl, err := loadThemeTemplates(newTheme)
if err != nil {
return err
}
r.SetHTMLTemplate(newTmpl)
return nil
}
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
var redisClient *redis.Client
if cfg.Redis.Enabled {
@ -92,19 +131,59 @@ func main() {
log.Println("Redis 未启用,使用内存存储")
}
// 站点设置管理器DB 持久化 + 内存缓存
siteSettings, err := config.NewSiteSettings(db)
if err != nil {
log.Fatalf("初始化站点设置失败: %v", err)
}
// 启动数据库健康检测(每 5s ping失败自动降级
middleware.StartDBHealthCheck(db)
// 路由注册
router.Setup(r, db, cfg, siteSettings, redisClient)
// 注入主题重载回调到站点设置 Controller
router.InjectThemeReloader(themeReloader)
// 定时发布调度器
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
// 启动
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)
}
}
// loadThemeTemplates 加载指定主题的模板(前台 + 管理后台)
func loadThemeTemplates(themeName string) (*template.Template, error) {
return theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/" + themeName + "/html", Prefix: ""},
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
)
}
// serveDynamicStatic 根据当前站点主题动态提供静态资源
func serveDynamicStatic(c *gin.Context, baseDir string, ss *config.SiteSettings, file string) {
themeName := ss.ThemeName()
root := filepath.Join(baseDir, themeName, "static")
// 清理路径并拼接
cleanFile := filepath.Clean(file)
fullPath := filepath.Join(root, cleanFile)
// 安全检查:防止路径遍历逃逸出主题目录
absRoot, err := filepath.Abs(root)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
absPath, err := filepath.Abs(fullPath)
if err != nil || !strings.HasPrefix(absPath, absRoot+string(os.PathSeparator)) && absPath != absRoot {
c.AbortWithStatus(http.StatusNotFound)
return
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
c.AbortWithStatus(http.StatusNotFound)
return
}
http.ServeFile(c.Writer, c.Request, fullPath)
}

View File

@ -5,6 +5,7 @@ server:
port: 8080
mode: debug # debug | release | test
cookie_secure: false # Cookie Secure 标志(生产环境应设为 true部分反向代理环境下可能需要灵活控制
timezone: Asia/Shanghai # 时区(影响 time.Now() 和模板时间格式化)
database:
host: 127.0.0.1

View File

@ -1,426 +0,0 @@
# MetaLab 项目全量代码审计报告
**审计日期**: 2025-05-31
**审计范围**: `lab.metazone.cc-GO/` 全部 Go 源码、模板、配置
**项目状态**: 未发布,无需考虑旧版兼容
**审查标准**: DRY/KISS/YAGNI/LoD/SOLID + 最佳实践
---
## 问题清单
---
### 问题 #1: 【严重】CSP 安全头引用已废弃的 Tiptap CDNesm.sh
**类型**: 死代码 / 残留配置
**位置**: `internal/middleware/security.go:12-21`
**违反原则**: KISS引用了不存在的依赖
```go
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js)
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
"connect-src 'self' https://esm.sh"
```
**问题**: 项目已迁移到 Vditor 编辑器,但 CSP 头仍保留 Tiptap 时代的 esm.sh CDN 白名单。三个指令script-src、style-src、connect-src都包含未使用的 `https://esm.sh`
**风险**:
- 扩大了不必要的 CSP 白名单,引入额外信任域
- connect-src 允许到 esm.sh 的连接,可能泄露页面信息
**建议**: 移除所有 `https://esm.sh` 引用highlight.js 主题已本地化为 73 个静态文件(`static/vditor/dist/js/highlight.js/styles/`CSP 可完全收紧为 `'self'`
---
### 问题 #2: 【严重】Login() 中封禁状态检查缺少防时序攻击保护
**类型**: 安全缺陷
**位置**: `internal/service/auth_service.go:151-154`
**违反原则**: 最佳安全实践
```go
// 封禁
if user.Status == model.StatusBanned {
return nil, common.ErrUserBanned // ← 没有 bcrypt dummy hash 比对
}
```
**问题**: 其他分支维护模式、用户不存在、密码错误、StatusLocked都有 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)` 防时序攻击,唯独 StatusBanned 分支缺失。攻击者可以通过响应时间差异判断被封禁的账号是否存在。
**建议**: 在返回 `ErrUserBanned` 前增加 dummy hash 比对:
```go
if user.Status == model.StatusBanned {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrUserBanned
}
```
---
### 问题 #3: 【严重】自定义 constantTimeEq 不如 crypto/subtle 安全
**类型**: 安全缺陷 / 最佳实践
**位置**: `internal/middleware/csrf_token.go:44-53`(被 `csrf.go:47` 调用)
**违反原则**: 安全最佳实践
```go
// constantTimeEq 恒定时间字符串比较(防时序攻击)
func constantTimeEq(a, b string) bool {
if len(a) != len(b) { // ← 长度不等时提前返回,泄露长度信息
return false
}
var result byte
for i := 0; i < len(a); i++ {
result |= a[i] ^ b[i]
}
return result == 0
}
```
**问题**:
1. `constantTimeEq` 在同包内被 `csrf.go:47` 调用,**非死代码**
2. 但其手写实现存在两个安全隐患:
- **长度提前返回泄露信息**`len(a) != len(b)` 时立即返回 false攻击者可通过响应时间判断 CSRF token 长度
- **无编译器优化防护**`crypto/subtle.ConstantTimeCompare` 内部使用了特殊的编译器屏障防止被优化,手写版本可能被 Go 编译器优化掉 XOR 结果检查
3. 函数定义在 `csrf_token.go`token 生成文件)而非 `csrf.go`(校验文件),逻辑归属不当
**建议**: 删除 `constantTimeEq`,改用 `crypto/subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1`。这也是 Go 官方推荐的做法。
---
### 问题 #4: 【高】Admin 控制器锁定操作消息显示错误
**类型**: Bug
**位置**: `internal/controller/admin/admin_controller.go:93-96`
**违反原则**: 无(纯 bug
```go
action := "封禁"
if req.Status == model.StatusActive {
action = "解封"
} else if req.Status == model.StatusLocked {
action = "已删除" // ← BUG: 应该是 "已锁定"
}
```
**问题**: 当管理员执行锁定操作时,成功提示消息显示"已删除成功",而不是"已锁定成功"。Locked 与 Deleted 是两个完全不同的状态。
**建议**: 改为 `action = "已锁定"`
---
### 问题 #5: 【高】Login() 中密码验证方式不一致
**类型**: 代码一致性问题
**位置**: `internal/service/auth_service.go:138,152`
**违反原则**: KISS同一逻辑用了两种实现
```go
// StatusDeleted 分支:
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
// StatusBanned 之后的主路径:
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
```
`CheckPassword` 内部只是封装了单行:
```go
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
```
**问题**: 虽然功能上没有 bug但在同一函数中既有 `common.CheckPassword()` 调用,也有其他分支使用 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)`(直接调用 bcrypt。风格不统一`CheckPassword` 的封装价值极低(仅包装一行标准库调用,不如直接使用 bcrypt 调用更直观)。
**建议**:
- 选项A: 删除 `common.CheckPassword`,统一使用 `bcrypt.CompareHashAndPassword`
- 选项B: 在 `CheckPassword` 中增加防时序的一致性包装,统一入口
---
### 问题 #6: 【中】大量空模板目录YAGNI 违规)
**类型**: YAGNI 违规
**位置**:
- `templates/MetaLab-2026/html/post/`(空)
- `templates/MetaLab-2026/html/comment/`(空)
- `templates/MetaLab-2026/html/partials/`(空)
- `templates/MetaLab-2026/html/search/`(空)
- `templates/MetaLab-2026/html/error/`(空)
- `templates/MetaLab-2026/html/admin/`(空)
- `templates/system/email/`(空)
**问题**: 7 个空目录,标注为"预留"。项目尚未发布,这些目录的创建时机应该和实际功能开发同步,而非提前占位。
**建议**: 删除所有空目录。需要时随功能一起创建。
---
### 问题 #7: 【中】Shortcode 预留类型poll/resource无后端实现
**类型**: YAGNI 违规
**位置**: `internal/model/shortcode.go:37-38`, `internal/service/shortcode_service.go:126-137`
**违反原则**: YAGNI
```go
ShortcodePoll ShortcodeType = "poll" // ※预留后端API未实现
ShortcodeResource ShortcodeType = "resource" // ※预留后端API未实现
```
**问题**: poll 和 resource 两个 shortcode 类型的后端 API 未实现前端也未实现shortcode.js 中可能也未实现对应渲染),但代码中已注册了完整的解析和占位 HTML 生成逻辑。用户实际上可以使用 `[zone:poll:xxx]` 语法,但会得到一个永远"加载中..."的卡片。
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。前端渲染逻辑将随 API 同步实现。
---
### 问题 #8: 【中】redis_store.go 全注释的"预留实现"
**类型**: YAGNI / 死代码
**位置**: `internal/session/redis_store.go`
**违反原则**: YAGNI
**问题**: 整个文件是注释掉的代码,没有实际可执行逻辑。如果未来需要 Redis 支持,到时再创建即可。
**状态**: 已排期开发保留不删除。首次审计时误删已恢复。Redis 支持将在后续迭代中实现。
---
### 问题 #9: 【中】tokenCtrl 是 authCtrl 的无意义别名
**类型**: 不必要的字段重复
**位置**: `internal/router/deps_core.go:30`, `internal/router/deps_extra.go:54`
**违反原则**: KISS
```go
// deps_core.go
type dependencies struct {
// ...
tokenCtrl *controller.AuthController // ← 与 authCtrl 类型完全相同
}
// deps_extra.go
return &dependencies{
// ...
tokenCtrl: authCtrl, // ← 赋的是同一个对象
}
```
**问题**: `tokenCtrl``authCtrl` 指向同一个 `*controller.AuthController` 实例,`tokenCtrl` 仅在 `api.go` 的路由中使用(`d.tokenCtrl.CheckEmail/Register/Login/...`)。这个别名不带来任何好处,反而增加理解成本。
**建议**: 删除 `tokenCtrl` 字段,`api.go` 中直接使用 `d.authCtrl`
---
### 问题 #10: 【中】RateLimiter.check() 存在竞态条件
**类型**: 并发缺陷
**位置**: `internal/middleware/ratelimit_core.go:34-81`
```go
func (rl *RateLimiter) check(...) (RateLimitResult, func()) {
rl.mu.Lock()
defer rl.mu.Unlock()
// ... 读取 state ...
recordFail := func() {
rl.mu.Lock() // ← 重新获取锁
defer rl.mu.Unlock()
// ... 修改 state ...
}
return RateLimitResult{Blocked: false}, recordFail
}
```
**问题**: `check()` 持锁检查后释放锁,返回的 `recordFail` 闭包在**锁外**执行,重新获取锁后再修改状态。
**修复方案**: 将 `check()` + `recordFail` 闭包模式重构为 `try()` 原子操作模式——在持锁状态下一次性完成检查+递增消除竞态窗口。API 改为 `AllowAccount() RateLimitResult` / `AllowIP() RateLimitResult`(不再返回闭包)。调用方在失败时不再需要显式调用 `recordFail()`,成功时仍调用 `Clear()` 清除计数。
---
### 问题 #11: 【低】audit_service.go review() 注释编号跳跃
**类型**: 文档瑕疵
**位置**: `internal/service/audit_service.go:152-166`
```go
// 3. 查审核人信息
reviewer, err := s.userRepo.FindByID(reviewerID)
// ...
// 5. 标记审核结果 ← 跳过了 4
submission.ReviewedBy = &reviewerID
```
**问题**: 注释编号从"3."直接跳到"5.",缺少"4."。
**建议**: 修正编号为连续递增。
---
### 问题 #12: 【低】编译产物未纳入 .gitignore误报实际不存在
**类型**: 仓库整洁性
**位置**: `server`(根目录), `cmd/server/server`
**状态**: 误报。经核实,`.gitignore` 已配置 `server` 规则且从未被 git 跟踪,`git rm --cached` 实际为空操作。此项已从修复表中移除。
---
### 问题 #13: 【低】项目零测试覆盖
**类型**: 质量保障缺失
**位置**: 整个项目(`*_test.go` 搜索结果: 0
**问题**: 项目没有任何单元测试或集成测试。对于包含认证、权限、审核、数据持久化等复杂逻辑的系统,零测试意味着每次重构和修改都有回归风险。
**建议**: 至少为核心模块添加测试:
1. `model/user.go` - HasMinRole/CanOperateRole纯函数易测
2. `service/auth_service.go` - 注册/登录/密码验证逻辑
3. `service/admin_service.go` - checkAndOperate 权限矩阵
4. `middleware/ratelimit_core.go` - 限流逻辑
---
### 问题 #14: 【低】全项目使用 log.Printf 无结构化日志
**类型**: 可维护性 / 可观测性
**位置**: 10 个文件,约 17 处 `log.Printf` 调用
**问题**: 项目大量使用标准库 `log.Printf`,无日志级别、无结构化字段、无上下文追踪。在生产环境中排查问题困难,无法按级别过滤日志。
**建议**: 引入轻量结构化日志库(如 `slog`Go 1.21+ 标准库),按级别区分 Info/Warn/Error关键路径添加 trace/request ID。
---
### 问题 #15: 【低】err != nil 返回时上下文信息丢失
**类型**: 可调试性
**位置**: 多处,例如 `internal/repository/user_repo.go`
```go
func (r *UserRepo) Create(user *model.User) error {
return r.db.Create(user).Error // ← 无上下文
}
```
**问题**: 数据库操作失败时,调用方只知道"出错了",无法快速定位是哪个操作、哪个实体、哪个 ID 导致的失败。
**建议**: 使用 `fmt.Errorf("创建用户失败: %w", err)` 包装错误,在保持错误链的同时添加操作上下文。
---
### 问题 #16: 【低】common.CheckPassword 封装价值极低
**类型**: KISS 违规
**位置**: `internal/common/crypto.go:12-15`
```go
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
```
**问题**: 仅包装一行标准库调用,无额外逻辑。与同一文件中封装了 bcrypt 成本参数的 `HashPassword` 不同,`CheckPassword` 没有提供抽象价值。反而因为隐藏了 `bcrypt.CompareHashAndPassword` 的调用,在需要 `dummyHash` 比对时(如 auth_service.go 的时序攻击防护)不得不绕过它直接调用 bcrypt。
**建议**:
- 如果保留 `CheckPassword`,将 dummy hash 比对也内置进去
- 或者删除此函数,直接在各处显式调用 `bcrypt.CompareHashAndPassword`
---
### 问题 #17: 【低】deps_core.go 中文注释错别字
**类型**: 文档瑕疵
**位置**: `internal/router/deps_core.go:45`
```go
// 启动后台过清理 goroutine
```
**问题**: "过清理"应为"过期清理",少了一个"期"字。
**建议**: 修正为 `启动后台过期清理 goroutine`
---
## 汇总统计
| 严重程度 | 数量 | 问题编号 |
|---------|------|---------|
| 严重 | 3 | #1, #2, #3 |
| 高 | 2 | #4, #5 |
| 中 | 5 | #6, #7, #8, #9, #10 |
| 低 | 7 | #11, #12, #13, #14, #15, #16, #17 |
**总计: 17 个问题**
### 按原则分类
| 原则 | 问题编号 |
|------------|---------|
| 安全 | #1, #2, #3 |
| YAGNI | #6, #7, #8 |
| KISS | #5, #9, #16 |
| Bug | #4, #10 |
| 质量/可维护性 | #12, #13, #14, #15 |
| 文档 | #11, #17 |
---
## 整体评价
项目的分层架构设计合理严格遵循单向依赖router→controller→service→repository→model接口隔离原则ISP执行到位每层都通过最小接口依赖下层。依赖注入清晰无循环依赖。
主要问题集中在三个方面:
1. **安全防护需加强** - CSP 配置残留、时序攻击防护不完整
2. **代码清理不及时** - 存在死代码、预留目录、未使用函数
3. **工程基础设施薄弱** - 零测试、无结构化日志、错误上下文丢失
建议优先处理严重级别问题(#1~#3),然后按批次逐步处理其余问题。
---
## 修复记录
**修复日期**: 2025-05-31
**执行方式**: 全量修复commit: `fix: 审计问题全量修复(安全/YAGNI/Bug/代码质量)`
### 已修复13 项)
| # | 修复内容 | 变更文件 |
|---|---------|---------|
| 1 | 移除 CSP 中 esm.sh (Tiptap 残留) 和 cdnjs.cloudflare.com主题已本地化CSP 全面收紧为 'self' | `middleware/security.go` |
| 2 | StatusBanned 分支增加 dummy hash 防时序攻击 | `service/auth_service.go` |
| 3 | 删除手写 constantTimeEq改用 `crypto/subtle.ConstantTimeCompare` | `middleware/csrf_token.go`, `middleware/csrf.go` |
| 4 | 锁定操作消息修正"已删除"→"已锁定" | `controller/admin/admin_controller.go` |
| 5 | 删除 common.CheckPassword 薄封装,统一改用 bcrypt.CompareHashAndPassword | `common/crypto.go`, `service/auth_service.go` |
| 6 | 删除 12 个空预留目录(含第二轮追加 5 个) | `templates/MetaLab-2026/html/{post,comment,partials,search,error,admin,topic,user}`, `templates/{system,system/email}`, `templates/MetaLab-2026/static/{img,vendor}` |
| 9 | 删除 tokenCtrl 别名字段,统一使用 authCtrl | `router/deps_core.go`, `router/deps_extra.go`, `router/api.go` |
| 10 | 将 check()+recordFail 闭包重构为 try() 原子操作,消除竞态 | `middleware/ratelimit_core.go`, `middleware/ratelimit.go`, `middleware/ratelimit_cleanup.go`, `controller/auth_api_login.go`, `controller/auth_api_register.go` |
| 11 | 修正 review() 注释编号 3→5→4 | `service/audit_service.go` |
| 17 | 修正"过清理"→"过期清理" | `router/deps_core.go` |
### 已回滚(误删,已排期开发,保留)
| # | 回滚内容 | 变更文件 |
|---|---------|---------|
| 7 | 恢复 poll/resource shortcode 类型(误删) | `model/shortcode.go`, `service/shortcode_service.go` |
| 8 | 恢复 redis_store.go 预留实现(误删) | `session/redis_store.go` |
### 误报(实际不存在)
| # | 说明 |
|---|------|
| 12 | 编译产物从未被 git 跟踪,`.gitignore` 已生效,`git rm --cached` 为空操作 |
### 暂缓修复3 项)
| # | 暂缓原因 | 后续计划 |
|---|---------|---------|
| 13 | 零测试 — 需要建立测试框架、mock 策略,工作量大 | 核心模块优先hasMinRole、checkAndOperate、RateLimiter |
| 14 | 无结构化日志 — 需评估 slog vs zap全量替换 log.Printf | Go 1.21+ 使用标准库 slog 渐进替换 |
| 15 | 错误上下文丢失 — 涉及全部 repository 层,工作量大 | 按文件逐批添加 `fmt.Errorf("...: %w", err)` |

View File

@ -1,5 +1,103 @@
# Go 代码规范
## 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>` 标签拿到不同 NonceCSP 校验失败。
- **MUST NOT** 在 `init()` 或包级变量中缓存 Nonce——Nonce 必须每次请求重新生成Number used ONCE
- 模板通过 `{{.csp_nonce}}` 获取:`<script nonce="{{.csp_nonce}}">...</script>`。
### Middleware 职责边界
Middleware **MUST** 保持单一职责,**MUST NOT** 包含以下内容:
- ❌ 数据库查询或调用 Repository
- ❌ 复杂的业务分支逻辑
- ❌ 直接操作请求体(`ctx.Request.Body`)——应使用 `ctx.ShouldBind` 系列方法
## Controller 禁止事项
- ❌ 直接调用 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 日志、代码中的技术常量

147
docs/deployment.md Normal file
View File

@ -0,0 +1,147 @@
# 生产环境部署指南
## 环境要求
| 依赖 | 最低版本 | 用途 |
|------|----------|------|
| Go | 1.26 | 编译 |
| PostgreSQL | 16 | 主数据库(用户、帖子、通知等) |
| Redis | 7 | 会话存储(推荐) |
| Nginx | 1.24+ | 反向代理 + 静态资源 |
## 1. 编译
```bash
CGO_ENABLED=0 go build -ldflags="-s -w" -o /usr/local/bin/mce cmd/server/main.go
```
## 2. 数据库初始化
```bash
# 创建数据库
sudo -u postgres createuser metazone -P
sudo -u postgres createdb metalab_prod -O metazone
# 执行迁移
for f in docs/migrations/0*.sql; do
psql -U metazone -d metalab_prod -f "$f"
done
```
## 3. 配置
```bash
cp .env.example .env
```
编辑 `.env`,填入生产环境值:
```bash
# 服务
SERVER_MODE=release
# 数据库(生产实例地址)
DATABASE_HOST=10.0.0.1
DATABASE_PORT=5432
DATABASE_USER=metazone
DATABASE_PASSWORD=your_secure_password
DATABASE_DBNAME=metalab_prod
DATABASE_SSLMODE=require
# Redis
REDIS_ENABLED=true
REDIS_HOST=10.0.0.2
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
REDIS_DB=0
```
> `config.yaml` 中的默认值会被 `.env` 覆盖,通常无需修改。
## 4. systemd 服务
创建 `/etc/systemd/system/mce.service`
```ini
[Unit]
Description=MetaZone Community Engine
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=mce
WorkingDirectory=/opt/mce
ExecStart=/usr/local/bin/mce
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
启动:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now mce
```
## 5. Nginx 反向代理
```nginx
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/ssl/certs/your-domain.pem;
ssl_certificate_key /etc/ssl/private/your-domain.key;
# 静态资源Gin 不处理)
location /static/ {
root /opt/mce/templates/MetaLab-2026;
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
```
## 6. 验证
```bash
# 服务状态
sudo systemctl status mce
# 日志
sudo journalctl -u mce -f
# 健康检查
curl -I https://your-domain.com
```
## 首次注册站长
部署后首个注册用户自动获得 `owner`(站长)权限,可访问 `/admin` 管理后台。
## 备份建议
```bash
# 数据库备份
pg_dump -U metazone metalab_prod | gzip > backup_$(date +%Y%m%d).sql.gz
# 上传文件备份
rsync -av /opt/mce/storage/ /backup/storage/
```

View File

@ -1,562 +1,194 @@
# 开发记录 — 阶段一:基础设施 + 用户认证(含"记住我"与令牌刷新)
# 开发记录 — MetaLab RC
> 最后更新2026-05-24
> 最后更新2026-06-22 | 版本v0.1.0-rc1
## 一、已完成事项
### 1. 数据库配置与连接
**问题**PostgreSQL 使用 `peer` 认证Go 进程以系统用户 `victor_jay` 运行,无法连接数据库用户 `metazone`
**解决**
-`metazone` 数据库用户设置密码(`MetalabDev2026!`
- 连接方式从 Unix socket`/var/run/postgresql`)改为 TCP`127.0.0.1:5432`),触发 `scram-sha-256` 密码认证
- 数据库名称:`metalab_dev`
**涉及文件**`config.yaml``database.host` 改为 `127.0.0.1`)、`.env``DATABASE_PASSWORD`
---
### 2. 配置加载架构修复
**问题**Viper 的 `SetConfigFile(".env")` 会**覆盖**之前设置的 `config.yaml` 路径,导致所有 YAML 配置被清空,数据库名称为空。
**根本原因**Viper 不支持同时加载多个不同类型的配置文件。
**解决方案**
`internal/config/config.go` 中的加载流程:
```
1. loadEnvFile(".env") → 逐行读取,通过 os.Setenv() 注入环境变量
2. viper 只读取 config.yaml
3. bindEnvOverride() → 将 DATABASE_PASSWORD、JWT_SECRET 显式绑定到对应配置键
4. viper.AutomaticEnv() → 自动读取其余环境变量
```
环境变量优先级:**系统环境变量 > .env 文件**`.env` 仅在对应环境变量未设置时生效)
**涉及文件**`internal/config/config.go`(重写 `Load()` 函数)
---
### 3. 数据模型设计
#### 3.1 公共基础模型 (`common.go`)
| 字段 | 类型 | GORM 标签 | 说明 |
|---|---|---|---|
| `ID` | `uint` | `primarykey;column:uid` | 主键,数据库列名为 `uid`,自增 bigint非 UUID |
| `CreatedAt` | `time.Time` | — | 自动管理 |
| `UpdatedAt` | `time.Time` | — | 自动管理 |
| `DeletedAt` | `gorm.DeletedAt` | `index;json:"-"` | 软删除JSON 不暴露 |
#### 3.2 用户模型 (`user.go`)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `Email` | `varchar(255)` | `uniqueIndex; not null` | 注册和登录的唯一凭证 |
| `PasswordHash` | `varchar(255)` | `not null; json:"-"` | bcrypt(12)JSON 永不暴露 |
| `Username` | `varchar(16)` | `uniqueIndex; not null` | 全局唯一10 位随机 `[a-z0-9]` 自动生成 |
| `Avatar` | `varchar(500)` | `default:''` | 预留头像字段 |
| `Bio` | `text` | — | 预留个人简介 |
| `Role` | `varchar(20)` | `default:user; index` | `user` / `moderator` / `admin` |
| `Status` | `varchar(20)` | `default:active; index` | `active` / `banned` |
**关键设计决策:**
| 决策项 | 选择 | 原因 |
|---|---|---|
| 主键方案 | 自增 bigint`uid` 列)而非 UUID | 社区应用索引性能更好URL 简洁 `/users/42``JOIN` 体积小 |
| 角色控制 | `role string` 而非 `is_admin bool` | 可扩展(已有 user/moderator/admin 三级),符合最佳实践 |
| 用户名生成 | `crypto/rand` 生成 10 位 `[a-z0-9]` | 3,656 万亿种组合,千万用户碰撞概率 < 0.01% |
| 用户名长度 | `varchar(16)` | 作为最大长度限制暂未支持中文字符名 |
| 密码哈希 | bcrypt(12) | 行业标准成本 12 平衡安全与性能 |
| 软删除 | GORM `DeletedAt` | 保留数据误删可恢复 |
---
### 4. 用户注册
**流程**
```
POST /api/auth/register
→ 验证 Email/Password/ConfirmPasswordbinding 标签)
→ Service.Register()
→ validatePassword()≥8位 + 含字母 + 含数字
→ userRepo.FindByEmail():邮箱查重
→ bcrypt.GenerateFromPassword(,12):密码哈希
→ generateUsername():生成 10 位随机用户名,重试 20 次去重
→ userRepo.Create():写入数据库
→ buildToken():生成 access JWTHS25615 分钟)
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWTHS25630 天)
→ SetAuthCookies(accessToken, refreshToken) → 设置 Cookie
→ 返回 JSON { success, message, data: user }
```
**关键点**注册即登录Cookie 自动设置前端无需额外操作勾选"记住我"则下发 refresh token + 标记 Cookie未勾选则不下发
**涉及文件**
- `internal/controller/auth_controller.go` `Register()` POST handler
- `internal/service/auth_service.go` `Register()``validatePassword()``generateUsername()``buildToken()``buildRefreshToken()`
- `internal/repository/user_repo.go` `Create()``FindByEmail()`
- `internal/model/dto.go` `RegisterRequest`binding 校验标签 + `remember_me`
---
### 5. 用户登录
**流程**
```
POST /api/auth/login
→ 验证 Email/Password
→ Service.Login()
→ userRepo.FindByEmail():查找用户
→ 检查 Status != banned封禁用户拒绝登录
→ bcrypt.CompareHashAndPassword():密码校验
→ buildToken():生成 access JWT
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT
→ SetAuthCookies(accessToken, refreshToken)
→ 返回 JSON { success, message, data: user }
```
**错误处理**根据错误类型返回不同 HTTP 状态码和消息
- 401邮箱不存在 / 密码错误
- 403用户已封禁
- 500服务器内部错误
**涉及文件**`auth_controller.go``Login()`)、`auth_service.go``Login()``ErrUserBanned``buildRefreshToken()`
---
### 6. JWT 认证中间件
| 中间件 | 用途 | 注入上下文的值 |
|---|---|---|
| `AuthRequired` | 严格认证失败返回 401 | `uid``email``username``role` |
| `AuthOptional` | 可选认证 token 就注入没有也放行 | 同上 token 时不注入 |
**Cookie 配置**
| Cookie | 名称 | HttpOnly | Secure | SameSite | 过期 |
|---|---|---|---|---|---|
| Access token | `mlb_token` | | ✅(debug 模式 HTTP | Lax | 15 min |
| Refresh token | `mlb_refresh` | | | Lax | 30 day记住我时 |
| 记住我标记 | `mlb_rm` | | | | 30 day记住我时 |
**中间件公开函数**
| 函数 | 用途 |
|---|---|
| `SetAuthCookies(c, accessToken, refreshToken, cfg)` | 设置 access + refresh+ mlb_rm 标记 |
| `SetAccessCookie(c, accessToken, cfg)` | 仅刷新 access cookie续期调用 |
| `ClearAuthCookies(c, cfg)` | 清除全部三个 Cookie登出/过期 |
**涉及文件**`internal/middleware/auth.go`
---
### 7. 登录状态注入SSR 模板渲染)
`internal/common/helper.go` 中的 `BuildPageData()`
```go
func BuildPageData(c *gin.Context, extra gin.H) gin.H
```
- `extra` 键值对复制到 `gin.H`
- Gin 上下文中存在 `username` `AuthOptional` 中间件注入自动添加
- `IsLoggedIn: true`
- `Username: "..."`
- 所有页面 Controller 通过此函数构建模板数据
---
### 8. 页面路由与模板
| 路由 | 方法 | 中间件 | 模板 | 说明 |
|---|---|---|---|---|
| `/` | GET | `AuthOptional` | `home/index.html` | 已登录显示首页未登录重定向 `/auth/login` |
| `/auth/register` | GET | `AuthOptional` | `auth/register.html` | 已登录重定向 `/` |
| `/auth/login` | GET | `AuthOptional` | `auth/login.html` | 已登录重定向 `/` |
---
### 9. NAV 导航栏逻辑
```html
{{if .IsLoggedIn}}
→ 显示用户名链接(指向 /settings+ "退出"按钮
{{else}}
→ 显示"登录"按钮(指向 /auth/login
{{end}}
```
**设计决策**NAV 只显示"登录"按钮不显示独立"注册"按钮注册入口在登录页面内部提供作为开发者社区用户应能理解)。已登录用户可通过"退出"按钮清除所有 Cookie 并返回首页
---
### 10. 模板加载器重构
**问题**原加载器使用 `info.Name()`纯文件名导致 `home/index.html` 注册为 `index.html`与路由引用的 `home_index.html` 不匹配模板渲染失败
**解决**改为使用相对路径作为模板名
## 一、架构总览
```
layout/header.html
layout/nav.html
layout/footer.html
auth/register.html
auth/login.html
home/index.html
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
Service 核心逻辑
Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
```
`{{template}}` 引用全部更新为这些相对路径名
| 层次 | 职责 | 单文件上限 |
|------|------|-----------|
| Router | 路由映射 + 依赖注入 | ≤ 60 行 |
| Controller | 参数绑定 + 调用服务 + 返回 | ≤ 120 行 |
| Middleware | 认证/授权/安全头/限流 | ≤ 60 行 |
| Service | 业务逻辑 | ≤ 300 行 |
| Repository | 纯数据库操作 | ≤ 200 行 |
| Model | 纯结构体 | ≤ 80 行 |
**涉及文件**`internal/theme/loader.go`所有 `.html` 模板文件中的 `{{template}}` 引用
## 二、已完成阶段
---
### Phase 1基础设施 + 用户认证
### 11. "记住我" + 刷新令牌Refresh Token
- PostgreSQL + AutoMigrate + 部分唯一索引(软删除用户邮箱复用
- Gin + 模板渲染 + 静态资源哈希缓存破坏
- 用户注册/登录/登出 + bcrypt(12) 密码哈希
- 服务端 Session滑动窗口续期Redis + 内存 FallbackStore 自动降级
- 全局 `SecurityHeaders` 中间件CSP nonce、HSTS、X-Frame-Options、X-Content-Type-Options
- `InjectSiteInfo` 中间件注入站点品牌信息到所有 SSR 页面
- 角色系统user/moderator/admin/ownerOCP 可扩展)
- `BuildPageData()` / `BuildAdminPageData()` 统一模板数据注入
- CSRF Double Submit Cookie 保护
- 敏感操作限流(`SensitiveRateLimit`:改密/注销/签到)+ 登录限流(账户+IP 双维度滑动窗口)
- 维护模式(仅 owner 可访问,其余用户重定向登录页)
#### 11.1 设计目标
### Phase 2公告系统
| 场景 | 行为 |
|---|---|
| 用户勾选"记住我" | Cookie`mlb_token`(15min) + `mlb_refresh`(30d) |
| 用户未勾选 | Cookie`mlb_token`(15min)过期需重新登录 |
| 页面持续开着 | 前端每 10 分钟静默调用 `/api/auth/refresh` 续期 access token |
| 关闭浏览器 15+ min 后回来 | access token 过期 JS 自动刷新 页面 reload 恢复登录态 |
| 30 天内任意时间回来 | refresh token 仍有效自动静默恢复登录 |
- 公告 CRUD + 管理后台管理页面
- 前台首页公告区域
#### 11.2 Cookie 架构
### Phase 3文章属性
| Cookie | 作用 | HttpOnly | JS 可读 | 过期时间 | 说明 |
|---|---|---|---|---|---|
| `mlb_token` | access token | | | 15 min | JWT `uid/email/username/role/exp/iat` |
| `mlb_refresh` | refresh token | | | 30 day勾选记住我时 | JWT `uid/email/username/role/exp/iat/purpose:"refresh"` |
| `mlb_rm` | 记住我标记 | | | 30 day勾选记住我时 | 值为 `"1"`仅标记位非凭据 |
- Post 模型Visibilitypublic/private、PostTypeoriginal/reprint、ReprintSource
- 创作声明7 种)、禁止转载标记
- 文章置顶category/global+ 管理后台 Pin/Unpin API
- 原创/转载:写文章页条件联动
- 模板函数 `declarationLabel()`
**设计决策 — `mlb_rm` 标记 Cookie**
- 本质是**非安全标记位**not a credential用于 JS 判断"是否该尝试刷新"
- 如果不存在未勾选记住我`common.js` 跳过所有刷新逻辑 **不会产生 401 控制台报错**
- 值仅为 `"1"`即使被 XSS 读取也无法用于任何认证操作
### Phase 4分类系统 + 标签系统
#### 11.3 配置 (`config.yaml` + `config.go`)
- Category 两级树形结构 + 管理后台页面
- Tag 系统 + 落地页 `/tags/{slug}`
- 写文章页分类选择 + 标签输入
```yaml
jwt:
access_expire: 15 # 分钟
refresh_expire: 168 # 小时 (7 天) — 备用字段,当前未启用
remember_expire: 720 # 小时 (30 天) — refresh token 有效期
### Phase 5定时发布
- Post 模型 `ScheduledAt` + 审核逻辑
- Scheduler goroutine 每分钟扫描发布到期文章
- 前端日期时间选择器min=now+30min
### Phase 6收尾与审计修复2026-06-22
**安全加固:**
| 项目 | 变更 |
|------|------|
| CSP | `'unsafe-inline'` 移除nonce 替代(`'unsafe-eval'` 保留供 Vditor |
| HSTS | 始终启用 `max-age=31536000; includeSubDomains; preload` |
| 密码强度 | 四级low/medium/high/very_high+ 连续字符检查 |
| 注册限流 | IP 维度 1min×5 次 |
| CSRF Token 日志 | 仅记录长度,不记明文 |
| DB 健康降级 | 5s ping + 全局 `dbHealthy` 标志 + 中间件拦截返回固定错误页 |
| 文件上传 | `image.Decode` 强制解码验证(替代 magic bytes |
**代码质量:**
| 项目 | 变更 |
|------|------|
| golangci-lint | 57 告警 → 0CI strict 模式零告警 |
| 包注释 | 16 个包全部添加 |
| 错误消息 | 全部小写开头 |
| errorlint | `==` 比较 → `errors.Is` |
| unused 代码 | `hasUnicode`/`energyStore`/`current`/`energyAdminUseCase` 移除 |
| gofumpt | 全量格式化 |
**架构重构:**
| 项目 | 变更 |
|------|------|
| `router/api.go` | 198 行拆为 7 个域文件auth/settings/posts/comments/reactions/social/studio |
| 管理后台站点设置 | 单页 310 行拆为 4 个子页brand/security/registration/content+ 子菜单,旧路由 301 重定向 |
| 前台用户设置 | 1201 行拆为 6 个独立模板 + 6 个独立 JS 文件 |
| Controller Exp 读取 | `common.GetGinExp()` 封装,不再直接读 context |
| UID 解析 | `common.ParseUIDParam()` 统一follow/admin controller 改用 |
| BaseModel 统一 | Post/Comment/Announcement/Category/Folder/FolderItem 嵌入 BaseModel`primaryKey``primarykey` |
| 时区 | `config.yaml server.timezone``time.Local` 初始化 |
**Footer 合规信息:**
- `site_settings.go` 扩展 8 个字段operator/ICP/公安/文网文/算法备案/隐私/条款)
- `footer.html` 渲染合规行(`safeHTML` 模板函数,空值跳过)
- 管理后台合规信息输入区
**注册准则控制:**
- 三层控制:`show_guidelines` / `force_guidelines` / `guidelines_timer`
- 管理后台注册设置区
## 三、文件清单
```text
mce/
├── cmd/server/main.go # 入口配置加载、DB/Redis、模板、路由、调度器
├── config.yaml # 默认配置
├── .golangci.yml # golangci-lint v2 配置
├── .gitea/workflows/ci.yml # CI 流水线strict 模式)
├── scripts/ci.sh # CI 脚本(支持 --strict
├── Makefile # fmt/lint/ci/test 命令
├── internal/
│ ├── common/ # 通用工具响应格式、Context 提取、模板数据构建、错误哨兵
│ ├── config/ # 配置加载 + SiteSettings DB 持久化内存缓存
│ ├── model/ # 所有数据模型25 个)
│ ├── repository/ # GORM 数据访问17 个 repo
│ ├── service/ # 业务逻辑18 个 service含 repository.go 接口定义)
│ ├── controller/ # Gin handler38 个 controller 文件)
│ │ └── admin/ # 管理后台 controller7 个)
│ ├── middleware/ # 中间件:认证/CSRF/安全头/限流/维护/DB健康
│ ├── router/ # 路由注册 + 依赖注入(按域拆分)
│ ├── session/ # 服务端 SessionRedis + Memory + Fallback
│ ├── scheduler/ # 定时发布调度器
│ ├── cache/ # 首页缓存
│ └── theme/ # 模板加载器 + safeHTML 函数
├── templates/
│ ├── MetaLab-2026/ # 前端主题
│ │ ├── html/ # Go 模板(按功能分目录)
│ │ └── static/ # CSS/JS/图片 + Vditor 编辑器
│ ├── admin/ # 管理后台模板
│ └── shared/ # 共享静态资源
└── docs/ # 设计文档
```
`JWTConfig` 新增字段
```go
RememberExpire int `mapstructure:"remember_expire"` // 小时
```
**关键**`mapstructure` 标签**不可省略**否则 Viper 无法将 YAML 键映射到结构体字段导致过期时间全为 0
#### 11.4 请求 DTO (`dto.go`)
`LoginRequest` `RegisterRequest` 都新增了 `RememberMe` 字段
```go
RememberMe bool `json:"remember_me"`
```
#### 11.5 后端业务逻辑 (`auth_service.go`)
**变更的方法:**
| 方法 | 旧返回值 | 新返回值 |
|---|---|---|
| `Register()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
| `Login()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
`req.RememberMe == false` `refreshToken` 返回空字符串 `""`
**新增方法:**
| 方法 | 签名 | 说明 |
|---|---|---|
| `buildRefreshToken(user)` | `(string, error)` | 构建 30 天过期 JWT`purpose` claim `"refresh"` |
| `RefreshAccessToken(refreshTokenStr)` | `(string, error)` | 校验 refresh token 返回新 access token |
**`RefreshAccessToken` 校验流程**
```
1. 解析 refresh token JWT
→ 失败ErrTokenExpired登录已过期
2. 校验 claims["purpose"] == "refresh"
→ 不匹配ErrTokenInvalid防止 access token 被用于刷新)
3. 从 claims 提取 uid/email/username/role → 构建新 access token
```
**安全点**`purpose: "refresh"` 声明确保**access token 无法充当 refresh token**防止短生命周期令牌被滥用为长期凭据
#### 11.6 Session 恢复实现(前端 JS
`common.js` 中的自动刷新逻辑
```
页面加载(非 /auth/* 路径)
→ 检查 document.cookie 是否存在 mlb_rm
→ 不存在 → 跳过所有刷新(无 401 报错)
→ 存在 → fetch POST /api/auth/refresh
→ 成功:
1. 启动 10 分钟定时器
2. 页面未显示登录态(无 logoutBtn
→ sessionStorage 防重 → window.location.reload()
重载后 access token 存在 → SSR 正确渲染登录状态
→ 失败 → 停止定时器(用户需手动重新登录)
```
**关键实现细节**
- `fetch()` 而非 `XMLHttpRequest``fetch` HTTP 2xx 不会触发浏览器控制台静默日志
- `sessionStorage` 防无限循环`mlb_refreshed` 标志确保 reload 只执行一次
- 不在认证页面执行`/auth/login``/auth/register` 路径直接跳过
#### 11.7 界面
登录页和注册页各增加一个复选框
```html
<div class="form-group remember-group">
<label class="remember-label">
<input type="checkbox" id="rememberMe">
保持登录 30 天
</label>
<span class="remember-hint">非共享设备推荐</span>
</div>
```
**默认不勾选**——公共环境教室网吧共享电脑不会意外留下长期凭据
---
### 12. 登出功能
#### 12.1 后端 API
**路由**`POST /api/auth/logout`
**处理流程**`auth_controller.go` `Logout()`
```
1. middleware.ClearAuthCookies(c, cfg)
→ 清除 mlb_token、mlb_refresh、mlb_rm 三个 CookieMaxAge=-1
2. 返回 JSON { success: true, message: "已退出登录" }
```
#### 12.2 前端
**NAV 导航栏**`nav.html`
```html
{{if .IsLoggedIn}}
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{end}}
```
**JS 处理**`common.js`
```
点击"退出" → POST /api/auth/logout → Cookie 清除完毕 → 跳转首页
```
---
### 13. 刷新令牌 API
**路由**`POST /api/auth/refresh`
**处理流程**`auth_controller.go` `RefreshToken()`
```
1. 从 Cookie 读取 mlb_refresh
→ 为空401 "请重新登录"
2. 调用 authService.RefreshAccessToken(refreshToken)
→ 失败ClearAuthCookies() + 401 "登录已过期"
3. 成功SetAccessCookie() → 下发新 access token
→ 返回 200 { success: true }
```
**注意**此端点**只刷新 access token**不刷新 refresh tokenrefresh token 30 天固定有效期到期后用户需手动登录这避免了无限续期带来的安全隐患长期不活跃的 refresh token 最终会自然过期)。
---
### 14. 个人设置页与头像上传
**涉及文件**参考 [`docs/settings.md`](settings.md) 获取完整实现文档
**功能概要**
| 功能 | 实现文件 |
|------|----------|
| 用户名编辑 | `auth_service.go` `UpdateProfile()`正则 `[\p{Han}a-zA-Z0-9_-]` 白名单 |
| 个性签名编辑 | 同上0-128 字符 |
| 头像处理管线 | `avatar_service.go`新文件)— 解码裁切CatmullRom缩放WebP二分编码原子写入 |
| 前端裁切弹窗 | `settings/index.html` 固定框 + 图片平移/缩放 + 坐标映射 |
| CSS样式 | `settings.css` 浅色主题裁切弹窗Toast通知输入框样式 |
| CSP放宽 | `security.go` `img-src 'self' data:`裁切预览用data URI |
| 静态路由 | `main.go` `/uploads/avatars` `./storage/uploads/avatars` |
**设计决策**
## 四、关键设计决策
| 决策 | 选择 | 原因 |
|------|------|------|
| 用户名校验 | 白名单 regex | 比黑名单更安全直接排除 `<>&"'` XSS 向量 |
| 字符计数 | `utf8.RuneCountInString` | 确保与 PostgreSQL `varchar(16)` 语义一致 |
| 头像编码 | WebP + 二分查找质量 | 目标 100KB兼顾质量与体积 |
| 原子写入 | `.tmp` `os.Rename` | 防止写入中断产生损坏文件 |
| 裁切方式 | 固定框 + 图片平移/缩放 | 框大小不变zoom使框覆盖更小区域真正支持精确定位 |
| GIF支持 | 不支持 | 裁切后无法保持动画无实际用途 |
| 最小分辨率 | 128×128 | 防止马赛克图被拉升到 512×512 |
| CSP data: URI | 显式允许 img-src | 裁切预览使用 FileReader data URI |
| Web 框架 | Gin | 性能好,生态成熟 |
| ORM | GORM v2 | AutoMigrate + 软删除 + 回调 |
| 数据库 | PostgreSQL | 社区级应用首选 |
| 会话 | 服务端 Session | 支持踢出设备、登录管理 |
| 存储 | Redis + Memory FallbackStore | Redis 不可达自动降级,恢复自动切回 |
| 模板 | Go html/template | 无前端框架依赖SSR 直出 |
| 编辑器 | Vditor v3.11.2(本地部署) | Markdown 所见即所得 |
| 图表 | Chart.js v4.4.0(本地化) | 趋势可视化 |
| 密码 | bcrypt(12) | 行业标准 |
| 架构 | 分层 + ISP 接口隔离 | 依赖倒置Controller 不依赖完整 Service |
| 重构 | 不向后兼容 | 全新项目,废弃旧代码不做兼容层 |
| Lint | golangci-lint v2.12.217 个 linter | CI strict 模式零告警 |
---
## 五、数据模型一览
## 二、解决方案建议
| 模型 | BaseModel | 说明 |
|------|-----------|------|
| User | ✅ | 用户(邮箱/用户名/密码/角色/状态/经验) |
| Post | ✅ | 帖子/文章(标题/正文/状态/分类/标签/属性/计数器) |
| Comment | ✅ | 评论(含 is_deleted 自定义软删除) |
| AuditSubmission | ✅ | 审核记录 |
| Notification | ✅ | 通知 |
| Announcement | ✅ | 公告 |
| Category | ✅ | 文章分类(两级树形) |
| Folder | ✅ | 收藏夹 |
| FolderItem | ✅ | 收藏记录 |
| Tag | ❌ | 标签 |
| PostTag | ❌ | 文章-标签关联(联合主键) |
| PostLike | ❌ | 点赞(联合主键) |
| PostDislike | ❌ | 踩(联合主键) |
| UserFollow | ❌ | 关注关系(联合主键) |
| CommentMention | ❌ | @提及映射 |
| SiteSetting | ❌ | 站点设置Key-Value |
| EnergyLog / FundLog / PostEnergizeLog | ❌ | 域能/资金流日志 |
| UserCheckIn / UserTask | ✅ | 签到/任务 |
| DailyExpSummary / DailyLikeSummary | ❌ | 每日统计聚合 |
| PostReadLog / PostGuestReadLog | ❌ | 阅读日志 |
| CommunityFund | ❌ | 公户余额(单行表) |
| Level | ❌ | 等级配置(内存) |
### 2.1 PostgreSQL 密码认证(问题一记录)
## 六、待做事项
**系统信息**
- 分布Linux Mint
- 数据库用户`metazone`
- 连接方式TCP `127.0.0.1:5432` Unix socket
- 认证方式`scram-sha-256`
- 密码`MetalabDev2026!`存储于 `.env` `DATABASE_PASSWORD`
- 数据库`metalab_dev`
**验证命令**
```bash
# 测试连接
PGPASSWORD='MetalabDev2026!' psql -h 127.0.0.1 -U metazone -d metalab_dev -c "SELECT current_user, version();"
```
### 2.2 .env 文件结构
```bash
# 必填
DATABASE_PASSWORD=MetalabDev2026!
JWT_SECRET=p2BVUsELbny4W4sYNvoyFesyglD2TlPRT+bzpDk8Dq/E2s9tZFzT6jC8N3AvQpzE
```
`.env` 已加入 `.gitignore`提供 `.env.example` 模板文件
---
## 三、代码规范新增/修改
### 3.1 新增规范
| 编号 | 规范 | 说明 |
|---|---|---|
| C01 | **`BuildPageData()` 统一模板数据注入** | 所有页面 Controller 必须通过此函数构建 `gin.H`禁止手动拼装 `IsLoggedIn`/`Username` |
| C02 | **Cookie 认证优先** | 使用 HttpOnly Cookie`mlb_token`传递 JWT而非 `Authorization` Header兼顾安全与 SSR |
| C03 | **主键列名:`uid`** | Go 侧字段名 `ID`GORM 标签 `column:uid`JSON 序列化 `"uid"` |
| C04 | **用户名10 位随机 `[a-z0-9]`** | 使用 `crypto/rand` `math/rand`20 次重试去重 |
| C05 | **配置 env 注入模式** | `.env` 文件通过 `os.Setenv()` 注入环境变量Viper 通过 `BindEnv` + `AutomaticEnv` 读取不再使用 viper `.env` 文件加载 |
| C06 | **模板名:相对路径** | `layout/header.html``auth/register.html` 禁止用纯文件名避免冲突 |
### 3.2 修改的已有规范
| 编号 | 变更 | | |
|---|---|---|---|
| M01 | 用户标识字段 | `nickname`非唯一 | `username`全局唯一varchar(16) |
| M02 | JSON 序列化字段名 | `"id"` | `"uid"` |
| M03 | `BaseModel.ID` GORM 列名 | `id`默认 | `uid``column:uid` |
---
## 四、当前文件清单
### 后端Go
```
internal/
├── config/
│ └── config.go # Config 结构体 + Load() + loadEnvFile() + DSN()
│ # JWTConfig 含 AccessExpire/RefreshExpire/RememberExpire
├── model/
│ ├── common.go # BaseModeluid 主键 + 时间戳 + 软删除)
│ ├── user.go # User 结构体 + Role/Status 常量
│ └── dto.go # RegisterRequest / LoginRequest 含 RememberMe
├── repository/
│ └── user_repo.go # UserRepoCreate/FindByEmail/FindByID/FindByUsername/ExistsByEmail/ExistsByUsername
├── service/
│ └── auth_service.go # AuthServiceLogin/Register/RefreshAccessToken + buildToken/buildRefreshToken + 8 个错误哨兵)
├── controller/
│ └── auth_controller.go # AuthControllerRegisterPage/LoginPage/Register/Login/Logout/RefreshToken/CheckEmail
├── middleware/
│ └── auth.go # AuthRequired/AuthOptional/SetAuthCookies/SetAccessCookie/ClearAuthCookies/parseToken
│ # 三个 Cookiemlb_token / mlb_refresh / mlb_rm
├── router/
│ └── router.go # Setup() — 路由注册 + 依赖注入链(含 /auth/logout、/auth/refresh
├── common/
│ └── helper.go # BuildPageData() — SSR 登录状态注入
└── theme/
└── loader.go # LoadTemplates() — 相对路径模板加载 + LoadContent()
```
### 前端(模板 + 静态资源)
```
templates/MetaLab-2026/
├── html/
│ ├── layout/
│ │ ├── header.html # HTML headtitle/meta/CSS 引入)
│ │ ├── nav.html # 导航栏(已登录显示用户名+退出按钮 / 未登录显示登录按钮)
│ │ └── footer.html # 页脚
│ ├── auth/
│ │ ├── register.html # 注册页(含准则模态框 + 60s 倒计时 + 记住我复选框)
│ │ └── login.html # 登录页(简洁表单 + 记住我复选框)
│ └── home/
│ └── index.html # 首页Hero 区域 + 欢迎信息)
├── static/
│ ├── css/
│ │ ├── common.css # 全局样式 + NAV 样式(含 .nav-logout
│ │ └── auth.css # 认证页样式表单、模态框、Toast、.remember-group/.remember-label/.remember-hint
│ └── js/
│ ├── common.js # 通用工具Toast + 登出 + 自动令牌刷新 + mlb_rm 检测)
│ ├── register.js # 注册页逻辑(表单验证/AJAX 提交/准则模态框/倒计时/记住我)
│ └── login.js # 登录页逻辑(表单验证/AJAX 提交/Toast/记住我)
```
### 配置文件
```
config.yaml # 主配置(服务/数据库/JWT 含 remember_expire/BCrypt
.env # 敏感信息(密码/JWT密钥→ .gitignore
.env.example # 敏感信息模板(无真实值)
```
---
## 五、待做事项
按优先级排序
| 优先级 | 功能 | 状态 | 说明 |
|---|---|---|---|
| **P0** | 邮箱验证码 | 待做 | 注册时发送验证码`auth/verify-email`预留了路由和接口 |
| ~~**P0**~~ | ~~登出功能~~ | 已完成 | `POST /api/auth/logout` 清除三个 Cookie + 跳转首页 |
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域需添加帖子列表话题导航等 |
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
| **P1** | 用户设置页 | 已完成 | `/settings/profile``/settings/account` 用户名修改个性签名头像上传与裁切 |
| **P2** | 管理后台 | 待做 | `/admin` 用户管理内容审核主题切换 |
| ~~**P2**~~ | ~~刷新令牌~~ | 已完成 | 记住我 + access/refresh Token + 自动续期 + 登出 |
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` `MetaLab-2027` 切换 |
---
## 六、约定速查
| 类别 | 约定 |
|---|---|
| 数据库主键 | Go: `ID uint`DB: `uid`bigint 自增JSON: `"uid"` |
| Access Token Cookie | `mlb_token`JWT(HS256)15 minHttpOnly + Secure + SameSite=Lax |
| Refresh Token Cookie | `mlb_refresh`JWT(HS256)30 day记住我时HttpOnly + Secure + SameSite=Lax `purpose:"refresh"` 声明 |
| 记住我标记 Cookie | `mlb_rm` `"1"`30 day记住我时 HttpOnlyJS 可读非凭据 |
| 密码哈希 | bcrypt(12)Go 字段 `PasswordHash`JSON 不暴露 |
| 用户名 | 10 `[a-z0-9]``crypto/rand` 生成全局唯一 |
| 角色 | `user` / `moderator` / `admin`string bool |
| 配置 | `.env` `os.Setenv` viper `BindEnv` + `AutomaticEnv` |
| 模板名 | 相对路径`layout/header.html``auth/register.html` |
| API 响应 | `common.Ok(ctx, data)` / `common.Error(ctx, code, msg)` |
| 页面数据 | `common.BuildPageData(c, extra)` 自动注入 `IsLoggedIn` / `Username` |
| 包依赖方向 | Router Controller Service Repository Model不可反向 |
| 令牌刷新 | `POST /api/auth/refresh`前端每 10 min 静默调用 |
| 优先级 | 功能 | 状态 |
|--------|------|------|
| P2 | B8: BaseModel 主键列名不一致 | ✅ 已完成 |
| P3 | 集成测试 | ⬜ 暂缓 |
| P3 | `git tag v0.1.0-rc1` | ⬜ 暂缓 |
| — | 黑名单系统 | ⬜ 未来迭代 |
| — | 首页推荐算法 | ⬜ 未来迭代 |
| — | 模板审计修复 | ⬜ 未来迭代 |

View File

@ -1,231 +0,0 @@
# 帖子系统代码审计报告
> 审计日期2026-05-30
> 审计范围:`internal/` 下所有 Post 相关文件router / controller / service / repository / model
> 审计标准DRY / KISS / YAGNI / LoD / SOLID + 分层架构规范
---
## 一、架构合规性总览
| 检查项 | 状态 | 说明 |
|--------|------|------|
| 分层依赖方向 | ✅ | router→controller→service→repo→model严格单向 |
| Controller 接口文件 | ✅ | `controller/interfaces.go` + `admin/interfaces.go` |
| Service Repository 接口 | ✅ | `service/repository.go`8 个方法,无冗余 |
| ISP 接口隔离 | ✅ | 前台 `postUseCase` 与后台 `adminPostUseCase` 分离 |
| DIP 依赖倒置 | ✅ | Controller→接口, Service→接口 |
| 依赖注入 | ✅ | 全部构造函数注入,无硬编码 |
| KISS | ✅ | 无过度设计 |
| YAGNI | ✅ | 无超前功能 |
| LoD | ✅ | 无跨层直接依赖 |
---
## 二、问题清单
### 🔴 中等问题4 个)
#### 问题 1Repository 方法代码重复(~70%
| 项 | 详情 |
|----|------|
| **文件** | `internal/repository/post_repo.go` |
| **位置** | `FindPageable`(行 49-73vs `FindAdminPageable`(行 76-102 |
| **违反原则** | DRY |
| **描述** | 两个方法的核心查询逻辑Table + Select + Joins + keyword LIKE + Count + Offset/Limit + ORDER BY几乎完全一致唯一区别是 `FindPageable` 固定过滤 `status = 'approved'``FindAdminPageable` 支持可选的 status 参数。两段代码约 70% 重复。 |
**现状:**
```go
// FindPageable (行 49-73)
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL").
Where("posts.status = ?", model.PostStatusApproved) // ← 唯一差异
if keyword != "" { /* LIKE 过滤 */ }
// ... Count + Offset/Limit + Find
}
// FindAdminPageable (行 76-102)
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL")
// ← 无硬编码 status由参数控制
if keyword != "" { /* LIKE 过滤 */ }
if status != "" { query = query.Where("posts.status = ?", status) }
// ... Count + Offset/Limit + Find
}
```
**建议修复:** 提取私有方法 `findPageableCommon`,两个公开方法调用它并传入各自的 WHERE 条件。
---
#### 问题 2Service 方法重复
| 项 | 详情 |
|----|------|
| **文件** | `internal/service/post_service.go` |
| **位置** | `List`(行 148-156vs `ListAdmin`(行 159-167 |
| **违反原则** | DRY |
| **描述** | 两个方法的 nil 检查 + 分页调用模式完全一致,唯一区别是调用的 repo 方法不同。 |
**现状:**
```go
// List (行 148-156)
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
posts, total, err := s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
if posts == nil { posts = []model.Post{} }
return posts, total, err
}
// ListAdmin (行 159-167)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
posts, total, err := s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
if posts == nil { posts = []model.Post{} }
return posts, total, err
}
```
**建议修复:** 提取公共的 Pagination 创建 + nil 检查逻辑。
---
#### 问题 3Controller 权限检查重复 6 处
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/post_controller.go` |
| **位置** | `ShowPage`(行 82-90)、`EditPage`(行 144-150)、`Update`(行 202-211)、`Delete`(行 239-248)、`Submit`(行 272-281)、`ShowAPI`(行 333-339) |
| **违反原则** | DRY |
| **描述** | 以下模式在 6 个方法中逐字重复: |
```go
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
if !model.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return
}
```
**建议修复:** 提取私有方法 `getPostAndCheckAccess(id, c)` 返回 `(*model.Post, bool)`
---
#### 问题 4Model 层职责过重
| 项 | 详情 |
|----|------|
| **文件** | `internal/model/post.go` |
| **位置** | 行 48-83 |
| **违反原则** | SRP单一职责 |
| **描述** | 一个文件混合了多种职责: |
| 内容 | 类型 | 应在位置 |
|------|------|---------|
| `Post` struct | 数据模型 | ✅ Model 层 |
| `PostStatusDraft` 等常量 | 状态常量 | ✅ Model 层 |
| `PostStatusDisplayNames` | 视图映射 | ❌ 应移到 View/Controller 层 |
| `PostListResult` | DTO | ❌ 应移到 `model/dto.go` |
| `PostCreateRequest` / `PostUpdateRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
| `PostRejectRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
| `PostListQuery` | 查询 DTO | ❌ 应移到 `model/dto.go` |
| `IsPostAccessible` | 业务权限逻辑 | ❌ 应移到 Service 层 |
**建议修复:** DTO 结构体移到 `model/dto.go``PostStatusDisplayNames` 移到 common 或 controller`IsPostAccessible` 移到 service 层或独立权限模块。
---
### 🟡 轻微问题3 个)
#### 问题 5工具函数位置不当
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/post_controller.go` |
| **位置** | `saveUploadedFile`(行 410-430 |
| **违反原则** | SRP / LoD |
| **描述** | `saveUploadedFile` 是通用文件 I/O 工具函数(创建目录 + 32KB buffer 循环写入),与 HTTP 处理无关,不依赖 controller 的任何字段。放在 controller 文件中职责不匹配。 |
**建议修复:** 移到 `internal/common/` 或新建 `internal/util/` 包。
---
#### 问题 6Controller 层分页重复初始化
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/post_controller.go` |
| **位置** | `ListPage`(行 47-49`ListAPI`(行 307-308 |
| **违反原则** | DRY |
| **描述** | Controller 层为模板渲染再次创建 `Pagination` 对象并调用 `DefaultPagination()`,而 Service 层 `List()` / `ListAdmin()` 内部已经做过一次分页参数校验。Controller 层可以信任 Service 返回的 total 直接计算。 |
```go
// controller 层重复的:
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
// ... 然后调用 service.List()service 里又做了一遍
```
**建议修复:** Controller 层直接用 `common.Pagination.PageCount(total, pageSize)` 计算总页数,不再重复调用 `DefaultPagination()`
---
#### 问题 7Admin Restore 错误分类缺失
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/admin/admin_post_controller.go` |
| **位置** | `Restore`(行 153-155 |
| **违反原则** | 一致性 |
| **描述** | 同文件内其他方法(`Approve`/`Reject`/`Unlock`/`Lock`)都对 `ErrPostNotFound``errors.Is` 精确匹配返回 400`Restore` 没有,所有错误统一返回 500。 |
```go
// Restore — 缺少错误分类
func (ctrl *AdminPostController) Restore(c *gin.Context) {
// ...
if err := ctrl.postService.Restore(id); err != nil {
// ← 此处应匹配 ErrPostNotFound返回 400 而非 500
common.Error(c, http.StatusInternalServerError, err.Error())
return
}
}
```
**建议修复:** 添加 `errors.Is(err, common.ErrPostNotFound)` 判断,返回 400。
---
## 三、亮点
1. **状态机设计清晰**Post 状态流转draft → pending → approved/rejected → locked每个转换有明确前置条件检查和专用错误哨兵
2. **接口设计优秀**`postUseCase` vs `adminPostUseCase` 的分离体现良好的关注点分离
3. **错误哨兵统一管理**`common/errors.go` 集中定义所有 Post 相关错误
4. **审核开关灵活**:通过 `SiteSettings.IsAuditEnabled()` 运行时控制
5. **Shortcode 扩展性好**:新增类型只需添加常量和 case 分支
6. **软删除 + 恢复**:完善的软删除和恢复机制
---
## 四、优先级建议
| 优先级 | 问题编号 | 原因 |
|--------|---------|------|
| P0 | — | 无阻塞性问题 |
| P1 | 1, 3 | Repository/Controller 重复影响维护成本 |
| P2 | 2, 4 | Service 重复 + Model 职责拆分 |
| P3 | 5, 6, 7 | 轻微优化项 |

View File

@ -1,319 +0,0 @@
# Vditor 整合 + MD 存储迁移方案
> **状态:已实施 ✓** `2026-05-30`
## 决策与结果
| 决策 | 结论 | 结果 |
|------|------|------|
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist5.8MB(仅必需插件) |
---
## 最终数据流
```
编辑器: Vditor(wysiwyg) → vditor.getValue() → MD 纯文本
存储: 后端直接存 MD纯文本无 XSS 风险,无需 sanitize
└─ generateExcerpt() 生成 plain text 摘要
显示: Vditor.preview() 客户端 MD → HTML + github-dark 代码主题
```
---
## 变更文件清单11 个)
| 文件 | 改动 | 详情 |
|------|------|------|
| `internal/model/post.go` | 修改 | `Body` → MD 存储,去 `BodyHTML`,加 `Excerpt`varchar 500 |
| `internal/service/post_service.go` | 重写 | 去 bluemonday`generateExcerpt()`(纯 Go regex无外部依赖 |
| `internal/controller/post_controller.go` | 修改 | `UploadImage` → Vditor 原生格式,`ShowPage` 去 BodyHTML`html/template` import |
| `internal/common/response.go` | 新增函数 | `VditorUploadOk()` — Vditor 图片上传成功响应 |
| `templates/.../posts/new.html` | 重写 | Tiptap → Vditor去除 importmap/工具栏/语言选择器 |
| `templates/.../posts/show.html` | 重写 | Vditor 客户端 MD 渲染,去 highlight.js CDN去代码标签注入脚本 |
| `templates/.../posts/index.html` | 微调 | `Body` 截断 → `Excerpt`(带降级回退) |
| `templates/.../editor.js` | 重写 | Vditor 初始化 + CSRF + upload + draft + word count + submit |
| `templates/.../posts.css` | 删减 | 去 Tiptap 样式(~150 行)+ 工具栏样式(~40 行),加 Vditor 微调 |
| `static/vditor/dist/` | 新增 | 本地 Vditor 文件5.8MB |
| `go.mod / go.sum` | 清理 | 移除 bluemonday 依赖 |
**不涉及的路由/中间件/仓储:零改动。**
---
## 各层详细实现
### 1. Model `internal/model/post.go`
```go
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` // Markdown content
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary
// ... 其余字段不变
}
```
### 2. Service `internal/service/post_service.go`
- 删除 `sanitizePolicy`bluemonday UGC`sanitizeHTML()`
- 新增 `generateExcerpt(md string) string`
- 按行逐条去掉 MD 语法(代码块、图片、标题 #、粗体/斜体、引用 >、列表标记等)
- 链接保留文字 `[text](url)``text`
- 按 rune 截断 300 字符,末尾加 `...`
- 纯 Go `regexp` 实现,零外部依赖
- `Create()` / `Update()` 生成 `Excerpt`MD 直存无消毒
### 3. Controller `internal/controller/post_controller.go`
**UploadImage — Vditor 原生响应格式:**
```json
{
"code": 0,
"msg": "",
"data": {
"errFiles": [],
"succMap": {
"微信截图_2026.png": "/uploads/posts/1_1717071234567.png"
}
}
}
```
**ShowPage — 去掉 `PostBodyHTML`**MD 由前端渲染。
### 4. Common `internal/common/response.go`
```go
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"data": gin.H{
"errFiles": []string{},
"succMap": succMap,
},
})
}
```
### 5. 编辑器 `posts/new.html` + `editor.js`
**Vditor 初始化关键配置:**
```javascript
new Vditor('vditor', {
mode: 'wysiwyg',
cdn: '/static/vditor', // 所有动态加载走本地
height: '100%',
lang: 'zh_CN',
toolbar: [
'headings', 'bold', 'italic', 'strike', '|',
'line', 'code', 'inline-code', 'link', 'quote', '|',
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
'upload', 'table', '|',
'undo', 'redo', '|',
'fullscreen', 'code-theme', '|',
'outline', 'preview', 'devtools',
],
upload: {
url: '/api/posts/upload-image',
fieldName: 'file',
max: 5 * 1024 * 1024,
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
setHeaders() { // 每次请求重新读取 CSRF
const meta = document.querySelector('meta[name="csrf-token"]');
return { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' };
},
},
preview: {
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
},
})
```
**CSRF 处理**`upload.setHeaders` 为函数,每次上传前动态读取 `meta[name="csrf-token"]`,不会因 token 过期而失败。
**草稿机制**
- 每 2 秒自动保存 `title` + `md``localStorage`
- 键名:`draft_post_title` / `draft_post_body_md`(与旧 Tiptap HTML 草稿隔离)
- 新帖模式下自动恢复草稿
**表单提交**`vditor.getValue()` 获取 MD`POST /api/posts``PUT /api/posts/:id`
### 6. 详情页 `posts/show.html`
```html
<!-- MD 内容通过隐藏 textarea 安全传递给 JS -->
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
<!-- Vditor 仅需 method.min.js47KB不需要完整编辑器 -->
<script src="/static/vditor/dist/method.min.js"></script>
<script>
Vditor.preview(document.getElementById('postContent'), md, {
cdn: '/static/vditor',
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
});
</script>
```
### 7. 列表页 `posts/index.html`
```html
<!-- Excerpt 优先,降级回退 Body 截断(兼容旧数据) -->
<p class="post-card-summary">
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
</p>
```
### 8. 样式 `posts.css`
- 移除:`.tiptap` 及所有子选择器(~100 行、highlight.js 硬编码主题色(~35 行)、手动工具栏样式(~40 行、JS 注入代码标签 CSS
- 保留:`.editor-layout` / `.editor-header` / `.editor-main` / `.editor-pane` / `.editor-statusbar`(布局)
- 新增:`#vditor` flex 适配、`.vditor-toolbar` / `.vditor-content` 主题微调
---
## Vditor 本地文件结构
```
templates/MetaLab-2026/static/vditor/dist/
├── index.css # 43KB 编辑器 + 预览全部样式
├── index.min.js # 292KB 编辑器核心wysiwyg/sv/ir + 工具栏 + 上传)
├── method.min.js # 47KB 独立方法Vditor.preview 等,详情页用)
├── css/content-theme/
│ ├── light.css # 内容区明亮主题
│ ├── dark.css # 内容区暗色主题
│ ├── ant-design.css # Ant Design 主题
│ └── wechat.css # 微信主题
├── images/
│ ├── img-loading.svg # 上传进度指示
│ ├── logo.png # Vditor logo
│ └── emoji/ # 内置表情包b3log/octocat/doge 等)
├── js/
│ ├── highlight.js/
│ │ ├── highlight.min.js # 1.4MB 代码高亮核心
│ │ ├── third-languages.js # 额外语言支持
│ │ └── styles/*.min.css # 60+ 代码主题
│ ├── lute/
│ │ └── lute.min.js # 3.9MB WASM MD 解析器Vditor 核心依赖)
│ ├── i18n/
│ │ └── zh_CN.js # 8KB 中文语言包
│ └── icons/
│ ├── material.js # Material Design 图标
│ └── ant.js # Ant Design 图标
```
**已剔除的插件**(节省 16MB+
| 插件 | 大小 | 功能 | 对技术论坛无用 |
|------|------|------|---------------|
| mathjax | 6.5MB | LaTeX 数学公式 | ❌ |
| mermaid | 2.6MB | 流程图/时序图 | ❌ |
| graphviz | 2.0MB | 图谱渲染 | ❌ |
| katex | 1.5MB | 数学公式引擎 | ❌ |
| echarts | 1.0MB | 图表渲染 | ❌ |
| markmap | 836KB | 思维导图 | ❌ |
| abcjs | 356KB | 五线谱 | ❌ |
| plantuml | 32KB | PlantUML | ❌ |
| flowchart.js / smiles-drawer | ~400KB | 流程图 / 化学分子式 | ❌ |
---
## Shortcode 扩展(`[zone:type:params]`
### 语法
在 Markdown 正文中嵌入特殊卡片,语法为 `[zone:类型:参数]`
```markdown
# 周末活动汇总
下面是我们本周的推荐活动:
[zone:event:summer2026]
更多内容请关注官方动态...
```
### 已注册类型
| 语法 | 渲染结果 | 状态 |
|------|---------|------|
| `[zone:event:活动ID]` | 活动卡片(🎪 图标 + ID + 跳转链接) | ✅ 已实现(占位) |
| `[zone:game:游戏slug]` | 游戏卡片(🎮 图标 + slug + 跳转链接) | ✅ 已实现(占位) |
| `[zone:poll:投票ID]` | 投票组件 | ⏳ 预留 |
| `[zone:resource:资源ID]` | 资源推荐卡片 | ⏳ 预留 |
### 架构
```
MD body "[zone:event:summer2026]"
ShortcodeService.Process() ← 后端:正则替换 [zone:...] → 占位 <div>
VDitor.preview() ← 前端MD → HTML占位 div 原样保留
renderShortcodes() ← 前端 shortcode.js扫描 .zone-card渲染 UI 卡片
```
### 文件清单
| 文件 | 职责 |
|------|------|
| `internal/model/shortcode.go` | 类型常量 + 正则 + DTO |
| `internal/service/shortcode_service.go` | 解析器 + 占位 HTML 生成器 |
| `internal/controller/post_controller.go` | ShowPage/ShowAPI 调用 Process() |
| `internal/router/deps_extra.go` | DI 注入 ShortcodeService |
| `templates/.../js/shortcode.js` | 前端卡片渲染器 |
| `templates/.../html/posts/show.html` | 引入 shortcode.js + 调用 renderShortcodes() |
| `templates/.../static/css/posts.css` | 卡片样式 |
| `templates/.../static/js/editor.js` | hint 自动补全提示 |
### 如何添加新类型
1. `internal/model/shortcode.go`:注册 `ShortcodeType` 常量
2. `internal/service/shortcode_service.go`:在 `renderPlaceholder()` 添加 case
3. `templates/.../js/shortcode.js`:在 `cardRenderers` 注册渲染函数
4. `templates/.../static/js/editor.js`:在 `hint.extend` 添加补全提示
### 注意事项
- 未注册或格式错误的 shortcode **不报错**,原文保留,避免破坏用户内容
- 占位 div 结构为 `<div class="zone-card" data-zone-type="..." data-zone-id="...">`
- 编辑器输入 `[zone:` 时自动弹出补全列表Vditor hint.extend
- 后端 API 就绪后,将 `shortcode.js` 中的静态卡片替换为 `fetch()` 动态数据即可
---
## 不涉及的部分
- `internal/router/api.go` — 路由不变(`POST /api/posts/upload-image` 端点不变)
- `internal/router/frontend.go` — SSR 页面路由不变
- `internal/repository/post_repo.go` — 仓储不变Model 字段变更由 GORM 自动映射)
- `internal/middleware/` — CSRF / Auth 中间件不变
- `internal/config/` — 配置不变
- `cmd/server/main.go``Static()` 映射不变(`/static``templates/.../static`
---
## 注意事项
1. **DB Schema**GORM AutoMigrate 自动添加 `excerpt` 列;旧的 `body_html` 列残留但不影响运行GORM 不会 DROP可手动清理
2. **已有帖子**:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
3. **CSRF**`upload.setHeaders` 为函数,每次上传前动态读取最新 token不受 token 过期影响
4. **时序**Vditor 主文件先加载lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
5. **bluemonday**:已从 `go.mod` 中移除(`go mod tidy` 自动清理)

View File

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

View File

@ -1,6 +1,11 @@
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
package common
import "github.com/gin-gonic/gin"
import (
"strconv"
"github.com/gin-gonic/gin"
)
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
// 如果用户未登录ok 返回 falserole 可能为空字符串
@ -18,3 +23,28 @@ func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
}
return
}
// GetGinExp 从 Gin context 中提取当前用户的经验值
// 如果用户未登录或 exp 不存在ok 返回 false
func GetGinExp(c *gin.Context) (exp int, ok bool) {
if v, exists := c.Get("exp"); exists {
if e, isInt := v.(int); isInt {
return e, true
}
}
return 0, false
}
// ParseUIDParam 从 URL 路径参数中提取 uint 类型的 UID
// 统一 UID 解析逻辑,避免各 Controller 重复 strconv.ParseUint
func ParseUIDParam(c *gin.Context, paramName string) (uint, error) {
s := c.Param(paramName)
if s == "" {
return 0, ErrInvalidParam
}
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, ErrInvalidParam
}
return uint(v), nil
}

View File

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

View File

@ -58,4 +58,7 @@ var (
// 评论相关
ErrCommentNotFound = errors.New("评论不存在")
ErrCommentEmpty = errors.New("评论内容不能为空")
// 通用参数校验
ErrInvalidParam = errors.New("参数无效")
)

View File

@ -1,29 +0,0 @@
package common
import (
"mime/multipart"
"os"
)
// SaveUploadedFile 将 multipart.File 内容写入目标路径
func SaveUploadedFile(file multipart.File, dst string) error {
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
}
if err != nil {
break
}
}
return nil
}

View File

@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"metazone.cc/mce/internal/model"
@ -19,23 +20,32 @@ import (
)
// assetHashes 静态资源文件哈希表URL 路径 → 短哈希,文件不变哈希不变)
var assetHashes map[string]string
var (
assetHashes map[string]string
assetMu sync.RWMutex
)
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
// 构建本地 map 后原子替换,避免并发读写冲突
func ComputeAssetHashes(templateDir string) {
assetHashes = make(map[string]string)
mu := make(map[string]string)
// 前端主题templates/MetaLab-2026/static → URL /static/
walkStaticDir(filepath.Join(templateDir, "static"), "/static")
walkStaticDirInto(mu, filepath.Join(templateDir, "static"), "/static")
// 共享资源templates/shared/static → URL /shared/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
// 管理后台templates/admin/static → URL /admin/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
assetMu.Lock()
assetHashes = mu
assetMu.Unlock()
log.Printf("[AssetHashes] computed %d file hashes", len(mu))
}
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32存入 assetHashes
func walkStaticDir(dir, urlPrefix string) {
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
// walkStaticDirInto 遍历目录,计算每个 .js/.css 的 CRC32存入目标 map
func walkStaticDirInto(dst map[string]string, dir, urlPrefix string) {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
@ -43,7 +53,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
}
@ -52,14 +62,17 @@ func walkStaticDir(dir, urlPrefix string) {
// 生成 URL 路径urlPrefix + 相对于 dir 的子路径
rel, _ := filepath.Rel(dir, path)
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
assetHashes[urlPath] = shortHash
dst[urlPath] = shortHash
return nil
})
}
// AssetV 返回指定静态资源的哈希版本号(模板函数)
// AssetV 返回指定静态资源的哈希版本号(模板函数,并发安全
func AssetV(path string) string {
if h, ok := assetHashes[path]; ok {
assetMu.RLock()
h, ok := assetHashes[path]
assetMu.RUnlock()
if ok {
return h
}
return "0"

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
// Package config 管理应用配置的加载、解析和运行时访问。
package config
import (
@ -32,6 +33,7 @@ type ServerConfig struct {
Port string `mapstructure:"port"`
Mode string `mapstructure:"mode"`
CookieSecure bool `mapstructure:"cookie_secure"`
Timezone string `mapstructure:"timezone"`
}
type DatabaseConfig struct {
@ -75,7 +77,7 @@ type RolesPermissions struct {
OperableRoles map[string][]string `mapstructure:"operable_roles"`
}
// 全局配置实例(初始化后只读)
// App 全局配置实例(初始化后只读)
var App *Config
// Load 加载配置config.yaml → .env 覆盖
@ -111,11 +113,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,15 +132,29 @@ 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)
}
}
}
// bindEnvOverride 将环境变量映射到 config 的嵌套键
// bindEnvOverride 将环境变量映射到 config 的嵌套键.env 覆盖 config.yaml 默认值)
func bindEnvOverride(v *viper.Viper) {
// 数据库
_ = v.BindEnv("database.host", "DATABASE_HOST")
_ = v.BindEnv("database.port", "DATABASE_PORT")
_ = v.BindEnv("database.user", "DATABASE_USER")
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
_ = v.BindEnv("database.dbname", "DATABASE_DBNAME")
_ = v.BindEnv("database.sslmode", "DATABASE_SSLMODE")
// Redis
_ = v.BindEnv("redis.enabled", "REDIS_ENABLED")
_ = v.BindEnv("redis.host", "REDIS_HOST")
_ = v.BindEnv("redis.port", "REDIS_PORT")
_ = v.BindEnv("redis.password", "REDIS_PASSWORD")
_ = v.BindEnv("redis.db", "REDIS_DB")
// 服务
_ = v.BindEnv("server.port", "SERVER_PORT")
_ = v.BindEnv("server.mode", "SERVER_MODE")
}
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口

View File

@ -1,12 +1,14 @@
package config
import (
"log"
"strconv"
"sync"
"metazone.cc/mce/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SiteSettings 站点设置管理器DB 持久化 + 内存缓存,不重启生效)
@ -69,15 +71,22 @@ func (s *SiteSettings) GetBool(key string, defaultVal bool) bool {
return b
}
// Set 写入设置(同步写 DB + 更新内存)
// Set 写入设置(同步写 DB + 更新内存,使用 UPSERT 兼容首次保存
func (s *SiteSettings) Set(key, value string) error {
entry := model.SiteSetting{Key: key, Value: value}
if err := s.db.Save(&entry).Error; err != nil {
return err
result := s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&entry)
if result.Error != nil {
log.Printf("[SiteSettings] Set FAIL key=%s value=%s err=%v", key, value, result.Error)
return result.Error
}
s.mu.Lock()
oldVal, existed := s.data[key]
s.data[key] = value
s.mu.Unlock()
log.Printf("[SiteSettings] Set OK key=%s old=%q new=%q existed=%v rows=%d", key, oldVal, value, existed, result.RowsAffected)
return nil
}
@ -190,3 +199,8 @@ func (s *SiteSettings) GuidelinesTimer() int {
func (s *SiteSettings) IsMaintenanceEnabled() bool {
return s.GetBool("maintenance.enabled", false)
}
// ThemeName 当前选用的主题目录名,默认 "MetaLab-2026"
func (s *SiteSettings) ThemeName() string {
return s.Get("site.theme", "MetaLab-2026")
}

View File

@ -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+"成功")
@ -131,23 +134,18 @@ func (ac *AdminController) ResetToken(c *gin.Context) {
// parseUIDParam 从 URL 路径参数解析 uid
func parseUIDParam(c *gin.Context) (uint, error) {
uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
return 0, err
}
return uint(uid), nil
return common.ParseUIDParam(c, "uid")
}
// 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, "操作失败")
}
}

View File

@ -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
}

View File

@ -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, "操作失败")
}
}

View File

@ -23,12 +23,13 @@ type auditUseCase interface {
Reject(reviewerID uint, submissionID uint, reason string) error
}
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖ISP4 个方法)
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖ISP5 个方法)
type siteSettingUseCase interface {
GetSettings() map[string]string
UpdateSetting(key, value string) error
UpdateBoolSetting(key string, value bool) error
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
UpdateTheme(themeName string, reload service.ThemeReloader) error
}
// auditStatusProvider 审核控制器所需的用户信息查询ISP

View File

@ -1,11 +1,13 @@
package admin
import (
"log"
"net/http"
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/service"
"metazone.cc/mce/internal/theme"
"github.com/gin-gonic/gin"
)
@ -14,6 +16,7 @@ import (
type SiteSettingController struct {
settingService siteSettingUseCase
defaults *service.AuditDefaults
themeReload service.ThemeReloader // 主题重载回调(切换后实时生效)
}
// NewSiteSettingController 构造函数
@ -21,12 +24,63 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
return &SiteSettingController{settingService: settingService, defaults: defaults}
}
// SiteSettingsPage 站点设置管理页面
// SetThemeReloader 注入主题重载回调main.go 中设置)
func (sc *SiteSettingController) SetThemeReloader(reload service.ThemeReloader) {
sc.themeReload = reload
}
// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置",
c.Redirect(http.StatusMovedPermanently, "/admin/settings/brand")
}
// BrandPage 品牌与合规设置页
func (sc *SiteSettingController) BrandPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/brand.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置 - 品牌与合规",
"ExtraCSS": "/admin/static/css/site-settings.css",
"ExtraJS": "/admin/static/js/site-settings.js",
"ActiveSetting": "brand",
}))
}
// SecurityPage 安全与维护设置页
func (sc *SiteSettingController) SecurityPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/security.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置 - 安全与维护",
"ExtraCSS": "/admin/static/css/site-settings.css",
"ExtraJS": "/admin/static/js/site-settings.js",
"ActiveSetting": "security",
}))
}
// RegistrationPage 注册策略设置页
func (sc *SiteSettingController) RegistrationPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/registration.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置 - 注册策略",
"ExtraCSS": "/admin/static/css/site-settings.css",
"ExtraJS": "/admin/static/js/site-settings.js",
"ActiveSetting": "registration",
}))
}
// ContentPage 审核与内容设置页
func (sc *SiteSettingController) ContentPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/content.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置 - 审核与内容",
"ExtraCSS": "/admin/static/css/site-settings.css",
"ExtraJS": "/admin/static/js/site-settings.js",
"ActiveSetting": "content",
}))
}
// ThemePage 外观主题设置页
func (sc *SiteSettingController) ThemePage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/site-settings/theme.html", common.BuildAdminPageData(c, gin.H{
"Title": "站点设置 - 外观主题",
"ExtraCSS": "/admin/static/css/site-settings.css",
"ExtraJS": "/admin/static/js/site-settings-theme.js",
"ActiveSetting": "theme",
}))
}
@ -34,6 +88,10 @@ func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
all := sc.settingService.GetSettings()
audit := sc.settingService.GetAuditSettings(sc.defaults)
log.Printf("[GetSettings] all=%d keys audit=%+v", len(all), audit)
if v, ok := all["registration.enabled"]; ok {
log.Printf("[GetSettings] registration.enabled=%q", v)
}
common.Ok(c, gin.H{
"all": all,
"audit": audit,
@ -110,3 +168,60 @@ func (sc *SiteSettingController) GetBoolSetting(c *gin.Context) {
common.Ok(c, gin.H{"value": d})
}
}
// GetThemes 获取所有可用主题列表
func (sc *SiteSettingController) GetThemes(c *gin.Context) {
themes, err := theme.DiscoverThemes("templates")
if err != nil {
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
return
}
if themes == nil {
themes = []theme.ThemeInfo{}
}
// 读取当前主题
all := sc.settingService.GetSettings()
currentTheme := "MetaLab-2026"
if t, ok := all["site.theme"]; ok && t != "" {
currentTheme = t
}
common.Ok(c, gin.H{
"themes": themes,
"current": currentTheme,
})
}
// UpdateTheme 切换主题
func (sc *SiteSettingController) UpdateTheme(c *gin.Context) {
var req struct {
Theme string `json:"theme" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
// 验证主题是否存在
themes, err := theme.DiscoverThemes("templates")
if err != nil {
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
return
}
found := false
for _, t := range themes {
if t.Dir == req.Theme {
found = true
break
}
}
if !found {
common.Error(c, http.StatusBadRequest, "主题不存在")
return
}
if err := sc.settingService.UpdateTheme(req.Theme, sc.themeReload); err != nil {
common.Error(c, http.StatusInternalServerError, "主题切换失败")
return
}
common.OkMessage(c, "主题已切换")
}

View File

@ -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

View File

@ -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

View File

@ -37,9 +37,9 @@ 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")
content, err := theme.LoadContent("templates/" + ac.siteSettings.ThemeName() + "/guidelines.html")
if err != nil {
c.String(http.StatusInternalServerError, "加载准则失败")
return
@ -48,10 +48,12 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
}
// 替换准则中的硬编码站点名和邮箱
siteInfo := ac.siteSettings.SiteInfo()
guidelines = template.HTML(strings.ReplaceAll(
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",

View File

@ -28,12 +28,11 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
}
// LV4+ 检查Exp ≥ 1500
expVal, exists := c.Get("exp")
if !exists {
exp, ok := common.GetGinExp(c)
if !ok {
common.Error(c, http.StatusForbidden, "无法获取经验值")
return
}
exp := expVal.(int)
if exp < 1500 {
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
return
@ -44,7 +43,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 +66,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 +87,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 +100,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
}

View File

@ -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

View File

@ -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
}

View File

@ -29,13 +29,13 @@ func (fc *FollowController) Toggle(c *gin.Context) {
return
}
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
isFollowing, err := fc.followSvc.Toggle(uid, targetUID)
if err != nil {
if err.Error() == "不能关注自己" {
common.Error(c, http.StatusBadRequest, err.Error())
@ -46,7 +46,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
}
// 获取最新状态
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
status, _ := fc.followSvc.GetStatus(uid, targetUID)
common.Ok(c, gin.H{
"is_following": isFollowing,
@ -56,7 +56,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
func (fc *FollowController) GetStatus(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@ -64,7 +64,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
status, err := fc.followSvc.GetStatus(uid, targetUID)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
@ -75,7 +75,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
// Followers 粉丝列表 GET /api/users/:uid/followers
func (fc *FollowController) Followers(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@ -86,7 +86,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
uid, _, _ := common.GetGinUser(c) // 允许未登录
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
result, err := fc.followSvc.ListFollowers(targetUID, uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
@ -97,7 +97,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
// Following 关注列表 GET /api/users/:uid/following
func (fc *FollowController) Following(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@ -108,7 +108,7 @@ func (fc *FollowController) Following(c *gin.Context) {
uid, _, _ := common.GetGinUser(c)
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
result, err := fc.followSvc.ListFollowing(targetUID, uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
@ -129,7 +129,7 @@ func NewFollowPageController(followSvc followUseCase) *FollowPageController {
// FollowersPage 粉丝列表页面
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@ -138,7 +138,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
result, err := fpc.followSvc.ListFollowers(targetUID, uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}
@ -159,7 +159,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
// FollowingPage 关注列表页面
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
targetUID, err := common.ParseUIDParam(c, "uid")
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
@ -168,7 +168,7 @@ func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
result, err := fpc.followSvc.ListFollowing(targetUID, uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}

View File

@ -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 的最小依赖ISP8 个方法)
// postUseCase PostController 对 PostService 的最小依赖ISP6 个方法)
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 的依赖ISP4 个方法)
type energyAdminUseCase interface {
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
GetFundBalance() (int, error)
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
}
// commentUseCase CommentController 对 CommentService 的最小依赖ISP6 个方法)
type commentUseCase interface {
CreateRoot(userID, postID uint, body string) (*model.Comment, error)

View File

@ -34,7 +34,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
common.Error(c, http.StatusBadRequest, "请选择文件")
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
}

View File

@ -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"))

View File

@ -68,6 +68,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
data := gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ExtraJS": "/static/js/settings-" + tab + ".js",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
@ -113,5 +114,22 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
data["CurrentSID"] = currentSID
}
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
// 按 tab 渲染独立模板
tplName := "settings/index.html"
switch tab {
case "profile":
tplName = "settings/profile.html"
case "account":
tplName = "settings/account.html"
case "sessions":
tplName = "settings/sessions.html"
case "energy":
tplName = "settings/energy.html"
case "favorites":
tplName = "settings/favorites.html"
case "notify":
tplName = "settings/notify.html"
}
c.HTML(http.StatusOK, tplName, common.BuildPageData(c, data))
}

View File

@ -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, "提交审核失败")
}
}

View File

@ -73,9 +73,13 @@ 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))

View File

@ -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
}

View File

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

View File

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

View File

@ -0,0 +1,84 @@
package middleware
import (
"log"
"net/http"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// dbHealthy 全局数据库健康状态(原子读写,默认为健康)
var dbHealthy atomic.Bool
func init() {
dbHealthy.Store(true)
}
// StartDBHealthCheck 启动定时 DB 健康检测(在 main goroutine 中调用)
// 每 5 秒 ping 一次,失败则标记不健康,恢复则重新标记健康
func StartDBHealthCheck(db *gorm.DB) {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
sqlDB, err := db.DB()
if err != nil {
dbHealthy.Store(false)
log.Printf("[DBHealth] 获取底层连接失败: %v", err)
continue
}
if err := sqlDB.Ping(); err != nil {
if dbHealthy.Swap(false) {
log.Printf("[DBHealth] 数据库不可达,整站进入降级模式: %v", err)
}
} else {
if !dbHealthy.Swap(true) {
log.Println("[DBHealth] 数据库已恢复,退出降级模式")
}
}
}
}()
}
// DBHealthGuard 数据库健康检查中间件
// 检测到 DB 不可达时,返回固定维护页面,避免暴露内部堆栈信息
func DBHealthGuard() gin.HandlerFunc {
return func(c *gin.Context) {
if dbHealthy.Load() {
c.Next()
return
}
path := c.Request.URL.Path
// 静态资源和 API 直接返回错误,不渲染页面
if isStaticPath(path) {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"message": "服务暂时不可用,请稍后重试",
})
return
}
// 页面请求返回友好 HTML
c.AbortWithStatus(http.StatusServiceUnavailable)
c.Header("Content-Type", "text/html; charset=utf-8")
_, _ = c.Writer.WriteString(`<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>服务暂时不可用</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
div{text-align:center;padding:2rem} h1{color:#333} p{color:#666}</style></head>
<body><div><h1>服务暂时不可用</h1><p>数据库连接异常,请稍后刷新页面重试</p></div></body></html>`)
}
}
// isStaticPath 判断是否为静态资源或 API 路径
func isStaticPath(path string) bool {
for _, prefix := range []string{"/static/", "/shared/", "/admin/static/", "/api/"} {
if len(path) >= len(prefix) && path[:len(prefix)] == prefix {
return true
}
}
return false
}

View File

@ -3,8 +3,7 @@ package middleware
import (
"crypto/rand"
"encoding/base64"
"metazone.cc/mce/internal/config"
"fmt"
"github.com/gin-gonic/gin"
)
@ -28,12 +27,13 @@ func SecurityHeaders() gin.HandlerFunc {
c.Set("csp_nonce", nonce)
// Content-Security-Policy
// script-src/style-src: 本站 + nonce替代 unsafe-inline
// script-src: 本站 + nonce替代 unsafe-inline,保留 unsafe-eval 供 Vditor 使用
// style-src: 本站(所有内联样式已使用 nonce
// img-src: 本站 + data: URI头像裁切+ blob:(粘贴图片)
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline'; "+
fmt.Sprintf("script-src 'self' 'unsafe-eval' 'nonce-%s'; ", nonce)+
"style-src 'self' 'nonce-"+nonce+"'; "+
"img-src 'self' data: blob:; "+
"connect-src 'self'")
@ -46,10 +46,8 @@ func SecurityHeaders() gin.HandlerFunc {
// 引用策略
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// HSTS仅在 release 模式启用,避免开发环境 localhost 证书问题
if config.App != nil && config.App.Server.Mode == "release" {
// HSTS始终启用max-age=1年含子域名允许 preload 列表收录)
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
c.Next()
}

View File

@ -4,7 +4,8 @@ import "time"
// Announcement 公告
type Announcement struct {
ID uint `gorm:"primarykey" json:"id"`
BaseModel
Title string `gorm:"type:varchar(200);not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Type string `gorm:"type:varchar(20);default:info" json:"type"` // info / warning / success / error
@ -12,6 +13,4 @@ type Announcement struct {
StartAt *time.Time `json:"start_at,omitempty"`
EndAt *time.Time `json:"end_at,omitempty"`
Sort int `gorm:"default:0" json:"sort"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

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

View File

@ -1,17 +1,14 @@
package model
import "time"
// Category 文章分类(两级树形结构)
type Category struct {
ID uint `gorm:"primarykey" json:"id"`
BaseModel
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"`

View File

@ -4,7 +4,8 @@ import "time"
// Comment 评论模型
type Comment struct {
ID uint `gorm:"primarykey" json:"id"`
BaseModel
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"` // 父评论IDNULL=顶级评论
@ -14,8 +15,6 @@ type Comment struct {
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"`
@ -26,7 +25,6 @@ type Comment struct {
// RepliesCount 回复数非DB字段查询填充
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}非DB字段批量查询填充
// key=创建时的原始@用户名value={uid,当前显示名},用户改名后链接仍有效且显示新名
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
}

View File

@ -1,10 +1,7 @@
package model
import "time"
// CommunityFund 公户余额单行表id=1
type CommunityFund struct {
ID uint `gorm:"primaryKey" json:"id"`
ID uint `gorm:"primarykey" json:"id"`
Balance int `gorm:"not null;default:0" json:"balance"` // 余额×10允许负数
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -1,17 +1,16 @@
package model
import "time"
// time imported by common.go
// Folder 收藏夹
type Folder struct {
ID uint `gorm:"primarykey" json:"id"`
BaseModel
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"type:varchar(50);not null" json:"name"`
Description string `gorm:"type:varchar(200);default:''" json:"description"`
IsPublic bool `gorm:"default:false" json:"is_public"`
IsDefault bool `gorm:"default:false" json:"is_default"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 联表查询填充(非数据库字段)
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
@ -19,11 +18,11 @@ type Folder struct {
// FolderItem 收藏记录
type FolderItem struct {
ID uint `gorm:"primarykey" json:"id"`
BaseModel
FolderID uint `gorm:"index;not null" json:"folder_id"`
PostID uint `gorm:"index;not null" json:"post_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
// 联表查询填充
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`

View File

@ -1,14 +1,11 @@
package model
import (
"time"
"gorm.io/gorm"
)
import "time"
// Post 帖子/文章模型
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
BaseModel
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
@ -38,13 +35,8 @@ type Post struct {
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充)
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
// 从 JOIN 查询的 users.username AS author_name 别名中读取
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
}

View File

@ -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"`
}

View File

@ -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 从配置初始化角色系统(唯一数据源)
// 必须在服务启动前调用,不提供默认值

View File

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

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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}

View File

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

View File

@ -28,6 +28,16 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/settings/brand", d.siteSettingCtrl.BrandPage,
middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/settings/security", d.siteSettingCtrl.SecurityPage,
middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/settings/registration", d.siteSettingCtrl.RegistrationPage,
middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/settings/content", d.siteSettingCtrl.ContentPage,
middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/settings/theme", d.siteSettingCtrl.ThemePage,
middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
@ -62,6 +72,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
siteSettingsAPI.PUT("", d.siteSettingCtrl.UpdateSetting)
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
siteSettingsAPI.GET("/themes", d.siteSettingCtrl.GetThemes)
siteSettingsAPI.PUT("/theme", d.siteSettingCtrl.UpdateTheme)
}
// --- 帖子管理 APIadmin+ ---

View File

@ -2,197 +2,17 @@ package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupAPIRoutes 注册 API 路由
// setupAPIRoutes 按域分发 API 路由注册
func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 设置页 API需登录 ---
settingsAPI := r.Group("/api/settings")
settingsAPI.Use(d.authMdw.Required())
settingsAPI.Use(d.authMdw.BannedWriteGuard())
settingsAPI.Use(middleware.CSRF(cfg))
{
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
// 登录管理
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
// 通知偏好
settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
}
// --- 消息中心 API需登录 ---
messagesAPI := r.Group("/api/messages")
messagesAPI.Use(d.authMdw.Required())
messagesAPI.Use(d.authMdw.BannedWriteGuard())
messagesAPI.Use(middleware.CSRF(cfg))
{
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead)
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
}
// --- 积分/等级 API需登录 ---
levelAPI := r.Group("/api/level")
levelAPI.Use(d.authMdw.Required())
levelAPI.Use(d.authMdw.BannedWriteGuard())
levelAPI.Use(middleware.CSRF(cfg))
{
levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
levelAPI.GET("/info", d.levelCtrl.GetLevel)
}
// --- 认证 API ---
api := r.Group("/api")
api.Use(middleware.CSRF(cfg))
{
api.POST("/auth/check-email", d.authCtrl.CheckEmail)
api.POST("/auth/register", d.authCtrl.Register)
api.POST("/auth/login", d.authCtrl.Login)
api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
api.POST("/auth/logout", d.authCtrl.Logout)
api.POST("/auth/refresh", d.authCtrl.RefreshToken)
// 帖子公开 API无需登录
api.GET("/posts", d.postCtrl.ListAPI)
api.GET("/posts/:id", d.postCtrl.ShowAPI)
}
// --- 帖子 API需登录 ---
postsAPI := r.Group("/api/posts")
postsAPI.Use(d.authMdw.Required())
postsAPI.Use(d.authMdw.BannedWriteGuard())
postsAPI.Use(middleware.CSRF(cfg))
{
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
}
// --- 创作中心 API需登录 ---
studioAPI := r.Group("/api/studio")
studioAPI.Use(d.authMdw.Required())
studioAPI.Use(d.authMdw.BannedWriteGuard())
studioAPI.Use(middleware.CSRF(cfg))
{
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI)
studioAPI.POST("/posts", d.studioCtrl.Create)
studioAPI.PUT("/posts/:id", d.studioCtrl.Update)
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
}
// --- 域能 API需登录 ---
energyAPI := r.Group("/api/energy")
energyAPI.Use(d.authMdw.Required())
energyAPI.Use(d.authMdw.BannedWriteGuard())
energyAPI.Use(middleware.CSRF(cfg))
{
energyAPI.POST("/energize", d.energyCtrl.Energize)
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
}
// --- 评论 API部分需登录 ---
// 公开读取
commentsPublic := r.Group("/api/posts/:id/comments")
{
commentsPublic.GET("", d.commentCtrl.ListRootComments)
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
}
// 需要登录的操作
commentsAPI := r.Group("/api")
commentsAPI.Use(d.authMdw.Required())
commentsAPI.Use(d.authMdw.BannedWriteGuard())
commentsAPI.Use(middleware.CSRF(cfg))
{
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
}
// --- 赞/踩 API ---
// 公开:查询反应状态(未登录返回 none + 点赞数)
r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
// 需登录:切换赞/踩
reactionAPI := r.Group("/api/posts/:id")
reactionAPI.Use(d.authMdw.Required())
reactionAPI.Use(d.authMdw.BannedWriteGuard())
reactionAPI.Use(middleware.CSRF(cfg))
{
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
}
// --- 关注 API ---
// 公开Optional 认证以获取当前用户关注状态,未登录返回 false
followPublic := r.Group("/api/users/:uid")
followPublic.Use(d.authMdw.Optional())
{
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
followPublic.GET("/followers", d.followCtrl.Followers)
followPublic.GET("/following", d.followCtrl.Following)
}
// 需登录:切换关注
followAPI := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required())
followAPI.Use(d.authMdw.BannedWriteGuard())
followAPI.Use(middleware.CSRF(cfg))
{
followAPI.POST("/follow", d.followCtrl.Toggle)
}
// --- 收藏夹 API ---
// 公开:文章收藏状态(未登录返回 false
r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
// 公开:查看公开收藏夹内容
r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
// 需登录:收藏操作
folderAPI := r.Group("/api/folders")
folderAPI.Use(d.authMdw.Required())
folderAPI.Use(d.authMdw.BannedWriteGuard())
folderAPI.Use(middleware.CSRF(cfg))
{
folderAPI.GET("", d.favoriteCtrl.ListFolders)
folderAPI.POST("", d.favoriteCtrl.CreateFolder)
folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
}
// 需登录切换收藏Toggle
favToggleAPI := r.Group("/api/posts/:id")
favToggleAPI.Use(d.authMdw.Required())
favToggleAPI.Use(d.authMdw.BannedWriteGuard())
favToggleAPI.Use(middleware.CSRF(cfg))
{
favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
}
// --- 空间/隐私设置 API需登录 ---
spaceAPI := r.Group("/api/space")
spaceAPI.Use(d.authMdw.Required())
spaceAPI.Use(d.authMdw.BannedWriteGuard())
spaceAPI.Use(middleware.CSRF(cfg))
{
spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
}
// --- Studio 趋势 API需登录 ---
trendsAPI := r.Group("/api/studio")
trendsAPI.Use(d.authMdw.Required())
{
trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
}
setupAuthAPIRoutes(r, cfg, d)
setupSettingsAPIRoutes(r, cfg, d)
setupPostsAPIRoutes(r, cfg, d)
setupCommentsAPIRoutes(r, cfg, d)
setupStudioAPIRoutes(r, cfg, d)
setupReactionsAPIRoutes(r, cfg, d)
setupSocialAPIRoutes(r, cfg, d)
}

View File

@ -0,0 +1,27 @@
package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupAuthAPIRoutes 注册认证相关 API 路由
func setupAuthAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
api := r.Group("/api")
api.Use(middleware.CSRF(cfg))
{
// 认证
api.POST("/auth/check-email", d.authCtrl.CheckEmail)
api.POST("/auth/register", d.authCtrl.Register)
api.POST("/auth/login", d.authCtrl.Login)
api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
api.POST("/auth/logout", d.authCtrl.Logout)
api.POST("/auth/refresh", d.authCtrl.RefreshToken)
// 帖子公开 API无需登录
api.GET("/posts", d.postCtrl.ListAPI)
api.GET("/posts/:id", d.postCtrl.ShowAPI)
}
}

View File

@ -0,0 +1,31 @@
package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupCommentsAPIRoutes 注册评论相关 API 路由
func setupCommentsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 公开读取
commentsPublic := r.Group("/api/posts/:id/comments")
{
commentsPublic.GET("", d.commentCtrl.ListRootComments)
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
}
// 需要登录的操作
commentsAPI := r.Group("/api")
commentsAPI.Use(d.authMdw.Required())
commentsAPI.Use(d.authMdw.BannedWriteGuard())
commentsAPI.Use(middleware.CSRF(cfg))
{
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
}
}

View File

@ -0,0 +1,20 @@
package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupPostsAPIRoutes 注册帖子相关 API 路由
func setupPostsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
postsAPI := r.Group("/api/posts")
postsAPI.Use(d.authMdw.Required())
postsAPI.Use(d.authMdw.BannedWriteGuard())
postsAPI.Use(middleware.CSRF(cfg))
{
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
}
}

View File

@ -0,0 +1,24 @@
package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupReactionsAPIRoutes 注册赞/踩相关 API 路由
func setupReactionsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 公开:查询反应状态
r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
// 需登录:切换赞/踩
reactionAPI := r.Group("/api/posts/:id")
reactionAPI.Use(d.authMdw.Required())
reactionAPI.Use(d.authMdw.BannedWriteGuard())
reactionAPI.Use(middleware.CSRF(cfg))
{
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
}
}

View File

@ -0,0 +1,51 @@
package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupSettingsAPIRoutes 注册设置、消息、等级相关 API 路由
func setupSettingsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 设置页 API
settingsAPI := r.Group("/api/settings")
settingsAPI.Use(d.authMdw.Required())
settingsAPI.Use(d.authMdw.BannedWriteGuard())
settingsAPI.Use(middleware.CSRF(cfg))
{
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
}
// 消息中心 API
messagesAPI := r.Group("/api/messages")
messagesAPI.Use(d.authMdw.Required())
messagesAPI.Use(d.authMdw.BannedWriteGuard())
messagesAPI.Use(middleware.CSRF(cfg))
{
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead)
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
}
// 积分/等级 API
levelAPI := r.Group("/api/level")
levelAPI.Use(d.authMdw.Required())
levelAPI.Use(d.authMdw.BannedWriteGuard())
levelAPI.Use(middleware.CSRF(cfg))
{
levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
levelAPI.GET("/info", d.levelCtrl.GetLevel)
}
}

View File

@ -0,0 +1,64 @@
package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupSocialAPIRoutes 注册关注、收藏夹、空间相关 API 路由
func setupSocialAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 关注 — 公开Optional 认证)
followPublic := r.Group("/api/users/:uid")
followPublic.Use(d.authMdw.Optional())
{
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
followPublic.GET("/followers", d.followCtrl.Followers)
followPublic.GET("/following", d.followCtrl.Following)
}
// 需登录:切换关注
followAPI := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required())
followAPI.Use(d.authMdw.BannedWriteGuard())
followAPI.Use(middleware.CSRF(cfg))
{
followAPI.POST("/follow", d.followCtrl.Toggle)
}
// 收藏夹 — 公开
r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
// 收藏夹 — 需登录
folderAPI := r.Group("/api/folders")
folderAPI.Use(d.authMdw.Required())
folderAPI.Use(d.authMdw.BannedWriteGuard())
folderAPI.Use(middleware.CSRF(cfg))
{
folderAPI.GET("", d.favoriteCtrl.ListFolders)
folderAPI.POST("", d.favoriteCtrl.CreateFolder)
folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
}
// 切换收藏 Toggle
favToggleAPI := r.Group("/api/posts/:id")
favToggleAPI.Use(d.authMdw.Required())
favToggleAPI.Use(d.authMdw.BannedWriteGuard())
favToggleAPI.Use(middleware.CSRF(cfg))
{
favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
}
// 空间/隐私设置
spaceAPI := r.Group("/api/space")
spaceAPI.Use(d.authMdw.Required())
spaceAPI.Use(d.authMdw.BannedWriteGuard())
spaceAPI.Use(middleware.CSRF(cfg))
{
spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
}
}

View File

@ -0,0 +1,43 @@
package router
import (
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/middleware"
"github.com/gin-gonic/gin"
)
// setupStudioAPIRoutes 注册创作中心、域能、趋势相关 API 路由
func setupStudioAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 创作中心
studioAPI := r.Group("/api/studio")
studioAPI.Use(d.authMdw.Required())
studioAPI.Use(d.authMdw.BannedWriteGuard())
studioAPI.Use(middleware.CSRF(cfg))
{
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI)
studioAPI.POST("/posts", d.studioCtrl.Create)
studioAPI.PUT("/posts/:id", d.studioCtrl.Update)
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
}
// 域能
energyAPI := r.Group("/api/energy")
energyAPI.Use(d.authMdw.Required())
energyAPI.Use(d.authMdw.BannedWriteGuard())
energyAPI.Use(middleware.CSRF(cfg))
{
energyAPI.POST("/energize", d.energyCtrl.Energize)
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
}
// 趋势
trendsAPI := r.Group("/api/studio")
trendsAPI.Use(d.authMdw.Required())
{
trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
}
}

View File

@ -1,21 +1,38 @@
// Package router 负责依赖注入组装和 HTTP 路由注册。
package router
import (
"metazone.cc/mce/internal/config"
adminCtrl "metazone.cc/mce/internal/controller/admin"
"metazone.cc/mce/internal/middleware"
"metazone.cc/mce/internal/service"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// 保存站点设置 Controller 引用,供 InjectThemeReloader 使用
var globalSiteSettingCtrl *adminCtrl.SiteSettingController
// InjectThemeReloader 注入主题重载回调main.go 在路由注册后调用,使主题切换实时生效)
func InjectThemeReloader(reload service.ThemeReloader) {
if globalSiteSettingCtrl != nil {
globalSiteSettingCtrl.SetThemeReloader(reload)
}
}
// Setup 注册所有路由
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
r.Use(middleware.SecurityHeaders())
r.Use(middleware.InjectSiteInfo(siteSettings))
r.Use(middleware.DBHealthGuard())
deps := buildDeps(db, cfg, siteSettings, redisClient)
// 保存站点设置 Controller 引用
globalSiteSettingCtrl = deps.siteSettingCtrl
// 维护模式中间件(全局,在路由匹配之前)
r.Use(deps.maintenanceMdw.Handler())

View File

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

View File

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

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -98,6 +98,7 @@ 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>

View File

@ -80,3 +80,17 @@ func (s *SiteSettingService) GetBoolSetting(key string, defaultVal bool) bool {
func ParseBool(v string) (bool, error) {
return strconv.ParseBool(v)
}
// ThemeReloader 主题重载回调:切换主题后实时生效
type ThemeReloader func(themeName string) error
// UpdateTheme 切换主题并触发重载
func (s *SiteSettingService) UpdateTheme(themeName string, reload ThemeReloader) error {
if err := s.settings.Set("site.theme", themeName); err != nil {
return err
}
if reload != nil {
return reload(themeName)
}
return nil
}

View File

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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

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

View File

@ -0,0 +1,52 @@
package theme
import (
"encoding/json"
"os"
"path/filepath"
"sort"
)
// ThemeInfo 描述一个已安装的主题。
type ThemeInfo struct {
Dir string `json:"dir"` // 目录名(如 "MetaLab-2026"
Name string `json:"name"` // 主题名称
DisplayName string `json:"display_name"` // 展示名称
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
Description string `json:"description"` // 描述
}
// DiscoverThemes 扫描 templates/ 目录,返回所有包含 theme.json 的主题列表。
func DiscoverThemes(rootDir string) ([]ThemeInfo, error) {
entries, err := os.ReadDir(rootDir)
if err != nil {
return nil, err
}
var themes []ThemeInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
themeJSON := filepath.Join(rootDir, entry.Name(), "theme.json")
data, err := os.ReadFile(themeJSON) //nolint:gosec // 内部主题文件,非用户输入
if err != nil {
continue // 无 theme.json 则跳过
}
var info ThemeInfo
if err := json.Unmarshal(data, &info); err != nil {
continue
}
info.Dir = entry.Name()
themes = append(themes, info)
}
// 按名称排序,保证稳定展示
sort.Slice(themes, func(i, j int) bool {
return themes[i].Name < themes[j].Name
})
return themes, nil
}

View File

@ -1,3 +1,4 @@
// Package theme 负责多主题模板的加载、渲染和静态内容管理。
package theme
import (
@ -27,7 +28,7 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
"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 分钟"

BIN
main Executable file

Binary file not shown.

45
scripts/ci.sh Executable file
View File

@ -0,0 +1,45 @@
#!/bin/bash
# CI 流水线脚本
# 调用方式bash scripts/ci.sh [--strict]
# --strict 阻断模式lint 失败则 exit 1
# 默认 报告模式lint 只输出不阻断)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
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)"
make fmt-check
# 2. 编译检查
echo ""
echo "==> 2/3 编译检查 (go build)"
go build ./...
# 3. 静态分析
echo ""
echo "==> 3/3 静态分析 (golangci-lint)"
if $STRICT; then
make lint-strict
else
make lint
fi
echo ""
echo "========================================"
echo " CI Pipeline 完成"
echo "========================================"

12
scripts/pre-push Normal file
View File

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

View File

@ -0,0 +1,5 @@
</main>
</div>
{{template "layout/footer.html" .}}
</body>
</html>

View File

@ -0,0 +1,56 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="settings-toast" id="settingsToast"></div>
<div class="settings-layout">
<aside class="settings-sidebar">
<nav class="settings-nav">
<a href="/settings/profile"
class="settings-nav-item {{if eq .ActiveTab "profile"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/>
</svg>
个人资料
</a>
<a href="/settings/account"
class="settings-nav-item {{if eq .ActiveTab "account"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<circle cx="12" cy="16" r="1"/>
</svg>
账号信息
</a>
<a href="/settings/sessions"
class="settings-nav-item {{if eq .ActiveTab "sessions"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
登录管理
</a>
<a href="/settings/energy"
class="settings-nav-item {{if eq .ActiveTab "energy"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>
我的域能
</a>
<a href="/settings/favorites"
class="settings-nav-item {{if eq .ActiveTab "favorites"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
我的收藏夹
</a>
<a href="/settings/notify"
class="settings-nav-item {{if eq .ActiveTab "notify"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
通知偏好
</a>
</nav>
</aside>
<main class="settings-main">

View File

@ -0,0 +1,84 @@
{{template "settings/_header.html" .}}
<!-- 账号信息 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>账号信息</h2>
<p class="settings-card-desc">账号安全和基本状态</p>
</div>
<div class="settings-card-body">
<div class="info-row">
<span class="info-label">用户 ID</span>
<span class="info-value">{{.User.ID}}</span>
</div>
<div class="info-row">
<span class="info-label">邮箱</span>
<span class="info-value">{{.User.Email}}</span>
</div>
<div class="info-row">
<span class="info-label">账号状态</span>
<span class="info-value">
<span class="status-badge status-{{.User.Status}}">{{.StatusName}}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">注册时间</span>
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
{{if .User.LastLoginAt}}
<div class="info-row">
<span class="info-label">上次登录</span>
<span class="info-value">{{.User.LastLoginAt.Format "2006-01-02 15:04:05"}}</span>
</div>
{{end}}
<div class="info-row">
<span class="info-label">最后更新</span>
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
</div>
</div>
<!-- 修改密码 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>修改密码</h2>
<p class="settings-card-desc">修改密码后需重新登录</p>
</div>
<div class="settings-card-body">
<div class="form-group">
<label class="form-label" for="currentPassword">当前密码</label>
<input type="password" id="currentPassword" class="settings-text-input" placeholder="输入当前密码" autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-label" for="newPassword">新密码</label>
<input type="password" id="newPassword" class="settings-text-input" placeholder="至少 8 位,包含大小写字母和数字" autocomplete="new-password">
</div>
<div class="settings-card-footer">
<button id="changePwdBtn" class="settings-save-btn">修改密码</button>
<span class="form-hint">修改成功后需重新登录</span>
</div>
</div>
</div>
<!-- 注销账号 -->
<div class="settings-card settings-card-danger">
<div class="settings-card-header">
<h2>注销账号</h2>
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录撤销注销</p>
</div>
<div class="settings-card-body">
<div class="form-group">
<label class="form-label" for="deletePassword">确认密码</label>
<input type="password" id="deletePassword" class="settings-text-input" placeholder="输入密码确认" autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-label" for="deleteReason">注销原因(选填)</label>
<textarea id="deleteReason" class="settings-textarea" maxlength="500" placeholder="可以告诉我们为什么要离开吗?"></textarea>
</div>
<div class="settings-card-footer">
<button id="deleteAccountBtn" class="settings-danger-btn">确认注销</button>
<span class="form-hint">此操作不可逆,请谨慎处理</span>
</div>
</div>
</div>
{{template "settings/_footer.html" .}}
<script src="/static/js/settings-account.js?v={{assetV "/static/js/settings-account.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -0,0 +1,29 @@
{{template "settings/_header.html" .}}
<!-- 我的域能 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>我的域能</h2>
<p class="settings-card-desc">域能余额和经验</p>
</div>
<div class="settings-card-body">
<div class="energy-summary">
<div class="energy-stat">
<span class="energy-label">域能余额</span>
<span class="energy-value" id="energyBalance">加载中...</span>
</div>
<div class="energy-stat">
<span class="energy-label">经验</span>
<span class="energy-value" id="energyExp">加载中...</span>
</div>
<div class="energy-stat">
<span class="energy-label">等级</span>
<span class="energy-value" id="energyLevel">加载中...</span>
</div>
</div>
<div class="energy-log-list" id="energyLogList">
<div class="energy-log-empty">加载中...</div>
</div>
</div>
</div>
{{template "settings/_footer.html" .}}
<script src="/static/js/settings-energy.js?v={{assetV "/static/js/settings-energy.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -0,0 +1,18 @@
{{template "settings/_header.html" .}}
<!-- 我的收藏夹 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>我的收藏夹</h2>
<p class="settings-card-desc">管理和查看你的收藏夹</p>
</div>
<div class="settings-card-body">
<div class="fav-toolbar">
<button class="settings-save-btn" id="createFolderBtn">+ 新建收藏夹</button>
</div>
<div class="fav-list" id="favFolderList">
<div class="energy-log-empty">加载中...</div>
</div>
</div>
</div>
{{template "settings/_footer.html" .}}
<script src="/static/js/settings-favorites.js?v={{assetV "/static/js/settings-favorites.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -0,0 +1,13 @@
{{template "settings/_header.html" .}}
<!-- 通知偏好 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>通知偏好</h2>
<p class="settings-card-desc">管理你的通知推送偏好,关闭后不影响通知记录存储</p>
</div>
<div class="settings-card-body" id="notifyPrefsBody">
<div class="energy-log-empty">加载中...</div>
</div>
</div>
{{template "settings/_footer.html" .}}
<script src="/static/js/settings-notify.js?v={{assetV "/static/js/settings-notify.js"}}" nonce="{{.CSPNonce}}"></script>

Some files were not shown because too many files have changed in this diff Show More