Compare commits

...

44 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
47efd7921b docs: 完善代码规范 — 设计原则+拆分决策+架构说明
- 响应格式示例对齐实际代码(http.Status 模式)
- 补 6 大设计原则(YAGNI/DIP/SRP/OCP/ISP/KISS)
- 补架构说明(分层+ISP,五边形架构)
- 补重构原则:不向后兼容 + CMS 化
- 文件拆分决策矩阵(该拆/不该拆)
2026-06-22 01:22:41 +08:00
9a496d3055 feat: 密码强度多级校验 — low/medium/high/very_high
- site_settings 新增 password.strength 配置项
- 四级:low(len>=8) / medium(两类) / high(三类+无连续) / very_high(四类+无连续)
- 连续字符检查:重复/升序/降序/键盘水平/键盘垂直序列
- 注册和改密接口统一读取配置
- 前端 register.js 动态校验 + 管理后台安全设置区
2026-06-22 00:54:21 +08:00
e9b7401dc9 feat: 定时发布系统 — Phase 5 完成
- Post ScheduledAt 字段
- Scheduler goroutine 每分钟扫描到期文章
- Create/Update 校验最短 30min 定时
- Approve 审核晚于定时则即时发布
- public 列表+空间页过滤未到期文章
- 写文章页 datetime-local 选择器(min=now+30min)
- AutoMigrate 追加新表
2026-06-22 00:39:04 +08:00
0b8f6f890d feat: 分类系统 + 标签系统 — Phase 4 完成
分类:Model/Repo/Service/Controller + 管理后台树形页面 + 首页导航栏
标签:Model/Repo/Service/Controller + /tags/{slug} 落地页 + 写文章页输入
Post 加 CategoryID,Create/Update 支持分类和标签
SQL 迁移含 categories/tags/post_tags 表及预设未分类
2026-06-22 00:30:29 +08:00
977c52513a feat: 文章属性前端完成 — Phase 3
- 写文章页:可见性/类型/转载来源/创作声明/禁止转载 属性表单
- 类型切换联动(转载显示来源,原创显示禁止转载复选框)
- 文章详情页:私密角标、置顶标记、创作声明横幅、转载来源
- 管理后台:置顶/取消置顶操作按钮
- PostService Create/Update 签名扩展接受文章属性
- DTO/接口同步更新
2026-06-22 00:20:52 +08:00
9c452afb40 feat: 文章属性 — Post Model 扩展 + 置顶系统
- Post 新增 7 字段:Visibility/PostType/ReprintSource/Declaration/ReprintProhibited/PinType/PinnedAt
- 公开列表过滤 private 文章(管理员可见全部)
- 列表排序:全局置顶 > 按时间
- declarationLabel 模板函数(7 种创作声明)
- Pin/Unpin API(admin+,category/global 两种置顶类型)
2026-06-22 00:04:55 +08:00
fc57ed17d4 feat: 公告系统 — Phase 2 完成
- Model/Repo/Service/Controller 全栈实现
- CRUD + ListActive(生效时间范围过滤)
- 管理后台独立页面 /admin/announcements(列表+弹窗CRUD)
- 前台首页 Hero 下方公告栏(info/success/warning/error 四色)
- Moderator+ 权限可管理公告
2026-06-22 00:00:21 +08:00
2d1dd5d401 docs: 更新依赖注入规范 — 构造器注入 + ISP 接口隔离 2026-06-21 23:54:48 +08:00
1933ea86b4 feat: 注册准则阅读控制 + Footer 合规信息
准则阅读(三层控制):
- registration.show_guidelines — 弹窗/复选框模式
- registration.force_guidelines — 是否强制倒计时+滚动
- registration.guidelines_timer — 倒计时秒数

Footer 合规(8 字段,空值不显示,链接纯手写):
- site.operator_name / site.icp_number / site.icp_license
- site.police_number / site.wenwangwen / site.algorithm_filing
- site.privacy_url / site.terms_url
- 新增 safeHTML 模板函数输出不转义 HTML
2026-06-21 23:50:52 +08:00
0014bdb9ff feat: 品牌CMS配置化 + cookie_secure配置
- config.yaml 新增 server.cookie_secure 配置项
- site_settings 新增 site_name/site_tagline/contact_email 站点信息
- SiteInfo 结构体扩展,支持运行时从 DB 读取站点品牌信息
- cookie.go 使用 cfg.Server.CookieSecure 替代 Mode!=debug 判断
- SetSessionCookie/ClearSessionCookie 支持 siteSettings 运行时覆盖
- 模板替换硬编码 MetaLab → {{.SiteName}}(nav/header/footer/home/login/register/guidelines/admin,12+处)
- 管理后台站点设置页新增站点名称/标语/联系邮箱输入项
- 页脚版权年份改为动态 CurrentYear
- 注册成功消息 CMS 化(后端+前端)
2026-06-21 23:34:14 +08:00
7b5856e0ba fix: post-comments.js L338 闭包Bug - var i/var item 改为 let i/let item 2026-06-21 22:51:28 +08:00
a2e8242c21 chore: 全局包名路径从 metalab 更改为 mce 2026-06-21 16:55:46 +08:00
ee137b23a6 docs: 项目更名为 MetaZone Community Engine (MCE) 2026-06-21 16:43:03 +08:00
8bf6ac9e51 feat: @提及发送通知 + 通知点击定位高亮
- 新增 NotifyCommentMention 通知类型,归属 mention 分类 TAB
- parseMentions 中向被@用户发送通知(排除自己和重复通知)
- 消息中心 mention 通知可点击跳转至文章页面
- 通过 mention 通知进入时,来源评论置顶并持久半透明高亮
- 普通评论链接保持原有短暂高亮行为不变
2026-06-03 13:21:27 +08:00
2ea9788ec7 fix: 关注状态 API 添加 Optional 认证中间件
- /api/users/:uid/follow-status 等公开 GET 接口缺少 Optional 认证
- 导致 GetGinUser 无法获取当前用户 uid,始终返回 i_follow=false
- 关注按钮加载时无法显示正确初始状态(始终显示"关注")
- 三条 GET 接口统一加入 authMdw.Optional() 组
- 不影响未登录用户(未登录依然返回 false)
2026-06-03 13:11:56 +08:00
be729c29a1 fix: 关注通知添加用户空间链接跳转
- 消息中心关注通知卡片 now 可点击跳转至关注者空间 (/space/:uid)
2026-06-03 13:06:44 +08:00
584d7fd146 fix: 修复 /space 关注按钮在默认标签页无法点击
- 将顶部关注按钮 JS 从 following/followers 标签页限定块中提取到全局脚本
- 按钮在所有标签页均可点击(之前仅在 following/followers 标签页有效)
- 本人点击关注时 toast 提示"不能关注自己",与赋能按钮行为一致
- 未登录用户点击关注跳转登录页
- 移除标签页限定块中的重复顶部按钮代码
2026-06-03 13:00:12 +08:00
6c9a4414cb refactor: 全局移除浏览器 alert 弹窗,统一使用页面内 showToast 通知
- 替换全部 11 个文件中 36 处 alert() 为 showToast()
- API 错误/异常使用 showToast(msg, 'error'),3秒自动消失
- 表单校验提示使用 showToast(msg, 'warning'),3秒自动消失
- 成功确认使用 showToast(msg, 'success'),3秒自动消失
- 移除 post-energize.js 中与全局 showToast 冲突的本地实现
- 删除 posts.css 中已失去引用的 .energize-toast 样式
- 保留 showConfirm() 自定义确认弹窗(需用户确认的操作)
2026-06-03 01:25:12 +08:00
864a488ee0 feat: @搜索优先展示已关注用户,结果限制最多5条
- SearchUsersByLevel SQL新增LEFT JOIN user_follows,按已关注优先+exp降序
- SearchUsers/followerUID参数贯穿repo→service→controller
- 继续输入时前端自动重新搜索新关键词
2026-06-03 01:15:38 +08:00
fb9724faed fix: space关注按钮移除隐藏逻辑,自关注改为toast提示
- 自己的space也显示关注按钮,不再隐藏
- 点击关注自己时toast'不能关注自己',不发请求
- 自己的space下,发消息/更多按钮保持隐藏
2026-06-03 01:13:01 +08:00
58aabb9bf0 fix: 临时登录cookie改用maxAge=IdleTimeout替代session模式
- rememberMe=false时maxAge从0改为IdleTimeout秒数(默认2h),与Redis TTL一致
- 解决服务器重启后部分浏览器(尤其隐私窗口)将session cookie视为失效的问题
- 关浏览器后依然自动清除(maxAge到期 + Redis TTL双保险)
2026-06-03 01:07:47 +08:00
ae15025b1d feat: @提及支持改名后链接跟随UID + 显示名自动更新
- comment_mentions新增original_username列,创建时记录原始@用户名
- Mentions map改为{original_username: {uid, name}}结构,name为当前显示名
- 用户改名后:旧@文本仍能匹配original_username,链接跟随uid不变,显示名自动更新
- 前端renderMentions适配新结构
2026-06-03 01:00:44 +08:00
be8ce04334 fix: 修复@提及uid始终为0的BUG + 空@提示优化
- SearchUsersByLevel SQL id列别名改为uid,修复GORM字段名映射导致uid=0
- @空输入时悬浮窗显示'输入以查找用户'而非空白
- 清理历史脏数据(uid=0的mention记录)
2026-06-03 00:55:51 +08:00
231 changed files with 7408 additions and 2740 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

