Files
mce/docs/development.md

21 KiB
Raw Blame History

开发记录 — 阶段一:基础设施 + 用户认证(含"记住我"与令牌刷新)

最后更新2026-05-24

一、已完成事项

1. 数据库配置与连接

问题PostgreSQL 使用 peer 认证Go 进程以系统用户 victor_jay 运行,无法连接数据库用户 metazone

解决

  • metazone 数据库用户设置密码(MetalabDev2026!
  • 连接方式从 Unix socket/var/run/postgresql)改为 TCP127.0.0.1:5432),触发 scram-sha-256 密码认证
  • 数据库名称:metalab_dev

涉及文件config.yamldatabase.host 改为 127.0.0.1)、.envDATABASE_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

关键设计决策:

决策项 选择 原因
主键方案 自增 bigintuid 列)而非 UUID 社区应用索引性能更好URL 简洁 /users/42JOIN 体积小
角色控制 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.goRegister() POST handler
  • internal/service/auth_service.goRegister()validatePassword()generateUsername()buildToken()buildRefreshToken()
  • internal/repository/user_repo.goCreate()FindByEmail()
  • internal/model/dto.goRegisterRequestbinding 校验标签 + 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.goLogin())、auth_service.goLogin()ErrUserBannedbuildRefreshToken()


6. JWT 认证中间件

中间件 用途 注入上下文的值
AuthRequired 严格认证,失败返回 401 uidemailusernamerole
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()

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 导航栏逻辑

{{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 设计目标

场景 行为
用户勾选"记住我" 双 Cookiemlb_token(15min) + mlb_refresh(30d)
用户未勾选 单 Cookiemlb_token(15min),过期需重新登录
页面持续开着 前端每 10 分钟静默调用 /api/auth/refresh 续期 access token
关闭浏览器 15+ min 后回来 access token 过期 → JS 自动刷新 → 页面 reload 恢复登录态
30 天内任意时间回来 refresh token 仍有效,自动静默恢复登录
Cookie 名 作用 HttpOnly JS 可读 过期时间 说明
mlb_token access token 15 min JWTuid/email/username/role/exp/iat
mlb_refresh refresh token 30 day勾选记住我时 JWTuid/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)

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

JWTConfig 新增字段:

RememberExpire int `mapstructure:"remember_expire"` // 小时

关键mapstructure 标签不可省略,否则 Viper 无法将 YAML 键映射到结构体字段,导致过期时间全为 0。

11.4 请求 DTO (dto.go)

LoginRequestRegisterRequest 都新增了 RememberMe 字段:

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 天过期 JWTpurpose 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() 而非 XMLHttpRequestfetch 对 HTTP 非 2xx 不会触发浏览器控制台静默日志
  • sessionStorage 防无限循环:mlb_refreshed 标志确保 reload 只执行一次
  • 不在认证页面执行:/auth/login/auth/register 路径直接跳过

11.7 界面

登录页和注册页各增加一个复选框:

<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.goLogout()

1. middleware.ClearAuthCookies(c, cfg)
   → 清除 mlb_token、mlb_refresh、mlb_rm 三个 CookieMaxAge=-1
2. 返回 JSON { success: true, message: "已退出登录" }

12.2 前端

NAV 导航栏nav.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.goRefreshToken()

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 最终会自然过期)。


二、解决方案建议

2.1 PostgreSQL 密码认证(问题一记录)

系统信息

  • 分布Linux Mint
  • 数据库用户:metazone
  • 连接方式TCP 127.0.0.1:5432(非 Unix socket
  • 认证方式:scram-sha-256
  • 密码:MetalabDev2026!(存储于 .envDATABASE_PASSWORD
  • 数据库:metalab_dev

验证命令

# 测试连接
PGPASSWORD='MetalabDev2026!' psql -h 127.0.0.1 -U metazone -d metalab_dev -c "SELECT current_user, version();"

2.2 .env 文件结构

# 必填
DATABASE_PASSWORD=MetalabDev2026!
JWT_SECRET=p2BVUsELbny4W4sYNvoyFesyglD2TlPRT+bzpDk8Dq/E2s9tZFzT6jC8N3AvQpzE

.env 已加入 .gitignore,提供 .env.example 模板文件。


三、代码规范新增/修改

3.1 新增规范

编号 规范 说明
C01 BuildPageData() 统一模板数据注入 所有页面 Controller 必须通过此函数构建 gin.H,禁止手动拼装 IsLoggedIn/Username
C02 Cookie 认证优先 使用 HttpOnly Cookiemlb_token)传递 JWT而非 Authorization Header兼顾安全与 SSR
C03 主键列名:uid Go 侧字段名 IDGORM 标签 column:uidJSON 序列化 "uid"
C04 用户名10 位随机 [a-z0-9] 使用 crypto/rand(非 math/rand20 次重试去重
C05 配置 env 注入模式 .env 文件通过 os.Setenv() 注入环境变量Viper 通过 BindEnv + AutomaticEnv 读取。不再使用 viper 的 .env 文件加载
C06 模板名:相对路径 layout/header.htmlauth/register.html 等。禁止用纯文件名避免冲突

3.2 修改的已有规范

编号 变更
M01 用户标识字段 nickname(非唯一) username全局唯一varchar(16)
M02 JSON 序列化字段名 "id" "uid"
M03 BaseModel.ID GORM 列名 id(默认) uidcolumn: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 — 修改用户名/头像/密码/个人简介
P2 管理后台 待做 /admin — 用户管理、内容审核、主题切换
P2 刷新令牌 已完成 记住我 + access/refresh 双 Token + 自动续期 + 登出
P3 多主题支持 待做 MetaLab-2026MetaLab-2027 切换

六、约定速查

类别 约定
数据库主键 Go: ID uintDB: uidbigint 自增JSON: "uid"
Access Token Cookie mlb_tokenJWT(HS256)15 minHttpOnly + Secure + SameSite=Lax
Refresh Token Cookie mlb_refreshJWT(HS256)30 day记住我时HttpOnly + Secure + SameSite=Laxpurpose:"refresh" 声明
记住我标记 Cookie mlb_rm,值 "1"30 day记住我时非 HttpOnlyJS 可读),非凭据
密码哈希 bcrypt(12)Go 字段 PasswordHashJSON 不暴露
用户名 10 位 [a-z0-9]crypto/rand 生成,全局唯一
角色 user / moderator / adminstring非 bool
配置 .envos.Setenv → viper BindEnv + AutomaticEnv
模板名 相对路径:layout/header.htmlauth/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 静默调用