21 KiB
开发记录 — 阶段一:基础设施 + 用户认证(含"记住我"与令牌刷新)
最后更新: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 handlerinternal/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():
func BuildPageData(c *gin.Context, extra gin.H) gin.H
- 将
extra键值对复制到gin.H - 若 Gin 上下文中存在
username(由AuthOptional中间件注入),自动添加:IsLoggedIn: trueUsername: "..."
- 所有页面 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 设计目标
| 场景 | 行为 |
|---|---|
| 用户勾选"记住我" | 双 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)
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)
LoginRequest 和 RegisterRequest 都新增了 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 天过期 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 界面
登录页和注册页各增加一个复选框:
<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):
{{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 最终会自然过期)。
二、解决方案建议
2.1 PostgreSQL 密码认证(问题一记录)
系统信息:
- 分布:Linux Mint
- 数据库用户:
metazone - 连接方式:TCP
127.0.0.1:5432(非 Unix socket) - 认证方式:
scram-sha-256 - 密码:
MetalabDev2026!(存储于.env→DATABASE_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 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),预留了路由和接口 |
| ✅ 已完成 | POST /api/auth/logout → 清除三个 Cookie + 跳转首页 |
||
| P1 | 首页完善 | 待做 | 当前仅有 Hero 区域,需添加帖子列表、话题导航等 |
| P1 | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
| P1 | 用户设置页 | 待做 | /settings — 修改用户名/头像/密码/个人简介 |
| P2 | 管理后台 | 待做 | /admin — 用户管理、内容审核、主题切换 |
| ✅ 已完成 | 记住我 + 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 静默调用 |