6
.gitignore vendored
View File

@ -20,6 +20,12 @@ go.work.sum
storage/uploads/
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

118
README.md
View File

@ -3,23 +3,29 @@
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
<img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31">
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/Redis-7-DC382D?style=flat-square&logo=redis&logoColor=white" alt="Redis">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0">
</p>
<h1 align="center">MetaLab</h1>
<h1 align="center">MetaZone Community Engine</h1>
<p align="center"><em>下一代开源技术社区——为构建者准备的纯粹空间</em></p>
<p align="center"><strong>MCE</strong>下一代开源社区引擎,为 BBS 3.0 而生</p>
> **⚠️ 维护状态**
> 因个人原因本项目暂时无法继续活跃维护。现有功能可正常使用Issues/PR 可能无法及时响应。
> 如有兴趣接手或继续开发,欢迎 Fork。
---
## 关于项目
MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间
MCE 是 **MetaZone Community Engine** 的缩写,是 MetaZone 品牌下的社区平台核心引擎。构建于 **MetaGenesis** 体系之上,定位为通用型社区/BBS 平台,面向下一个十年
**核心理念:**
- 🎯 **专注技术**——代码、架构、逻辑与可验证的事实
- 🤝 **平等交流**——没有特权,每个成员权利平等
- 🔓 **开放共享**——鼓励开源、协作与知识共享
- 🌊 **流式社区**——告别传统板块制,内容以信息流自然流动
- 🤝 **平等参与**——去中心化讨论,每个成员权利平等
- 🧩 **模块可组装**——MetaGenesis 框架层提供通用基础能力,引擎层专注社区体验
- 🔓 **开放共享**——AGPL 开源,鼓励二次开发与生态共建
## 技术栈
@ -29,32 +35,33 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
| Web 框架 | **Gin** | 1.12 |
| ORM | **GORM** | 1.31 |
| 数据库 | **PostgreSQL** | 16+ |
| 认证 | **JWT** (HS256) | v5 |
| 缓存 | **Redis** | 7+ |
| 密码加密 | **bcrypt** (cost=12) | — |
| 编辑器 | **Vditor** | 3.11 |
| 编辑器 | **Tiptap** (WYSIWYG) | — |
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
| 架构 | 分层六边形架构 | — |
| 架构 | 分层架构 (Controller → Service → Repository) | — |
## 当前功能
-**用户系统**——注册、登录、登出
-**双 Token 认证**——Access Token15min+ Refresh Token7d session / 30d "记住我"token_version 实时吊销
-**CSRF 保护**——全站 CSRF Token自动注入 state-changing 请求
-**速率限制**——API 调用频率控制(账户 + IP 双维度)
-**安全头**——Content-Security-Policy 等安全响应头 + 登录防时序攻击
-**管理后台**——用户管理、角色权限控制
-**用户系统**——注册、登录、登出,会话管理
-**安全认证**——CSRF 保护、速率限制(账户 + IP、安全响应头
-**管理后台**——用户管理、角色权限控制、审核面板
-**账号注销**——用户自助注销7 天冷却期,登录二次确认恢复),站长禁止自删
-**密码修改**——修改后强制所有设备退登
-**IP 审计**——记录注册 IP、最后登录 IP 及时间
-**用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切)
-**头像处理**——JPEG/PNG/WebP 上传方形裁切512×512 WebP 编码 ≤100KB
-**消息中心**——审核通知推送SSR 消息页面,单条/全部标为已读未读数角标1/66/99+
-**帖子系统**——Markdown 发布/编辑/删除Vditor 所见即所得编辑器,图片粘贴/拖拽上传
-**头像处理**——JPEG/PNG/WebP 上传方形裁切512×512 WebP 编码
-**消息中心**——通知推送SSR 消息页面,已读/未读管理,未读数角标
-**@提及系统**——@用户搜索、通知推送、通知点击定位高亮
-**用户空间**——个人主页 `/space/:uid`,关注/取关,关注列表
-**帖子系统**——Markdown 发布/编辑/删除Tiptap 所见即所得编辑器
-**代码高亮**——highlight.js 60+ 主题,支持 30+ 编程语言
-**Shortcode 卡片**——`[zone:event:ID]` 活动/游戏/投票/资源等可扩展卡片
-**草稿自动保存**——localStorage 草稿,每 2 秒自动保存
-**审核工作流**——帖子 draft → pending → approved/rejected,管理员审核面板
-**草稿自动保存**——localStorage 草稿,自动保存
-**审核工作流**——帖子 draft → pending → approved/rejected
-**多主题支持**——可切换的前端主题系统
-**赋值(赋能)系统**——社区互动激励,赋能溢出截断 + 防自赋能
-**收藏功能**——文章收藏,收藏夹管理
## 计划中
@ -63,39 +70,82 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
- 🔲 邮箱验证
- 🔲 全文搜索
- 🔲 回收站(用户数据彻底清理)
- 🔲 Redis 会话 & 缓存层深度集成
## 快速开始
### 环境要求
| 依赖 | 最低版本 | 说明 |
|------|----------|------|
| Go | 1.26 | 编译运行 |
| PostgreSQL | 16 | 主数据库 |
| Redis | 7 | 会话存储(可选,关闭后使用内存存储) |
### 1. 克隆并配置
```bash
# 克隆仓库
git clone git@git.metazone.cc:MetaZone/MetaLab.git
cd MetaLab
git clone git@git.metazone.cc:MetaZone/mce.git
cd mce
```
# 创建 .env 文件
cat > .env << EOF
DATABASE_PASSWORD=your_db_password
JWT_SECRET=your_jwt_secret
EOF
复制环境变量模板并填入实际值:
# 确保 PostgreSQL 运行中
# 默认连接: 127.0.0.1:5432, 数据库: metalab_dev
```bash
cp .env.example .env
```
# 运行
编辑 `.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)。
## 项目结构
```
MetaLab/
mce/
├── cmd/server/ # 入口:初始化 → 启动
├── internal/
│ ├── config/ # 配置加载YAML + .env
│ ├── router/ # 路由注册
│ ├── controller/ # 控制器(参数绑定 → 调服务 → 返回)
│ │ └── admin/ # 后台控制器
│ ├── service/ # 业务逻辑层
│ ├── repository/ # 数据访问层DAO
│ ├── model/ # 数据模型 + DTO
@ -119,5 +169,5 @@ MetaLab/
---
<p align="center">
<sub>&copy; 2024-2026 MetaZone | Built with ❤️ by <a href="https://metazone.cc">MetaZone</a> </sub>
<sub>Built on <a href="#">MetaGenesis</a> | &copy; 2024-2026 <a href="https://metazone.cc">MetaZone</a></sub>
</p>

