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 -