From 3d69dae799478371400079705f0eaedbdaca4746 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Mon, 22 Jun 2026 03:12:29 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20development.md=20?= =?UTF-8?q?=E5=88=B0=20v0.1.0-rc1=EF=BC=8C=E8=A6=86=E7=9B=96=E5=85=A8?= =?UTF-8?q?=E9=83=A8=20Phase=20=E5=8F=8A=E5=AE=A1=E8=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development.md | 706 +++++++++++--------------------------------- 1 file changed, 169 insertions(+), 537 deletions(-) diff --git a/docs/development.md b/docs/development.md index ef2a3e7..301c019 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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/ConfirmPassword(binding 标签) - → Service.Register() - → validatePassword():≥8位 + 含字母 + 含数字 - → userRepo.FindByEmail():邮箱查重 - → bcrypt.GenerateFromPassword(,12):密码哈希 - → generateUsername():生成 10 位随机用户名,重试 20 次去重 - → userRepo.Create():写入数据库 - → buildToken():生成 access JWT(HS256,15 分钟) - → 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT(HS256,30 天) - → 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/owner,OCP 可扩展) +- `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 模型:Visibility(public/private)、PostType(original/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 告警 → 0,CI 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 handler(38 个 controller 文件) +│ │ └── admin/ # 管理后台 controller(7 个) +│ ├── middleware/ # 中间件:认证/CSRF/安全头/限流/维护/DB健康 +│ ├── router/ # 路由注册 + 依赖注入(按域拆分) +│ ├── session/ # 服务端 Session:Redis + 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 -
- - 非共享设备推荐 -
-``` -**默认不勾选**——公共环境(教室、网吧、共享电脑)不会意外留下长期凭据。 - ---- - -### 12. 登出功能 - -#### 12.1 后端 API - -**路由**:`POST /api/auth/logout` - -**处理流程**(`auth_controller.go` → `Logout()`): -``` -1. middleware.ClearAuthCookies(c, cfg) - → 清除 mlb_token、mlb_refresh、mlb_rm 三个 Cookie(MaxAge=-1) -2. 返回 JSON { success: true, message: "已退出登录" } -``` - -#### 12.2 前端 - -**NAV 导航栏**(`nav.html`): -```html -{{if .IsLoggedIn}} -
  • {{.Username}}
  • -
  • 退出
  • -{{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 token。refresh 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.2(17 个 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 # BaseModel(uid 主键 + 时间戳 + 软删除) -│ ├── user.go # User 结构体 + Role/Status 常量 -│ └── dto.go # RegisterRequest / LoginRequest 含 RememberMe -├── repository/ -│ └── user_repo.go # UserRepo(Create/FindByEmail/FindByID/FindByUsername/ExistsByEmail/ExistsByUsername) -├── service/ -│ └── auth_service.go # AuthService(Login/Register/RefreshAccessToken + buildToken/buildRefreshToken + 8 个错误哨兵) -├── controller/ -│ └── auth_controller.go # AuthController(RegisterPage/LoginPage/Register/Login/Logout/RefreshToken/CheckEmail) -├── middleware/ -│ └── auth.go # AuthRequired/AuthOptional/SetAuthCookies/SetAccessCookie/ClearAuthCookies/parseToken -│ # 三个 Cookie:mlb_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 head(title/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 min,HttpOnly + Secure + SameSite=Lax | -| Refresh Token Cookie | `mlb_refresh`,JWT(HS256),30 day(记住我时),HttpOnly + Secure + SameSite=Lax,含 `purpose:"refresh"` 声明 | -| 记住我标记 Cookie | `mlb_rm`,值 `"1"`,30 day(记住我时),非 HttpOnly(JS 可读),非凭据 | -| 密码哈希 | 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` | ⬜ 暂缓 | +| — | 黑名单系统 | ⬜ 未来迭代 | +| — | 首页推荐算法 | ⬜ 未来迭代 | +| — | 模板审计修复 | ⬜ 未来迭代 |