View File

@ -1,13 +1,22 @@
package main
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/router"
"metazone.cc/metalab/internal/theme"
"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"
"metazone.cc/mce/internal/scheduler"
"metazone.cc/mce/internal/theme"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
@ -19,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)
@ -27,7 +46,7 @@ func main() {
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}, &model.Announcement{}, &model.Category{}, &model.Tag{}, &model.PostTag{}); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
}
@ -55,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 {
@ -89,16 +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

@ -4,6 +4,8 @@
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,6 +109,16 @@
| Middleware | ≤ 60 行 | 单一职责 |
| Router | ≤ 60 行 | 只做路由映射 |
> **注意**:行数上限为**警告线**而非硬截断。拆分决策遵循 SRP一个文件只有一个变更原因
| 该拆 | 不该拆 |
|------|--------|
| 一个文件混了两个及以上业务域(如 `commentService` + `likeService` 塞同一个文件) | 只有单一业务域,但因逻辑复杂产生了大量私有辅助函数 — 保持内聚 |
| 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) |
| 路由文件按业务域自然分割 | 文件超线但职责单一、内聚良好 — 维持现状 |
> **判定口诀**:多域混居 → 拆;单域内聚 → 留。如果"辅助函数太多"到了怀疑域本身是否单一的 程度,那问题不是该拆文件,而是该把域拆小(如 `PostService` → `CommentService` + `LikeService`),按变更原因重新切分。
## 瘦控制器铁律
Controller 每个方法不超过三步:
@ -51,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。
## 命名规范
@ -65,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` 追加新的业务逻辑)
### 结构体与方法
@ -73,22 +295,58 @@ 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 命名
统一使用 `common` 包的响应函数
**MUST** 使用类型名首字母缩写1-2 个字母),同一类型的全部方法间 **MUST** 保持 receiver 名一致
```go
common.Ok(ctx, data) // 200 成功
common.Error(ctx, common.ErrInvalidParam, msg) // 400 参数错误
common.Error(ctx, common.ErrUnauthorized, msg) // 401 未认证
common.Error(ctx, common.ErrForbidden, msg) // 403 无权限
common.Error(ctx, common.ErrNotFound, msg) // 404 未找到
common.Error(ctx, common.ErrInternal, msg) // 500 服务器错误
// ✅ 正确:首字母/首字母缩写,同类型全部方法一致
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 状态码直接传入:
```go
common.Ok(c, data) // 200 成功
common.OkWithMessage(c, data, "操作成功") // 200 带消息
common.OkMessage(c, "操作成功") // 200 纯消息
common.Error(c, http.StatusBadRequest, "参数错误") // 400
common.Error(c, http.StatusUnauthorized, "请先登录") // 401
common.Error(c, http.StatusForbidden, "权限不足") // 403
common.Error(c, http.StatusNotFound, "未找到") // 404
common.Error(c, http.StatusInternalServerError, "服务器错误") // 500
```
### 模板变量
使用驼峰命名,与模板文件保持一致:
使用驼峰命名,变量名与 Go 模板中 `{{.VariableName}}` 的引用名一致:
```go
gin.H{
@ -98,14 +356,214 @@ gin.H{
}
```
## 依赖注入
## 注释规范
当前阶段 Controller 直接在方法内创建 Service 实例。后期引入 DI 容器后统一管理。
### 导出符号
所有导出的函数、类型、常量、变量必须有 doc 注释且以被注释符号的名称开头godoc 惯例):
```go
// 当前
func (ac *AuthController) RegisterPage(c *gin.Context) { ... }
// PostService 处理文章相关的业务逻辑。
type PostService struct{ ... }
// 后期
func NewAuthController(svc *AuthService) *AuthController { ... }
// 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 的依赖通过构造函数传入:
```go
// 构造器注入依赖
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config, siteSettings *config.SiteSettings) *AuthController {
return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg, siteSettings: siteSettings}
}
```
Controller 层通过 **ISP 接口隔离** 声明最小依赖 —— 只声明自己需要的方法子集,不依赖完整 Service 接口:
```go
// controller/admin/interfaces.go
type siteSettingUseCase interface {
GetSettings() map[string]string
UpdateSetting(key, value string) error
UpdateBoolSetting(key string, value bool) error
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
}
```
各 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)
```
## 设计原则
| 原则 | 含义 | 本项目实践 |
|------|------|-----------|
| **YAGNI** | You Ain't Gonna Need It — 不为可能的需求提前实现 | 分 Phase 推进,不做预留接口/未用抽象层 |
| **DIP**(依赖倒置) | 高层模块不依赖低层,都依赖抽象 | Controller → ISP 接口 ← ServiceService → Store 接口 ← Repository |
| **SRP**(单一职责) | 一个模块只为一个角色变更 | 拆分决策:「多域混居则拆,内聚共享则留」 |
| **OCP**(开闭原则) | 对扩展开放,对修改关闭 | `config.yaml` 角色/审核配置可扩展,不改代码 |
| **ISP**(接口隔离) | 不依赖用不到的接口 | Controller 只声明自己需要的方法子集,见下方 ISP 章节 |
| **KISS** | Keep It Simple | 连续字符校验纯函数、密码强度 switch-case不做策略模式/DI 框架 |
### 架构风格
分层架构 + ISP 接口隔离,非纯六边形(即不在所有方向都引入 Port/Adapter 抽象,仅在业务边界 Controller ↔ Service、Service ↔ Repository 做接口倒置。Gin、GORM 等框架层不额外抽象:
```
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
Service 核心逻辑
Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
```
## 重构原则
### 不向后兼容
本项目为全新项目,尚未发布正式版。**重构时直接废弃旧实现,不做兼容层**,不保留任何向后兼容代码。遇到旧代码直接删除/覆盖,不新增 `deprecated`、`legacy` 等标记或包装函数。
### CMS 化
**纳入 CMS 配置化的内容**(站点级可配置文案,需管理后台提供对应输入项):
- 品牌名、标语、版权声明、联系邮箱
- 页面标题前缀/后缀、SEO 描述
- 空状态占位文案、系统级通知横幅
- 其他面向最终用户展示的站点文本
**不受此规则约束的例外**(无需配置化):
- 表单校验错误消息(如"用户名不能为空")— 属于应用逻辑,不是站点配置
- `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

@ -0,0 +1,13 @@
-- 公告表
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(20) DEFAULT 'info',
is_active BOOLEAN DEFAULT true,
start_at TIMESTAMP,
end_at TIMESTAMP,
sort INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);

View File

@ -0,0 +1,8 @@
-- 文章属性扩展
ALTER TABLE posts ADD COLUMN IF NOT EXISTS visibility VARCHAR(10) DEFAULT 'public';
ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type VARCHAR(10) DEFAULT 'original';
ALTER TABLE posts ADD COLUMN IF NOT EXISTS reprint_source VARCHAR(500) DEFAULT '';
ALTER TABLE posts ADD COLUMN IF NOT EXISTS declaration VARCHAR(30) DEFAULT '';
ALTER TABLE posts ADD COLUMN IF NOT EXISTS reprint_prohibited BOOLEAN DEFAULT false;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS pin_type VARCHAR(10) DEFAULT '';
ALTER TABLE posts ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMP;

View File

@ -0,0 +1,32 @@
-- 分类表(两级树形)
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(200) DEFAULT '',
parent_id INT REFERENCES categories(id) ON DELETE SET NULL,
sort INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 预设「未分类」
INSERT INTO categories (id, name, slug) VALUES (1, '未分类', 'uncategorized') ON CONFLICT DO NOTHING;
-- 标签表
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 文章-标签关联表
CREATE TABLE IF NOT EXISTS post_tags (
post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- 文章加分类外键
ALTER TABLE posts ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL;

View File

@ -0,0 +1,3 @@
-- 定时发布
ALTER TABLE posts ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_posts_scheduled_at ON posts(scheduled_at);

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` 自动清理)

