# 开发记录 — 阶段一：基础设施 + 用户认证（含"记住我"与令牌刷新）

> 最后更新：2026-05-24

## 一、已完成事项

### 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
```

`{{template}}` 引用全部更新为这些相对路径名。

**涉及文件**：`internal/theme/loader.go`、所有 `.html` 模板文件中的 `{{template}}` 引用

---

### 11. "记住我" + 刷新令牌（Refresh Token）

#### 11.1 设计目标

| 场景 | 行为 |
|---|---|
| 用户勾选"记住我" | 双 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 仍有效，自动静默恢复登录 |

#### 11.2 Cookie 架构

| 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"`，仅标记位，非凭据 |

**设计决策 — `mlb_rm` 标记 Cookie**：
- 本质是**非安全标记位**（not a credential），用于 JS 判断"是否该尝试刷新"
- 如果不存在（未勾选记住我），`common.js` 跳过所有刷新逻辑 → **不会产生 401 控制台报错**
- 值仅为 `"1"`，即使被 XSS 读取也无法用于任何认证操作

#### 11.3 配置 (`config.yaml` + `config.go`)

```yaml
jwt:
  access_expire: 15     # 分钟
  refresh_expire: 168   # 小时 (7 天) — 备用字段，当前未启用
  remember_expire: 720  # 小时 (30 天) — refresh token 有效期
```

`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 三个 Cookie（MaxAge=-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 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 |

---

## 二、解决方案建议

### 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 静默调用 |