2
go.mod
View File

@ -1,4 +1,4 @@
module metazone.cc/metalab
module metazone.cc/mce
go 1.26.0

View File

@ -1,10 +1,11 @@
// Package cache 提供首页等热点数据的内存缓存层。
package cache
import (
"sync"
"time"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/model"
)
// HomePageCache 首页内存缓存30 秒 TTL创建/更新/删除/审核通过时失效

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

@ -4,7 +4,7 @@ import (
"log"
"net/http"
"metazone.cc/metalab/internal/config"
"metazone.cc/mce/internal/config"
"github.com/gin-gonic/gin"
)
@ -15,17 +15,17 @@ const (
)
// SetSessionCookie 写入会话 ID Cookie
// rememberMe=true → Cookie 持久化(30天,关浏览器后仍保持登录
// rememberMe=false → Cookie session 模式maxAge=0,关浏览器清除
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
// rememberMe=true → Cookie maxAge=30天关浏览器后仍保持登录
// rememberMe=false → Cookie maxAge=空闲超时默认2h与Redis TTL一致,关浏览器后自动清除
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config, siteSettings *config.SiteSettings) {
secure := siteSettings.CookieSecure()
c.SetSameSite(http.SameSiteLaxMode)
var maxAge int
if rememberMe {
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
} else {
maxAge = 0 // session cookie
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
}
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
@ -33,8 +33,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
}
// ClearSessionCookie 清除会话 ID Cookielogout 调用)
func ClearSessionCookie(c *gin.Context, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
func ClearSessionCookie(c *gin.Context, cfg *config.Config, siteSettings *config.SiteSettings) {
secure := siteSettings.CookieSecure()
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
}

View File

@ -40,6 +40,7 @@ var (
// 帖子相关
ErrPostNotFound = errors.New("帖子不存在")
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
@ -57,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,30 +11,41 @@ import (
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
// 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
}
@ -42,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
}
@ -51,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"
@ -159,10 +173,47 @@ func RedirectToLogin(c *gin.Context) {
// injectSiteInfo 从 context 注入站点展示信息
func injectSiteInfo(c *gin.Context, data gin.H) {
if siteName, exists := c.Get("site_name"); exists {
data["SiteName"] = siteName
}
if siteTagline, exists := c.Get("site_tagline"); exists {
data["SiteTagline"] = siteTagline
}
if contactEmail, exists := c.Get("site_contact_email"); exists {
data["ContactEmail"] = contactEmail
}
if framework, exists := c.Get("site_framework"); exists {
data["Framework"] = framework
}
if copyrightStart, exists := c.Get("site_copyright_start"); exists {
data["CopyrightStart"] = copyrightStart
}
if operatorName, exists := c.Get("site_operator_name"); exists {
data["OperatorName"] = operatorName
}
if icpNumber, exists := c.Get("site_icp_number"); exists {
data["ICPNumber"] = icpNumber
}
if icpLicense, exists := c.Get("site_icp_license"); exists {
data["ICPLicense"] = icpLicense
}
if policeNumber, exists := c.Get("site_police_number"); exists {
data["PoliceNumber"] = policeNumber
}
if wenwangwen, exists := c.Get("site_wenwangwen"); exists {
data["WenWangWen"] = wenwangwen
}
if algorithmFiling, exists := c.Get("site_algorithm_filing"); exists {
data["AlgorithmFiling"] = algorithmFiling
}
if privacyURL, exists := c.Get("site_privacy_url"); exists {
data["PrivacyURL"] = privacyURL
}
if termsURL, exists := c.Get("site_terms_url"); exists {
data["TermsURL"] = termsURL
}
if guidelinesTimer, exists := c.Get("registration_guidelines_timer"); exists {
data["GuidelinesTimer"] = guidelinesTimer
}
data["CurrentYear"] = time.Now().Year()
}

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

@ -1,6 +1,6 @@
package common
import "metazone.cc/metalab/internal/model"
import "metazone.cc/mce/internal/model"
// PostStatusDisplayNames 帖子状态 → 中文名称映射(供模板渲染使用)
var PostStatusDisplayNames = map[string]string{

View File

@ -7,7 +7,7 @@ import (
"time"
"github.com/redis/go-redis/v9"
"metazone.cc/metalab/internal/config"
"metazone.cc/mce/internal/config"
)
// NewRedisClient 创建 Redis 客户端并尝试验证连接
@ -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 (
@ -31,6 +32,8 @@ type AuditConfig struct {
type ServerConfig struct {
Port string `mapstructure:"port"`
Mode string `mapstructure:"mode"`
CookieSecure bool `mapstructure:"cookie_secure"`
Timezone string `mapstructure:"timezone"`
}
type DatabaseConfig struct {
@ -74,7 +77,7 @@ type RolesPermissions struct {
OperableRoles map[string][]string `mapstructure:"operable_roles"`
}
// 全局配置实例(初始化后只读)
// App 全局配置实例(初始化后只读)
var App *Config
// Load 加载配置config.yaml → .env 覆盖
@ -110,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() {
@ -129,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/metalab/internal/model"
"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
}
@ -112,24 +121,86 @@ func (s *SiteSettings) IsAuditTypeEnabled(auditType string, defaultVal bool) boo
// SiteInfoDefaults 站点展示信息的默认值
type SiteInfoDefaults struct {
Framework string // 附加标识,默认 "Framework: MetaZone"
SiteName string // 站点名称,默认 "MetaLab"
SiteTagline string // 站点标语,默认 "下一代开发者社区"
ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com"
Framework string // 附加标识,默认 "Framework: MetaGenesis"
CopyrightStart string // 版权起始年份,默认 "2024"
OperatorName string // 运营主体
ICPNumber string // ICP 备案HTML含链接
ICPLicense string // ICP 经营许可证HTML
PoliceNumber string // 公安备案HTML
WenWangWen string // 文网文HTML
AlgorithmFiling string // 算法备案HTML
PrivacyURL string // 隐私政策HTML
TermsURL string // 服务条款HTML
}
// SiteInfo 获取站点展示信息(优先 DB 设置fallback 默认值)
func (s *SiteSettings) SiteInfo() SiteInfoDefaults {
return SiteInfoDefaults{
Framework: s.Get("site.framework", "Framework: MetaZone"),
SiteName: s.Get("site.name", "MetaLab"),
SiteTagline: s.Get("site.tagline", "下一代开发者社区"),
ContactEmail: s.Get("site.contact_email", "metazone@foxmail.com"),
Framework: s.Get("site.framework", "Framework: MetaGenesis"),
CopyrightStart: s.Get("site.copyright_start", "2024"),
OperatorName: s.Get("site.operator_name", ""),
ICPNumber: s.Get("site.icp_number", ""),
ICPLicense: s.Get("site.icp_license", ""),
PoliceNumber: s.Get("site.police_number", ""),
WenWangWen: s.Get("site.wenwangwen", ""),
AlgorithmFiling: s.Get("site.algorithm_filing", ""),
PrivacyURL: s.Get("site.privacy_url", ""),
TermsURL: s.Get("site.terms_url", ""),
}
}
// CookieSecure 返回 cookie_secure 配置值优先站点设置fallback config.yaml
func (s *SiteSettings) CookieSecure() bool {
return s.GetBool("site.cookie_secure", App.Server.CookieSecure)
}
// IsRegistrationEnabled 是否允许新用户注册
func (s *SiteSettings) IsRegistrationEnabled() bool {
return s.GetBool("registration.enabled", true)
}
// ShowGuidelinesModal 注册时是否弹窗显示社区准则
func (s *SiteSettings) ShowGuidelinesModal() bool {
return s.GetBool("registration.show_guidelines", true)
}
// ForceGuidelines 是否强制阅读准则(需要倒计时+滚动到底部)
func (s *SiteSettings) ForceGuidelines() bool {
return s.GetBool("registration.force_guidelines", true)
}
// PasswordStrength 密码强度级别
func (s *SiteSettings) PasswordStrength() string {
level := s.Get("password.strength", "medium")
switch level {
case "low", "medium", "high", "very_high":
return level
}
return "medium"
}
// GuidelinesTimer 强制阅读倒计时秒数
func (s *SiteSettings) GuidelinesTimer() int {
val := s.Get("registration.guidelines_timer", "60")
n, err := strconv.Atoi(val)
if err != nil || n < 1 {
return 60
}
return n
}
// IsMaintenanceEnabled 是否处于维护模式(维护期间仅站长可登录和访问)
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

@ -4,8 +4,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -1,12 +1,14 @@
// Package admin 提供管理后台的 HTTP 控制器:用户管理、审核、站点设置等。
package admin
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"github.com/gin-gonic/gin"
)
@ -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,11 +1,12 @@
package admin
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -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

@ -3,8 +3,8 @@ package admin
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -5,8 +5,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -103,6 +103,44 @@ func (ctrl *AdminPostController) Unlock(c *gin.Context) {
common.OkMessage(c, "已解锁")
}
// Pin 置顶文章
func (ctrl *AdminPostController) Pin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req struct {
PinType string `json:"pin_type" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || (req.PinType != model.PinTypeCategory && req.PinType != model.PinTypeGlobal) {
common.Error(c, http.StatusBadRequest, "无效的置顶类型")
return
}
if err := ctrl.postService.Pin(uint(id), req.PinType); err != nil {
common.Error(c, http.StatusInternalServerError, "置顶失败")
return
}
common.OkMessage(c, "已置顶")
}
// Unpin 取消置顶
func (ctrl *AdminPostController) Unpin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Unpin(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "取消置顶失败")
return
}
common.OkMessage(c, "已取消置顶")
}
// Restore 恢复软删除
func (ctrl *AdminPostController) Restore(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)

View File

@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -0,0 +1,102 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"github.com/gin-gonic/gin"
)
// AnnouncementController 公告管理控制器
type AnnouncementController struct {
svc announcementUseCase
}
type announcementUseCase interface {
Create(a *model.Announcement) error
Update(a *model.Announcement) error
Delete(id uint) error
List(offset, limit int) ([]model.Announcement, int64, error)
ListActive() ([]model.Announcement, error)
}
// NewAnnouncementController 构造函数
func NewAnnouncementController(svc announcementUseCase) *AnnouncementController {
return &AnnouncementController{svc: svc}
}
// Page 公告管理页面
func (ac *AnnouncementController) Page(c *gin.Context) {
c.HTML(http.StatusOK, "admin/announcements/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "公告管理",
"ExtraCSS": "/admin/static/css/announcements.css",
"ExtraJS": "/admin/static/js/announcements.js",
"Types": service.AnnouncementTypes,
}))
}
// List 公告列表 API
func (ac *AnnouncementController) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
offset := (page - 1) * pageSize
list, total, err := ac.svc.List(offset, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, gin.H{"list": list, "total": total})
}
// Create 创建公告 API
func (ac *AnnouncementController) Create(c *gin.Context) {
var req model.Announcement
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
if err := ac.svc.Create(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "创建失败")
return
}
common.OkMessage(c, "公告已创建")
}
// Update 更新公告 API
func (ac *AnnouncementController) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
var req model.Announcement
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
req.ID = uint(id)
if err := ac.svc.Update(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "更新失败")
return
}
common.OkMessage(c, "公告已更新")
}
// Delete 删除公告 API
func (ac *AnnouncementController) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
if err := ac.svc.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "公告已删除")
}

View File

@ -1,11 +1,12 @@
package admin
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -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

@ -0,0 +1,100 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
// CategoryController 分类管理控制器
type CategoryController struct {
svc categoryUseCase
}
type categoryUseCase interface {
Create(c *model.Category) error
Update(c *model.Category) error
Delete(id uint) error
Tree() ([]model.Category, error)
}
// NewCategoryController 构造函数
func NewCategoryController(svc categoryUseCase) *CategoryController {
return &CategoryController{svc: svc}
}
// Page 分类管理页面
func (cc *CategoryController) Page(c *gin.Context) {
tree, _ := cc.svc.Tree()
c.HTML(http.StatusOK, "admin/categories/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "分类管理",
"ExtraCSS": "/admin/static/css/categories.css",
"ExtraJS": "/admin/static/js/categories.js",
"Tree": tree,
}))
}
// List 分类列表 API
func (cc *CategoryController) List(c *gin.Context) {
tree, err := cc.svc.Tree()
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
if tree == nil {
tree = []model.Category{}
}
common.Ok(c, tree)
}
// Create 创建分类
func (cc *CategoryController) Create(c *gin.Context) {
var req model.Category
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
if err := cc.svc.Create(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "创建失败")
return
}
common.OkMessage(c, "分类已创建")
}
// Update 更新分类
func (cc *CategoryController) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
var req model.Category
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
req.ID = uint(id)
if err := cc.svc.Update(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "更新失败")
return
}
common.OkMessage(c, "分类已更新")
}
// Delete 删除分类
func (cc *CategoryController) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
if err := cc.svc.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "分类已删除")
}

View File

@ -1,9 +1,9 @@
package admin
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"metazone.cc/mce/internal/session"
)
// adminUseCase AdminController 对 AdminService 的最小依赖ISP6 个方法)
@ -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
@ -36,7 +37,7 @@ type auditStatusProvider interface {
FindByID(id uint) (*model.User, error)
}
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP7 个方法)
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP9 个方法)
type adminPostUseCase interface {
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
@ -44,6 +45,8 @@ type adminPostUseCase interface {
Reject(postID uint, reason string) error
Lock(postID uint, reason string) error
Unlock(postID uint) error
Pin(postID uint, pinType string) error
Unpin(postID uint) error
Restore(postID uint) error
}

View File

@ -1,11 +1,13 @@
package admin
import (
"log"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/service"
"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,10 +1,11 @@
package controller
import (
"errors"
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -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
@ -78,7 +78,7 @@ func (ac *AuthController) Login(c *gin.Context) {
return
}
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg, ac.siteSettings)
common.OkWithMessage(c, user, "登录成功")
}
@ -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
@ -108,6 +107,6 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
return
}
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg, ac.siteSettings)
common.OkWithMessage(c, user, "注销已撤销,欢迎回来")
}

View File

@ -1,10 +1,11 @@
package controller
import (
"errors"
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -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
@ -69,9 +69,9 @@ func (ac *AuthController) Register(c *gin.Context) {
return
}
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg, ac.siteSettings)
common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab")
common.OkWithMessage(c, user, "注册成功!欢迎加入 "+ac.siteSettings.SiteInfo().SiteName)
}
// Logout 退出登录:删除服务端 session + 清除 Cookie
@ -79,7 +79,7 @@ func (ac *AuthController) Logout(c *gin.Context) {
if sid, err := c.Cookie(common.SessionCookieName); err == nil && sid != "" {
_ = ac.sessionManager.Destroy(sid)
}
common.ClearSessionCookie(c, ac.cfg)
common.ClearSessionCookie(c, ac.cfg, ac.siteSettings)
common.OkMessage(c, "已退出登录")
}
@ -93,7 +93,7 @@ func (ac *AuthController) RefreshToken(c *gin.Context) {
s, err := ac.sessionManager.Validate(sid)
if err != nil || s == nil {
common.ClearSessionCookie(c, ac.cfg)
common.ClearSessionCookie(c, ac.cfg, ac.siteSettings)
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
return
}

View File

@ -3,11 +3,12 @@ package controller
import (
"html/template"
"net/http"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/session"
"metazone.cc/metalab/internal/theme"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/session"
"metazone.cc/mce/internal/theme"
"github.com/gin-gonic/gin"
)
@ -36,20 +37,32 @@ 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
}
guidelines = content
}
// 替换准则中的硬编码站点名和邮箱
siteInfo := ac.siteSettings.SiteInfo()
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",
"Guidelines": guidelines,
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
"PasswordStrength": ac.siteSettings.PasswordStrength(),
"ShowGuidelinesModal": ac.siteSettings.ShowGuidelinesModal(),
"ForceGuidelines": ac.siteSettings.ForceGuidelines(),
"GuidelinesTimer": ac.siteSettings.GuidelinesTimer(),
}))
}

View File

@ -5,8 +5,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -41,7 +41,7 @@ func (ctrl *CommentController) Delete(c *gin.Context) {
// SearchUsers GET /api/users/search?q=keyword
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
_, _, ok := common.GetGinUser(c)
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
@ -53,7 +53,7 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) {
return
}
users, err := ctrl.commentService.SearchUsers(q)
users, err := ctrl.commentService.SearchUsers(q, uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
return

View File

@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -13,7 +13,7 @@ import (
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)
@ -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

@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -1,11 +1,12 @@
package controller
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -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

@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -1,10 +1,11 @@
package controller
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)
@ -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

@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"github.com/gin-gonic/gin"
)
@ -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,12 +1,13 @@
// Package controller 定义 HTTP 请求处理层,通过 ISP 接口隔离依赖 Service。
package controller
import (
"io"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
"metazone.cc/mce/internal/middleware"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"metazone.cc/mce/internal/session"
)
// authUseCase AuthController 对 AuthService 的最小依赖ISP5 个方法)
@ -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)
@ -42,9 +40,9 @@ type postUseCase interface {
// studioUseCase StudioController 对 PostService 的最小依赖ISP9 个方法)
type studioUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
Update(postID uint, title, body string) error
Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) error
Delete(postID uint) error
SubmitForAudit(postID uint) error
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
@ -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)
@ -115,7 +105,7 @@ type commentUseCase interface {
Delete(commentID uint, requesterID uint, isAdmin bool) error
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
ListReplies(rootID uint) ([]model.Comment, error)
SearchUsers(keyword string) ([]model.UserSearchResult, error)
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
}
// reactionUseCase ReactionController 对 ReactionService 的最小依赖ISP4 个方法)

View File

@ -3,7 +3,7 @@ package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -4,8 +4,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -4,8 +4,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -5,8 +5,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -6,8 +6,8 @@ import (
"encoding/hex"
"image"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin"

View File

@ -6,8 +6,8 @@ import (
"strconv"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -14,7 +14,7 @@ import (
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin"
@ -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

@ -4,8 +4,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/service"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/service"
"github.com/gin-gonic/gin"
)

View File

@ -3,7 +3,7 @@ package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -3,7 +3,7 @@ package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -3,8 +3,8 @@ package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -1,11 +1,12 @@
package controller
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -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

@ -3,7 +3,7 @@ package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -4,9 +4,9 @@ import (
"net/http"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/session"
"github.com/gin-gonic/gin"
)
@ -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,41 +1,40 @@
package controller
import (
"errors"
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)
// 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

@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)
@ -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

@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"github.com/gin-gonic/gin"
)

View File

@ -6,7 +6,7 @@ import (
"strconv"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)

View File

@ -1,7 +1,8 @@
package controller
import (
"metazone.cc/metalab/internal/service"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
)
// trendsUseCase StudioController 对趋势服务的最小依赖ISP
@ -9,11 +10,24 @@ type trendsUseCase interface {
GetTrends(userID uint, since string) (*service.TrendResult, error)
}
// tagParser ISP标签解析
type tagParser interface {
ParseAndSave(tagStr string) ([]uint, error)
SetPostTags(postID uint, tagStr string) error
}
// StudioController 创作中心控制器SSR 页面 + API
type StudioController struct {
postService studioUseCase
energySvc energyDeducter
trendsSvc trendsUseCase
tagSvc tagParser
categorySvc categoryLoader
}
// categoryLoader ISP分类叶子节点查询
type categoryLoader interface {
GetLeaves() ([]model.Category, error)
}
// NewStudioController 构造函数
@ -32,3 +46,15 @@ func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioContro
ctrl.trendsSvc = svc
return ctrl
}
// WithTagService 链式注入标签服务
func (ctrl *StudioController) WithTagService(svc tagParser) *StudioController {
ctrl.tagSvc = svc
return ctrl
}
// WithCategoryService 链式注入分类服务
func (ctrl *StudioController) WithCategoryService(svc categoryLoader) *StudioController {
ctrl.categorySvc = svc
return ctrl
}

View File

@ -5,8 +5,8 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -1,11 +1,12 @@
package controller
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
@ -30,12 +31,21 @@ func (ctrl *StudioController) Create(c *gin.Context) {
return
}
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
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 errors.Is(err, common.ErrScheduledTooSoon) {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Error(c, http.StatusInternalServerError, "发布失败")
return
}
// 关联标签
if ctrl.tagSvc != nil && req.Tags != "" {
_ = ctrl.tagSvc.SetPostTags(post.ID, req.Tags)
}
common.OkWithMessage(c, post, "发布成功")
}
@ -69,10 +79,15 @@ func (ctrl *StudioController) Update(c *gin.Context) {
return
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt); err != nil {
common.Error(c, http.StatusInternalServerError, "编辑失败")
return
}
// 更新标签
if ctrl.tagSvc != nil {
_ = ctrl.tagSvc.SetPostTags(uint(id), req.Tags)
}
common.OkMessage(c, "编辑成功")
}

View File

@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)
@ -17,6 +17,16 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
return
}
// 加载分类列表(叶子节点)
var categories []interface{}
if ctrl.categorySvc != nil {
if leaves, err := ctrl.categorySvc.GetLeaves(); err == nil {
for _, cat := range leaves {
categories = append(categories, cat)
}
}
}
// 支持编辑模式:传入 post_id 参数
postIDStr := c.Query("id")
if postIDStr != "" {
@ -36,6 +46,7 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
"Post": post,
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
"Categories": categories,
}))
return
}
@ -46,6 +57,7 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
"Title": "写文章 - 创作中心",
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
"Categories": categories,
}))
}

View File

@ -0,0 +1,54 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
// TagController 标签控制器(标签落地页)
type TagController struct {
svc tagUseCase
}
type tagUseCase interface {
GetPostsByTag(slug string, page, pageSize int) ([]model.Post, int64, *model.Tag, error)
}
// NewTagController 构造函数
func NewTagController(svc tagUseCase) *TagController {
return &TagController{svc: svc}
}
// TagPage 标签落地页
func (tc *TagController) TagPage(c *gin.Context) {
slug := c.Param("slug")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize := 20
posts, total, tag, err := tc.svc.GetPostsByTag(slug, page, pageSize)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ErrorMsg": "该标签不存在",
}))
return
}
totalPages := common.PageCount(total, pageSize)
c.HTML(http.StatusOK, "tags/detail.html", common.BuildPageData(c, gin.H{
"Title": "标签:" + tag.Name,
"ExtraCSS": "/static/css/posts.css",
"Posts": posts,
"Total": total,
"Page": page,
"TotalPages": totalPages,
"PrevPage": max(1, page-1),
"NextPage": min(totalPages, page+1),
"Tag": tag,
}))
}

View File

@ -4,7 +4,7 @@ import (
"log"
"net/http"
"metazone.cc/metalab/internal/model"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)

View File

@ -4,10 +4,10 @@ import (
"net/http"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/session"
"github.com/gin-gonic/gin"
)
@ -22,6 +22,7 @@ type AuthMiddleware struct {
cfg *config.Config
sessionManager *session.Manager
levelSvc autoCheckIner
siteSettings *config.SiteSettings
}
// NewAuthMiddleware 构造函数
@ -29,6 +30,12 @@ func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
}
// WithSiteSettings 链式注入站点设置(用于 cookie_secure 运行时覆盖)
func (am *AuthMiddleware) WithSiteSettings(ss *config.SiteSettings) *AuthMiddleware {
am.siteSettings = ss
return am
}
// WithLevelService 链式注入等级服务(用于自动签到)
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
am.levelSvc = svc
@ -60,7 +67,7 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
// abortUnauthorized 统一处理未认证API 返回 JSON页面请求跳转登录
func (am *AuthMiddleware) abortUnauthorized(c *gin.Context) {
common.ClearSessionCookie(c, am.cfg)
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false, "message": "登录已过期,请重新登录",
@ -81,7 +88,7 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
common.ClearSessionCookie(c, am.cfg)
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
c.Next()
return
}

View File

@ -4,7 +4,7 @@ import (
"log"
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/mce/internal/common"
"github.com/gin-gonic/gin"
)
@ -14,7 +14,7 @@ func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) {
sid, err := c.Cookie(common.SessionCookieName)
if err != nil || sid == "" {
common.ClearSessionCookie(c, am.cfg)
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
@ -23,7 +23,7 @@ func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
log.Printf("[AdminAuth] session invalid path=%s → 302", c.Request.URL.Path)
common.ClearSessionCookie(c, am.cfg)
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
c.Redirect(http.StatusFound, "/")
c.Abort()
return

View File

@ -1,3 +1,4 @@
// Package middleware 提供 HTTP 中间件认证、授权、CSRF、安全头、限流等。
package middleware
import (
@ -5,7 +6,7 @@ import (
"log"
"net/http"
"metazone.cc/metalab/internal/config"
"metazone.cc/mce/internal/config"
"github.com/gin-gonic/gin"
)

View File

@ -5,7 +5,7 @@ import (
"encoding/hex"
"net/http"
"metazone.cc/metalab/internal/config"
"metazone.cc/mce/internal/config"
"github.com/gin-gonic/gin"
)
@ -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

@ -4,10 +4,10 @@ import (
"net/http"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/session"
"github.com/gin-gonic/gin"
)

View File

@ -3,8 +3,7 @@ package middleware
import (
"crypto/rand"
"encoding/base64"
"metazone.cc/metalab/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

@ -1,7 +1,7 @@
package middleware
import (
"metazone.cc/metalab/internal/config"
"metazone.cc/mce/internal/config"
"github.com/gin-gonic/gin"
)
@ -11,9 +11,21 @@ import (
func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc {
return func(c *gin.Context) {
info := siteSettings.SiteInfo()
c.Set("site_name", info.SiteName)
c.Set("site_tagline", info.SiteTagline)
c.Set("site_contact_email", info.ContactEmail)
c.Set("site_framework", info.Framework)
c.Set("site_copyright_start", info.CopyrightStart)
c.Set("site_operator_name", info.OperatorName)
c.Set("site_icp_number", info.ICPNumber)
c.Set("site_icp_license", info.ICPLicense)
c.Set("site_police_number", info.PoliceNumber)
c.Set("site_wenwangwen", info.WenWangWen)
c.Set("site_algorithm_filing", info.AlgorithmFiling)
c.Set("site_privacy_url", info.PrivacyURL)
c.Set("site_terms_url", info.TermsURL)
c.Set("is_maintenance", siteSettings.IsMaintenanceEnabled())
c.Set("registration_guidelines_timer", siteSettings.GuidelinesTimer())
c.Next()
}
}

View File

@ -0,0 +1,16 @@
package model
import "time"
// Announcement 公告
type Announcement struct {
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
IsActive bool `gorm:"default:true" json:"is_active"`
StartAt *time.Time `json:"start_at,omitempty"`
EndAt *time.Time `json:"end_at,omitempty"`
Sort int `gorm:"default:0" json:"sort"`
}

View File

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

View File

@ -0,0 +1,15 @@
package model
// Category 文章分类(两级树形结构)
type Category struct {
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"`
// 非 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"`
@ -25,16 +24,22 @@ type Comment struct {
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
// Mentions 有效@提及映射 username -> uid非DB字段批量查询填充
// 前端据此判断 @username 是否为有效提及并生成链接
Mentions map[string]uint `gorm:"-" json:"mentions,omitempty"`
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}非DB字段批量查询填充
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
}
// CommentMention @提及映射绑定UID不是username,支持改名后自动更新显示名)
// CommentMention @提及映射绑定UID + 原始用户名,支持改名后自动更新显示名)
type CommentMention struct {
ID uint `gorm:"primarykey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
}
// MentionInfo @提及显示信息(前端渲染用)
type MentionInfo struct {
UID uint `json:"uid"`
Name string `json:"name"` // 当前显示名
}
// UserSearchResult @搜索用户结果

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

@ -34,12 +34,28 @@ type PostListResult struct {
type PostCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
Visibility string `json:"visibility"`
PostType string `json:"post_type"`
ReprintSource string `json:"reprint_source"`
Declaration string `json:"declaration"`
ReprintProhibited bool `json:"reprint_prohibited"`
CategoryID string `json:"category_id"`
Tags string `json:"tags"`
ScheduledAt string `json:"scheduled_at"`
}
// PostUpdateRequest 编辑请求
type PostUpdateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
Visibility string `json:"visibility"`
PostType string `json:"post_type"`
ReprintSource string `json:"reprint_source"`
Declaration string `json:"declaration"`
ReprintProhibited bool `json:"reprint_prohibited"`
CategoryID string `json:"category_id"`
Tags string `json:"tags"`
ScheduledAt string `json:"scheduled_at"`
}
// PostRejectRequest 退回请求

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

@ -13,6 +13,7 @@ const (
NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
NotifyCommentMention = "comment_mention" // 评论@提及通知
)
// Notification 消息/通知模型
@ -39,6 +40,7 @@ var NotifyTypeNames = map[string]string{
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
NotifyCommentMention: "@ 提及",
NotifyLikeAggregated: "点赞",
NotifyFollow: "关注",
}
@ -54,6 +56,7 @@ var NotifyCategory = map[string]string{
NotifyLevelUp: "system",
NotifyComment: "mention",
NotifyCommentReply: "mention",
NotifyCommentMention: "mention",
NotifyLikeAggregated: "like",
NotifyFollow: "follow",
}

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
@ -19,6 +16,18 @@ type Post struct {
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
// 文章属性
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private
PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能乘10冗余计数
@ -26,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"`
}
@ -44,3 +48,32 @@ const (
PostStatusRejected = "rejected"
PostStatusLocked = "locked"
)
// 可见性常量
const (
VisibilityPublic = "public"
VisibilityPrivate = "private"
)
// 文章类型常量
const (
PostTypeOriginal = "original"
PostTypeReprint = "reprint"
)
// 置顶类型常量
const (
PinTypeCategory = "category"
PinTypeGlobal = "global"
)
// DeclarationLabels 创作声明名称映射
var DeclarationLabels = map[string]string{
"ai_generated": "该内容使用AI辅助创作",
"fictional": "该内容包含虚构情节",
"speculative": "该内容包含猜测/推测性内容",
"disturbing": "该内容可能包含令人不适的内容",
"spoiler": "该内容包含剧透内容",
"controversial": "该内容可能涉及争议性话题",
"opinion": "本文仅代表作者个人观点",
}

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

17
internal/model/tag.go Normal file
View File

@ -0,0 +1,17 @@
package model
import "time"
// Tag 标签
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"type:varchar(50);not null" json:"name"`
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
CreatedAt time.Time `json:"created_at"`
}
// PostTag 文章-标签关联
type PostTag struct {
PostID uint `gorm:"primarykey" json:"post_id"`
TagID uint `gorm:"primarykey" json:"tag_id"`
}

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

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