diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f2e98de
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# 复制为 .env 后填入真实值
+# .env 会覆盖 config.yaml 中的同名字段
+
+DATABASE_PASSWORD=your_password_here
+JWT_SECRET=generate-a-random-64-char-string-here
diff --git a/.gitignore b/.gitignore
index 9092411..de6ad9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,52 +1,27 @@
-# ---> Go
-# If you prefer the allow list template instead of the deny list, see community template:
-# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
-#
-# Binaries for programs and plugins
+# 环境变量(含密钥,绝不能提交)
+.env
+
+# 编译产物
+/server
+cmd/server/server
*.exe
*.exe~
*.dll
*.so
*.dylib
-
-# Test binary, built with `go test -c`
*.test
-
-# Output of the go coverage tool, specifically when used with LiteIDE
*.out
-# Dependency directories (remove the comment below to include it)
-# vendor/
-
-# Go workspace file
+# Go workspace
go.work
go.work.sum
-# env file
-.env
-
-# ---> Go.AllowList
-# Allowlisting gitignore template for GO projects prevents us
-# from adding various unwanted local files, such as generated
-# files, developer configurations or IDE-specific files etc.
-#
-# Recommended: Go.AllowList.gitignore
-
-# Ignore everything
-*
-
-# But not these files...
-!/.gitignore
-
-!*.go
-!go.sum
-!go.mod
-
-!README.md
-!LICENSE
-
-# !Makefile
-
-# ...even if they are in subdirectories
-!*/
+# 运行时数据
+storage/uploads/
+storage/logs/
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..fcf24e5
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "log"
+
+ "metazone.cc/metalab/internal/config"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/router"
+ "metazone.cc/metalab/internal/theme"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+)
+
+func main() {
+ // 配置
+ cfg := config.Load("config.yaml")
+
+ // 数据库
+ db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{})
+ if err != nil {
+ log.Fatalf("连接数据库失败: %v", err)
+ }
+ if err := db.AutoMigrate(&model.User{}); err != nil {
+ log.Fatalf("数据库迁移失败: %v", err)
+ }
+
+ // 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
+ // 支持软删除用户邮箱复用
+ migrations := []string{
+ `DROP INDEX IF EXISTS idx_users_email`,
+ `CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_active ON users (email) WHERE deleted_at IS NULL`,
+ }
+ for _, m := range migrations {
+ if err := db.Exec(m).Error; err != nil {
+ log.Fatalf("数据库迁移失败 (%s): %v", m, err)
+ }
+ }
+ log.Println("数据库连接成功")
+
+ // Gin 模式
+ gin.SetMode(cfg.Server.Mode)
+
+ r := gin.Default()
+
+ // 模板加载(前端主题 + 管理后台)
+ tmpl, err := theme.LoadTemplates(
+ theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
+ theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
+ )
+ if err != nil {
+ panic("加载模板失败: " + err.Error())
+ }
+ r.SetHTMLTemplate(tmpl)
+
+ // 静态资源
+ r.Static("/static", "./templates/MetaLab-2026/static")
+ r.Static("/admin/static", "./templates/admin/static")
+
+ // 路由注册
+ router.Setup(r, db, cfg)
+
+ // 启动
+ log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
+ r.Run("0.0.0.0:" + cfg.Server.Port)
+}
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..273025e
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,23 @@
+# MetaZone.FAN 默认配置
+# 敏感值通过环境变量或 .env 覆盖
+
+server:
+ port: 8080
+ mode: debug # debug | release | test
+
+database:
+ host: 127.0.0.1
+ port: 5432
+ user: metazone
+ password: "" # 敏感值由 .env 注入
+ dbname: metalab_dev
+ sslmode: disable
+
+jwt:
+ secret: "" # 生产环境通过 JWT_SECRET 环境变量或 .env 注入
+ access_expire: 15 # 分钟
+ refresh_expire: 168 # 小时 (7 天)
+ remember_expire: 720 # 小时 (30 天)
+
+bcrypt:
+ cost: 12
diff --git a/docs/code-style.md b/docs/code-style.md
new file mode 100644
index 0000000..5968bcf
--- /dev/null
+++ b/docs/code-style.md
@@ -0,0 +1,111 @@
+# Go 代码规范
+
+## 文件大小
+
+| 层次 | 单文件行数上限 | 说明 |
+|---|---|---|
+| Controller | ≤ 120 行 | 只管参数绑定 + 调用服务 + 返回 |
+| Service | ≤ 300 行 | 承载业务逻辑,超过则拆子服务 |
+| Repository | ≤ 200 行 | 纯数据库操作 |
+| Model | ≤ 80 行 | 纯结构体 |
+| Middleware | ≤ 60 行 | 单一职责 |
+| Router | ≤ 60 行 | 只做路由映射 |
+
+## 瘦控制器铁律
+
+Controller 每个方法不超过三步:
+
+1. 绑定请求参数
+2. 调用 Service
+3. 返回响应
+
+```go
+// ✅ 正确
+func (c *PostController) Create(ctx *gin.Context) {
+ var req dto.CreatePostRequest
+ if err := ctx.ShouldBind(&req); err != nil {
+ common.Error(ctx, common.ErrInvalidParam, "参数错误")
+ return
+ }
+ post, err := c.postService.Create(ctx, req, getCurrentUser(ctx))
+ if err != nil {
+ common.Error(ctx, common.ErrInternal, "创建失败")
+ return
+ }
+ common.Ok(ctx, post)
+}
+
+// ❌ 错误:Controller 里写 if-else 业务分支
+func (c *PostController) Create(ctx *gin.Context) {
+ // ... 参数绑定 ...
+ if req.Type == "draft" {
+ // 草稿逻辑写在这里 ↓ 错误!
+ post.Status = 0
+ } else {
+ // 发布逻辑写在这里 ↓ 错误!
+ post.Status = 1
+ if post.Score > 100 { ... }
+ }
+}
+```
+
+业务判断全部放在 Service 层。
+
+## Controller 禁止事项
+
+- ❌ 直接调用 Repository
+- ❌ 调用另一个 Controller
+- ❌ 写 if-else 业务分支
+- ❌ 在 handler 里直接操作数据库
+- ❌ 代码回滚、重试等编排逻辑
+
+## 命名规范
+
+### 文件
+
+- Go 文件:`snake_case.go`
+- 模板文件:`snake_case.html`
+- CSS/JS 文件:`kebab-case.css` / `camelCase.js`
+
+### 结构体与方法
+
+- Controller:`type PostController struct{}`,方法 `func (c *PostController) Create(...)`
+- Service:`type PostService struct{ repo *PostRepo }`,方法 `func (s *PostService) Create(...)`
+- Repository:`type PostRepo struct{ db *gorm.DB }`,方法 `func (r *PostRepo) FindByID(...)`
+
+### 响应格式
+
+统一使用 `common` 包的响应函数:
+
+```go
+common.Ok(ctx, data) // 200 成功
+common.Error(ctx, common.ErrInvalidParam, msg) // 400 参数错误
+common.Error(ctx, common.ErrUnauthorized, msg) // 401 未认证
+common.Error(ctx, common.ErrForbidden, msg) // 403 无权限
+common.Error(ctx, common.ErrNotFound, msg) // 404 未找到
+common.Error(ctx, common.ErrInternal, msg) // 500 服务器错误
+```
+
+### 模板变量
+
+使用驼峰命名,与模板文件保持一致:
+
+```go
+gin.H{
+ "Title": "页面标题",
+ "ExtraCSS": "/static/css/page.css",
+ "Guidelines": template.HTML(content), // HTML 内容需显式标记类型
+}
+```
+
+## 依赖注入
+
+当前阶段 Controller 直接在方法内创建 Service 实例。后期引入 DI 容器后统一管理。
+
+```go
+// 当前
+func (ac *AuthController) RegisterPage(c *gin.Context) { ... }
+
+// 后期
+func NewAuthController(svc *AuthService) *AuthController { ... }
+```
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..59d8b92
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,531 @@
+# 开发记录 — 阶段一:基础设施 + 用户认证(含"记住我"与令牌刷新)
+
+> 最后更新: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
+
+
+ 非共享设备推荐
+
+```
+**默认不勾选**——公共环境(教室、网吧、共享电脑)不会意外留下长期凭据。
+
+---
+
+### 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 最终会自然过期)。
+
+---
+
+## 二、解决方案建议
+
+### 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` — 修改用户名/头像/密码/个人简介 |
+| **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 静默调用 |
diff --git a/docs/routing.md b/docs/routing.md
new file mode 100644
index 0000000..6c08287
--- /dev/null
+++ b/docs/routing.md
@@ -0,0 +1,119 @@
+# 路由格式规范
+
+## 核心原则
+
+**统一使用伪静态(Clean URL),禁止 `?action=` 参数路由。**
+
+### 为什么
+
+| `?action=` 模式 | 伪静态模式 |
+|---|---|
+| 一个路由承载多个功能 | 每个功能独立路由 |
+| 中间件无法精细化 | 路由组统一绑定中间件 |
+| 日志/监控无法区分操作 | 精确到每个 URL 的 QPS/延迟 |
+| SEO/收录不可控 | 每个页面独立收录 |
+| Controller 一个 handler 吃所有,变胖 | 每个 handler 单一职责 |
+
+### 对照
+
+```
+❌ /auth?action=register ✅ /auth/register
+❌ /auth?action=login ✅ /auth/login
+❌ /posts?action=view&id=1 ✅ /posts/1
+❌ /posts?action=list ✅ /posts
+❌ /posts?action=list&topic=go ✅ /topic/go
+❌ /users?action=profile&id=42 ✅ /users/42
+❌ /admin?action=theme ✅ /admin/theme
+```
+
+## 路由分层
+
+```
+前端页面路由(模板渲染) → router/web.go
+API 路由(JSON 响应) → router/api.go
+后台路由 → router/admin.go
+```
+
+## 格式约定
+
+### 资源路由
+
+| 路由 | 方法 | 说明 |
+|---|---|---|
+| `/posts` | GET | 帖子列表 |
+| `/posts/create` | GET | 发帖页面 |
+| `/posts` | POST | 创建帖子(API) |
+| `/posts/:id` | GET | 帖子详情 |
+| `/posts/:id/edit` | GET | 编辑页面 |
+| `/posts/:id` | PUT | 更新帖子(API) |
+| `/posts/:id` | DELETE | 删除帖子(API) |
+
+### 认证路由
+
+| 路由 | 说明 |
+|---|---|
+| `/auth/register` | 注册页 |
+| `/auth/login` | 登录页 |
+| `/auth/logout` | 登出 |
+| `/auth/verify-email` | 邮箱验证 |
+| `/auth/forgot-password` | 忘记密码 |
+| `/auth/reset-password` | 重置密码 |
+
+认证路由统一挂 `NoIndex` 中间件,禁止搜索引擎收录。
+
+### 用户路由
+
+| 路由 | 说明 |
+|---|---|
+| `/users/:id` | 用户主页 |
+| `/users/:id/posts` | 用户发布的帖子 |
+| `/users/:id/comments` | 用户发表的评论 |
+| `/settings` | 个人设置 |
+
+### 话题路由
+
+| 路由 | 说明 |
+|---|---|
+| `/topics` | 话题/板块列表 |
+| `/topics/:slug` | 话题详情(该话题下的帖子) |
+
+### 后台路由
+
+| 路由 | 说明 |
+|---|---|
+| `/admin` | 后台首页 |
+| `/admin/users` | 用户管理 |
+| `/admin/posts` | 内容管理 |
+| `/admin/topics` | 板块管理 |
+| `/admin/theme` | 主题切换 |
+| `/admin/settings` | 系统设置 |
+
+### 静态资源
+
+```
+/static/css/* → 当前激活主题的 static/css/
+/static/js/* → 当前激活主题的 static/js/
+/static/img/* → 当前激活主题的 static/img/
+```
+
+不随路由变化,由 `theme/resolver.go` 统一处理。
+
+## 路由组中间件
+
+```go
+// 认证路由:限流 + 禁止收录
+authGroup := r.Group("/auth")
+authGroup.Use(middleware.RateLimiter(5, 60), middleware.NoIndex)
+
+// 需要登录的路由
+authRequired := r.Group("/")
+authRequired.Use(middleware.AuthRequired)
+
+// 后台路由:需要管理员
+adminGroup := r.Group("/admin")
+adminGroup.Use(middleware.AuthRequired, middleware.AdminOnly)
+
+// API 路由
+apiGroup := r.Group("/api")
+apiGroup.Use(middleware.RateLimiter(60, 60))
+```
diff --git a/docs/structure.md b/docs/structure.md
new file mode 100644
index 0000000..921ee23
--- /dev/null
+++ b/docs/structure.md
@@ -0,0 +1,128 @@
+# 目录结构与分层规范
+
+## 总览
+
+```
+METAZONE.FAN/
+├── cmd/server/main.go # 入口:初始化→启动,只做编排,不写业务
+├── internal/ # 内部包(不可外部 import)
+│ ├── config/ # 配置加载(结构体 + yaml/file)
+│ ├── router/ # 路由注册(按路由组拆文件)
+│ ├── controller/ # 控制器(瘦)— 接参数 → 调服务 → 返回
+│ │ └── admin/ # 后台子包(功能多,独立拆)
+│ ├── service/ # 业务逻辑(厚)— 核心逻辑、校验、编排
+│ ├── repository/ # 数据访问(DAO)— 纯数据库操作
+│ ├── model/ # 数据模型 — 纯结构体 + DTO
+│ ├── middleware/ # 中间件 — 每个中间件一个文件
+│ ├── theme/ # 模板/主题管理 — 加载、切换、静态资源
+│ └── common/ # 公共工具 — 响应格式、分页、校验、错误码
+├── templates/ # 前端模板 — 多主题支持
+│ ├── MetaLab-2026/ # 当前主题
+│ │ ├── theme.json # 主题元信息
+│ │ ├── guidelines.html # 准则等长文本(非模板,纯内容文件)
+│ │ ├── html/ # 页面模板
+│ │ │ ├── layout/ # 布局组件(header/nav/footer)
+│ │ │ ├── home/ post/ comment/ user/ topic/ ...
+│ │ │ └── partials/ # 可复用组件(卡片/分页/侧栏)
+│ │ └── static/ # 静态资源(css/js/img)
+│ ├── MetaLab-2027/ # 未来主题(相同结构)
+│ └── system/ # 系统模板(email 等,与主题无关)
+├── storage/ # 运行时数据(.gitignore)
+│ ├── uploads/avatars/ attachments/
+│ └── logs/
+└── docs/ # 项目文档
+```
+
+## 各层职责与边界
+
+### Controller(控制器)
+
+- **只做三件事**:绑定请求参数 → 调用 Service → 返回响应
+- 不允许直接调用 Repository
+- 不允许写 if-else 业务分支逻辑
+- 单文件 ≤ 120 行
+- 每个领域实体一个文件,后台独立 `admin/` 子包
+
+```go
+// ✅ 正确:瘦控制器
+func (c *PostController) Detail(ctx *gin.Context) {
+ id := ctx.Param("id")
+ post, err := c.postService.GetDetail(ctx, id)
+ if err != nil {
+ common.Error(ctx, common.ErrNotFound, "帖子不存在")
+ return
+ }
+ common.Ok(ctx, post)
+}
+```
+
+### Service(业务逻辑)
+
+- **承载所有业务规则**:权限校验、数据组装、事务编排
+- 可以调用多个 Repository
+- 单文件 ≤ 300 行
+- 方法命名语义化:`Create`/`Update`/`Delete`/`GetDetail`/`ListByTopic`
+
+### Repository(数据访问)
+
+- **纯数据库操作**:增删改查,不含业务逻辑
+- 单文件 ≤ 200 行
+- 不引用 Controller 或 Service 层
+
+### Model(模型)
+
+- 纯结构体定义 + DTO
+- 单文件 ≤ 80 行
+- `common.go` 放 BaseModel(ID/CreatedAt/UpdatedAt)
+- `dto.go` 放请求/响应传输对象
+
+### Middleware(中间件)
+
+- 每个中间件一个文件,≤ 60 行
+- 按关注点注册到路由组上,不要逐个路由单独加
+
+### Router(路由)
+
+- 按路由组拆文件:`web.go`(页面)、`api.go`(API)、`admin.go`(后台)
+- 只做路由映射,不写 handler 逻辑
+
+### Theme(主题管理)
+
+- `loader.go`:模板文件加载、内容文件读取
+- `manager.go`:主题扫描、切换、获取当前
+- `resolver.go`:静态资源路径解析(`/static/` → 当前主题 static 目录)
+- `theme.go`:主题接口定义 + ThemeMeta 结构体
+
+### Common(公共工具)
+
+- `response.go`:统一 JSON 响应格式
+- `error_code.go`:错误码常量
+- `validator.go`:参数校验
+- `pagination.go`:分页工具
+
+## import 规则
+
+```
+允许的依赖方向(从上到下,不可反向):
+
+controller → service → repository → model
+controller → common
+service → common + repository + model
+middleware → common + service(可选)
+router → controller + middleware
+theme → 无(独立工具包)
+```
+
+- Controller **不可** import Repository
+- Controller **不可** import 另一个 Controller
+- Service **不可** import Controller
+- Repository **不可** import Service
+
+## 模板目录规范
+
+- 每个主题 `templates/{主题名}/` 下有独立完整的 `html/` 和 `static/`
+- 主题之间互不引用、互不依赖
+- 长文本内容(准则、关于页面等)放主题根目录(`guidelines.html`),不在 `html/` 下
+- 这些内容文件**不是模板**(不含 `{{}}` 语法),由 Controller 读取后注入
+- 系统级模板(email 等)放 `templates/system/`,与主题无关
+- 切换主题时,Gin 引擎重新指向新主题的 `html/` 目录
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..6ad1a69
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,59 @@
+module metazone.cc/metalab
+
+go 1.25.0
+
+require (
+ github.com/gin-gonic/gin v1.12.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
+ github.com/spf13/viper v1.21.0
+ golang.org/x/crypto v0.52.0
+ gorm.io/driver/postgres v1.6.0
+ gorm.io/gorm v1.31.1
+)
+
+require (
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.15.0 // indirect
+ github.com/bytedance/sonic/loader v0.5.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.30.1 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.6.0 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/quic-go/quic-go v0.59.0 // indirect
+ github.com/sagikazarmark/locafero v0.11.0 // indirect
+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.1 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/arch v0.22.0 // indirect
+ golang.org/x/net v0.54.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ google.golang.org/protobuf v1.36.10 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..ed3c47f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,140 @@
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
+github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
+github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
+github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
+github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
+github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
+golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/internal/common/cookie.go b/internal/common/cookie.go
new file mode 100644
index 0000000..c99a54e
--- /dev/null
+++ b/internal/common/cookie.go
@@ -0,0 +1,62 @@
+package common
+
+import (
+ "log"
+ "net/http"
+
+ "metazone.cc/metalab/internal/config"
+
+ "github.com/gin-gonic/gin"
+)
+
+// Cookie 名称常量
+const (
+ CookieName = "mlb_token"
+ RefreshCookieName = "mlb_refresh"
+ RMCookieName = "mlb_rm" // 记住我标记,JS 可读
+)
+
+// SetAuthCookies 设置 access + refresh Cookie
+// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
+// rememberMe=false → Cookie 用 session 模式(maxAge=0),关浏览器即清除,但页面开启期间自动刷新
+func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) {
+ secure := cfg.Server.Mode != "debug"
+ c.SetSameSite(http.SameSiteLaxMode)
+
+ setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
+
+ if rememberMe {
+ setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure)
+ setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure)
+ } else {
+ // session cookie:关浏览器即清除,但页面开启期间自动刷新生效
+ setCookie(c, RefreshCookieName, refreshToken, 0, secure)
+ setPlainCookie(c, RMCookieName, "1", 0, secure)
+ }
+}
+
+// SetAccessCookie 仅刷新 access Cookie(refresh 续期调用)
+func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
+ secure := cfg.Server.Mode != "debug"
+ c.SetSameSite(http.SameSiteLaxMode)
+ setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
+}
+
+// ClearAuthCookies 清除所有认证 Cookie(logout 调用)
+func ClearAuthCookies(c *gin.Context, cfg *config.Config) {
+ secure := cfg.Server.Mode != "debug"
+ c.SetCookie(CookieName, "", -1, "/", "", secure, true)
+ c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true)
+ c.SetCookie(RMCookieName, "", -1, "/", "", secure, false)
+}
+
+// setCookie 写入一个 HttpOnly Cookie
+func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
+ log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure)
+ c.SetCookie(name, value, maxAge, "/", "", secure, true)
+}
+
+// setPlainCookie 写入非 HttpOnly Cookie(JS 可读)
+func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
+ c.SetCookie(name, value, maxAge, "/", "", secure, false)
+}
diff --git a/internal/common/crypto.go b/internal/common/crypto.go
new file mode 100644
index 0000000..13f8cad
--- /dev/null
+++ b/internal/common/crypto.go
@@ -0,0 +1,20 @@
+package common
+
+import (
+ "golang.org/x/crypto/bcrypt"
+)
+
+// HashPassword 使用 bcrypt 哈希密码
+func HashPassword(password string, cost int) (string, error) {
+ bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
+ if err != nil {
+ return "", err
+ }
+ return string(bytes), nil
+}
+
+// CheckPassword 验证密码
+func CheckPassword(password, hash string) bool {
+ err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+ return err == nil
+}
diff --git a/internal/common/errors.go b/internal/common/errors.go
new file mode 100644
index 0000000..300fc8c
--- /dev/null
+++ b/internal/common/errors.go
@@ -0,0 +1,22 @@
+package common
+
+import "errors"
+
+// 认证相关错误哨兵(统一存放,避免 service 和 middleware 交叉引用)
+var (
+ ErrEmailExists = errors.New("该邮箱已注册")
+ ErrUsernameTaken = errors.New("该用户名已被占用")
+ ErrInvalidCred = errors.New("邮箱或密码错误")
+ ErrUserNotFound = errors.New("用户不存在")
+ ErrUserBanned = errors.New("该账号已被封禁")
+ ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复")
+ ErrUserLocked = errors.New("邮箱或密码错误")
+ ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
+ ErrTokenExpired = errors.New("登录已过期,请重新登录")
+ ErrTokenInvalid = errors.New("无效的认证凭据")
+ ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
+ ErrRateLimitAccount = errors.New("该账号登录尝试过于频繁")
+ ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
+ ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试")
+ ErrPermissionDenied = errors.New("权限不足")
+)
diff --git a/internal/common/helper.go b/internal/common/helper.go
new file mode 100644
index 0000000..73be484
--- /dev/null
+++ b/internal/common/helper.go
@@ -0,0 +1,44 @@
+package common
+
+import (
+ "metazone.cc/metalab/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// BuildPageData 构建页面模板数据,自动注入登录状态与 CSRF token
+func BuildPageData(c *gin.Context, extra gin.H) gin.H {
+ data := gin.H{}
+ for k, v := range extra {
+ data[k] = v
+ }
+ if username, exists := c.Get("username"); exists {
+ data["IsLoggedIn"] = true
+ data["Username"] = username
+ }
+ // 注入 CSRF token(由 CSRF 中间件设置到上下文中)
+ if token, exists := c.Get("csrf_token"); exists {
+ data["CSRFToken"] = token
+ }
+ return data
+}
+
+// BuildAdminPageData 构建管理后台模板数据
+// 额外注入 UID、Role、CanManageUsers、CurrentPath 等权限信息
+func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
+ data := BuildPageData(c, extra)
+ data["IsAdmin"] = true
+ data["CurrentPath"] = c.Request.URL.Path
+
+ if uid, exists := c.Get("uid"); exists {
+ data["UID"] = uid
+ }
+ if role, exists := c.Get("role"); exists {
+ roleStr := role.(string)
+ data["Role"] = roleStr
+ data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
+ data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner)
+ data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner)
+ }
+ return data
+}
diff --git a/internal/common/pagination.go b/internal/common/pagination.go
new file mode 100644
index 0000000..4cb9294
--- /dev/null
+++ b/internal/common/pagination.go
@@ -0,0 +1,51 @@
+package common
+
+import "time"
+
+// Pagination 分页请求参数
+type Pagination struct {
+ Page int `form:"page" json:"page" binding:"min=1"`
+ PageSize int `form:"page_size" json:"page_size" binding:"min=1,max=100"`
+}
+
+// PaginatedResult 分页响应
+type PaginatedResult struct {
+ Items interface{} `json:"items"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ TotalPages int `json:"total_pages"`
+}
+
+// DefaultPagination 默认分页(未传参时使用)
+func (p *Pagination) DefaultPagination() {
+ if p.Page < 1 {
+ p.Page = 1
+ }
+ if p.PageSize < 1 || p.PageSize > 100 {
+ p.PageSize = 20
+ }
+}
+
+// Offset 计算 SQL offset
+func (p *Pagination) Offset() int {
+ return (p.Page - 1) * p.PageSize
+}
+
+// NewPaginatedResult 构建分页响应
+func NewPaginatedResult(items interface{}, total int64, page, pageSize int) *PaginatedResult {
+ totalPages := int(total) / pageSize
+ if int(total)%pageSize > 0 {
+ totalPages++
+ }
+ return &PaginatedResult{
+ Items: items,
+ Total: total,
+ Page: page,
+ PageSize: pageSize,
+ TotalPages: totalPages,
+ }
+}
+
+// 固定时间格式,前后端统一
+const TimeFormat = time.RFC3339
diff --git a/internal/common/response.go b/internal/common/response.go
new file mode 100644
index 0000000..60f3e8d
--- /dev/null
+++ b/internal/common/response.go
@@ -0,0 +1,20 @@
+package common
+
+import (
+ "github.com/gin-gonic/gin"
+ "net/http"
+)
+
+// 统一 JSON 响应
+
+func Ok(c *gin.Context, data interface{}) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
+}
+
+func OkMessage(c *gin.Context, message string) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
+}
+
+func Error(c *gin.Context, code int, message string) {
+ c.JSON(code, gin.H{"success": false, "message": message})
+}
diff --git a/internal/common/username.go b/internal/common/username.go
new file mode 100644
index 0000000..04310b5
--- /dev/null
+++ b/internal/common/username.go
@@ -0,0 +1,51 @@
+package common
+
+import (
+ "crypto/rand"
+ "errors"
+ "math/big"
+)
+
+// usernameChars 随机用户名字符集(小写字母 + 数字)
+const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
+const usernameLen = 10
+
+// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository)
+type UsernameChecker interface {
+ ExistsByUsername(username string) (bool, error)
+}
+
+// GenerateUsername 生成不重复的随机 10 位用户名(小写字母 + 数字)
+// checker 提供去重查询,maxRetry 次重试后仍冲突则返回错误
+func GenerateUsername(checker UsernameChecker, maxRetry int) (string, error) {
+ if maxRetry <= 0 {
+ maxRetry = 20
+ }
+ for i := 0; i < maxRetry; i++ {
+ username, err := randomUsername(usernameLen)
+ if err != nil {
+ return "", err
+ }
+ exists, err := checker.ExistsByUsername(username)
+ if err != nil {
+ return "", err
+ }
+ if !exists {
+ return username, nil
+ }
+ }
+ return "", errors.New("生成用户名失败,请重试")
+}
+
+// randomUsername 通过 crypto/rand 生成安全的随机字符串
+func randomUsername(n int) (string, error) {
+ b := make([]byte, n)
+ for i := range b {
+ idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(usernameChars))))
+ if err != nil {
+ return "", err
+ }
+ b[i] = usernameChars[idx.Int64()]
+ }
+ return string(b), nil
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..55b9b82
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,119 @@
+package config
+
+import (
+ "bufio"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/spf13/viper"
+)
+
+// Config 应用总配置
+type Config struct {
+ Server ServerConfig `mapstructure:"server"`
+ Database DatabaseConfig `mapstructure:"database"`
+ JWT JWTConfig `mapstructure:"jwt"`
+ Bcrypt BcryptConfig `mapstructure:"bcrypt"`
+}
+
+type ServerConfig struct {
+ Port string `mapstructure:"port"`
+ Mode string `mapstructure:"mode"`
+}
+
+type DatabaseConfig struct {
+ Host string `mapstructure:"host"`
+ Port string `mapstructure:"port"`
+ User string `mapstructure:"user"`
+ Password string `mapstructure:"password"`
+ DBName string `mapstructure:"dbname"`
+ SSLMode string `mapstructure:"sslmode"`
+}
+
+type JWTConfig struct {
+ Secret string `mapstructure:"secret"`
+ AccessExpire int `mapstructure:"access_expire"` // 分钟
+ RefreshExpire int `mapstructure:"refresh_expire"` // 小时
+ RememberExpire int `mapstructure:"remember_expire"` // 小时
+}
+
+type BcryptConfig struct {
+ Cost int `mapstructure:"cost"`
+}
+
+// 全局配置实例(初始化后只读)
+var App *Config
+
+// Load 加载配置:config.yaml → .env 覆盖
+func Load(configPath string) *Config {
+ // 1. 从 .env 加载环境变量(优先于 config.yaml)
+ loadEnvFile(".env")
+
+ v := viper.New()
+
+ // 2. 读取 config.yaml
+ v.SetConfigFile(configPath)
+ v.SetConfigType("yaml")
+ if err := v.ReadInConfig(); err != nil {
+ log.Fatalf("读取配置文件失败: %v", err)
+ }
+
+ // 3. 环境变量覆盖(DATABASE_PASSWORD → database.password)
+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ v.AutomaticEnv()
+ bindEnvOverride(v)
+
+ c := &Config{}
+ if err := v.Unmarshal(c); err != nil {
+ log.Fatalf("解析配置失败: %v", err)
+ }
+
+ log.Printf("[Config] JWT.SecretLen=%d JWT.AccessExpire=%d min JWT.RefreshExpire=%d h JWT.RememberExpire=%d h | Server.Mode=%s",
+ len(c.JWT.Secret), c.JWT.AccessExpire, c.JWT.RefreshExpire, c.JWT.RememberExpire, c.Server.Mode)
+
+ App = c
+ return c
+}
+
+// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
+func loadEnvFile(path string) {
+ f, err := os.Open(path)
+ if err != nil {
+ return // .env 不存在,跳过
+ }
+ defer f.Close()
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ key := strings.TrimSpace(parts[0])
+ val := strings.TrimSpace(parts[1])
+ if os.Getenv(key) == "" {
+ os.Setenv(key, val)
+ }
+ }
+}
+
+// bindEnvOverride 将环境变量映射到 config 的嵌套键
+func bindEnvOverride(v *viper.Viper) {
+ _ = v.BindEnv("database.password", "DATABASE_PASSWORD")
+ _ = v.BindEnv("jwt.secret", "JWT_SECRET")
+}
+
+// DSN 返回 PostgreSQL 连接字符串
+func (d DatabaseConfig) DSN() string {
+ return "host=" + d.Host +
+ " port=" + d.Port +
+ " user=" + d.User +
+ " password=" + d.Password +
+ " dbname=" + d.DBName +
+ " sslmode=" + d.SSLMode
+}
diff --git a/internal/controller/admin/admin_controller.go b/internal/controller/admin/admin_controller.go
new file mode 100644
index 0000000..bc48c9e
--- /dev/null
+++ b/internal/controller/admin/admin_controller.go
@@ -0,0 +1,131 @@
+package admin
+
+import (
+ "net/http"
+ "strconv"
+
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminController 管理后台控制器
+type AdminController struct {
+ adminService *service.AdminService
+}
+
+// NewAdminController 构造函数
+func NewAdminController(adminService *service.AdminService) *AdminController {
+ return &AdminController{adminService: adminService}
+}
+
+// Dashboard 管理首页(欢迎页)
+func (ac *AdminController) Dashboard(c *gin.Context) {
+ c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
+ "Title": "管理首页",
+ }))
+}
+
+// UsersPage 用户管理页面
+func (ac *AdminController) UsersPage(c *gin.Context) {
+ c.HTML(http.StatusOK, "admin/users/index.html", common.BuildAdminPageData(c, gin.H{
+ "Title": "用户管理",
+ "ExtraCSS": "/admin/static/css/users.css",
+ "ExtraJS": "/admin/static/js/users.js",
+ }))
+}
+
+// ListUsers 用户列表 API(分页 + 搜索 + 筛选)
+func (ac *AdminController) ListUsers(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ params := service.ListUsersParams{
+ Keyword: c.Query("keyword"),
+ Role: c.Query("role"),
+ Status: c.Query("status"),
+ Page: page,
+ PageSize: pageSize,
+ }
+
+ result, err := ac.adminService.ListUsers(params)
+ if err != nil {
+ common.Error(c, http.StatusInternalServerError, "查询失败")
+ return
+ }
+
+ common.Ok(c, result)
+}
+
+// UpdateUserStatus 封禁/解封用户
+func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
+ targetUID, err := parseUIDParam(c)
+ if err != nil {
+ common.Error(c, http.StatusBadRequest, "无效的用户 ID")
+ return
+ }
+
+ var req struct {
+ Status string `json:"status" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.Error(c, http.StatusBadRequest, "参数错误")
+ return
+ }
+
+ operatorUID := c.GetUint("uid")
+ if err := ac.adminService.UpdateUserStatus(operatorUID, targetUID, req.Status); err != nil {
+ handleServiceError(c, err)
+ return
+ }
+
+ action := "封禁"
+ if req.Status == model.StatusActive {
+ action = "解封"
+ } else if req.Status == model.StatusLocked {
+ action = "已删除"
+ }
+ common.OkMessage(c, action+"成功")
+}
+
+// ResetToken 强制用户下线
+func (ac *AdminController) ResetToken(c *gin.Context) {
+ targetUID, err := parseUIDParam(c)
+ if err != nil {
+ common.Error(c, http.StatusBadRequest, "无效的用户 ID")
+ return
+ }
+
+ operatorUID := c.GetUint("uid")
+ if err := ac.adminService.ResetUserToken(operatorUID, targetUID); err != nil {
+ handleServiceError(c, err)
+ return
+ }
+
+ common.OkMessage(c, "已强制下线")
+}
+
+// parseUIDParam 从 URL 路径参数解析 uid
+func parseUIDParam(c *gin.Context) (uint, error) {
+ uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ return uint(uid), nil
+}
+
+// handleServiceError 统一处理 service 层返回的错误
+func handleServiceError(c *gin.Context, err error) {
+ switch err {
+ case common.ErrPermissionDenied:
+ common.Error(c, http.StatusForbidden, "权限不足")
+ case common.ErrUserNotFound:
+ common.Error(c, http.StatusNotFound, "用户不存在")
+ case common.ErrEmailExists:
+ common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁")
+ default:
+ common.Error(c, http.StatusInternalServerError, "操作失败")
+ }
+}
diff --git a/internal/controller/auth_controller.go b/internal/controller/auth_controller.go
new file mode 100644
index 0000000..87e995c
--- /dev/null
+++ b/internal/controller/auth_controller.go
@@ -0,0 +1,229 @@
+package controller
+
+import (
+ "net/http"
+
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/config"
+ "metazone.cc/metalab/internal/middleware"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/service"
+ "metazone.cc/metalab/internal/theme"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AuthController 认证相关页面 + API
+type AuthController struct {
+ authService *service.AuthService
+ tokenService *service.TokenService
+ rateLimiter *middleware.RateLimiter
+ cfg *config.Config
+}
+
+// NewAuthController 构造函数
+func NewAuthController(authService *service.AuthService, tokenSvc *service.TokenService, limiter *middleware.RateLimiter, cfg *config.Config) *AuthController {
+ return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg}
+}
+
+// RegisterPage 注册页面(已登录用户重定向到首页)
+func (ac *AuthController) RegisterPage(c *gin.Context) {
+ if _, exists := c.Get("uid"); exists {
+ c.Redirect(http.StatusMovedPermanently, "/")
+ return
+ }
+ guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
+ if err != nil {
+ c.String(http.StatusInternalServerError, "加载准则失败")
+ return
+ }
+ c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
+ "Title": "注册",
+ "ExtraCSS": "/static/css/auth.css",
+ "Guidelines": guidelines,
+ }))
+}
+
+// LoginPage 登录页面(已登录用户重定向到首页)
+func (ac *AuthController) LoginPage(c *gin.Context) {
+ if _, exists := c.Get("uid"); exists {
+ c.Redirect(http.StatusMovedPermanently, "/")
+ return
+ }
+ c.HTML(http.StatusOK, "auth/login.html", common.BuildPageData(c, gin.H{
+ "Title": "登录",
+ "ExtraCSS": "/static/css/auth.css",
+ }))
+}
+
+// clientIP 获取客户端真实 IP(考虑反向代理)
+func clientIP(c *gin.Context) string {
+ if fwd := c.GetHeader("X-Forwarded-For"); fwd != "" {
+ return fwd
+ }
+ if real := c.GetHeader("X-Real-IP"); real != "" {
+ return real
+ }
+ return c.ClientIP()
+}
+
+// Login 登录 API(含双维度限流)
+func (ac *AuthController) Login(c *gin.Context) {
+ var req model.LoginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.Error(c, http.StatusBadRequest, "请检查输入")
+ return
+ }
+
+ email := req.Email
+ ip := clientIP(c)
+
+ // --- 限流:账户维度 ---
+ acctResult, recordAccount := ac.rateLimiter.AllowAccount(email)
+ if acctResult.Blocked {
+ common.Error(c, http.StatusTooManyRequests, acctResult.Message)
+ return
+ }
+
+ // --- 限流:IP 维度 ---
+ ipResult, recordIP := ac.rateLimiter.AllowIP(ip)
+ if ipResult.Blocked {
+ common.Error(c, http.StatusTooManyRequests, ipResult.Message)
+ return
+ }
+
+ accessToken, refreshToken, user, err := ac.authService.Login(req)
+ if err != nil {
+ // 记录失败 → 两个维度各 +1
+ if recordAccount != nil {
+ recordAccount()
+ }
+ if recordIP != nil {
+ recordIP()
+ }
+
+ switch err {
+ case service.ErrInvalidCred:
+ common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
+ case service.ErrUserBanned:
+ common.Error(c, http.StatusForbidden, "账号已被封禁")
+ case service.ErrUserLocked:
+ common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
+ case service.ErrUserDeleted:
+ // deleted 状态本应在 Login 中自动恢复,此 case 作为兜底
+ common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复")
+ default:
+ common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
+ }
+ return
+ }
+
+ // 登录成功 → 清除失败计数
+ ac.rateLimiter.Clear(email, ip)
+
+ common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "登录成功",
+ "data": user,
+ })
+}
+
+// CheckEmail 检查邮箱是否已注册
+func (ac *AuthController) CheckEmail(c *gin.Context) {
+ var req model.CheckEmailRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.Error(c, http.StatusBadRequest, "请提供有效的邮箱地址")
+ return
+ }
+
+ exists, err := ac.authService.CheckEmail(req.Email)
+ if err != nil {
+ common.Error(c, http.StatusInternalServerError, "检查失败")
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": gin.H{"exists": exists},
+ })
+}
+
+// Register 注册 API
+func (ac *AuthController) Register(c *gin.Context) {
+ var req model.RegisterRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.Error(c, http.StatusBadRequest, "请检查输入:"+err.Error())
+ return
+ }
+
+ // 注册 IP 限流:1 分钟 5 次
+ regResult, recordReg := ac.rateLimiter.AllowIP(clientIP(c) + ":register")
+ if regResult.Blocked {
+ common.Error(c, http.StatusTooManyRequests, "注册请求过于频繁,请稍后重试")
+ return
+ }
+
+ accessToken, refreshToken, user, err := ac.authService.Register(req)
+ if err != nil {
+ if recordReg != nil {
+ recordReg()
+ }
+ switch err {
+ case service.ErrEmailExists:
+ common.Error(c, http.StatusConflict, "该邮箱已注册")
+ case service.ErrWeakPassword:
+ common.Error(c, http.StatusBadRequest, err.Error())
+ default:
+ common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
+ }
+ return
+ }
+
+ common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "注册成功!欢迎加入 MetaLab",
+ "data": user,
+ })
+}
+
+// Logout 退出登录:清除所有认证 Cookie
+func (ac *AuthController) Logout(c *gin.Context) {
+ common.ClearAuthCookies(c, ac.cfg)
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "已退出登录",
+ })
+}
+
+// RefreshToken 用 refresh token 换取新的 access token
+func (ac *AuthController) RefreshToken(c *gin.Context) {
+ refreshToken, err := c.Cookie(common.RefreshCookieName)
+ if err != nil {
+ common.Error(c, http.StatusUnauthorized, "请重新登录")
+ return
+ }
+
+ accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken)
+ if err != nil {
+ common.ClearAuthCookies(c, ac.cfg)
+ switch err {
+ case service.ErrTokenExpired, service.ErrTokenRevoked:
+ common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
+ case service.ErrUserBanned:
+ common.Error(c, http.StatusForbidden, "账号已被封禁")
+ default:
+ common.Error(c, http.StatusUnauthorized, "请重新登录")
+ }
+ return
+ }
+
+ common.SetAccessCookie(c, accessToken, ac.cfg)
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ })
+}
diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go
new file mode 100644
index 0000000..e9e3aa2
--- /dev/null
+++ b/internal/middleware/admin.go
@@ -0,0 +1,92 @@
+package middleware
+
+import (
+ "log"
+ "net/http"
+
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/config"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/repository"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminAuth 管理后台页面认证中间件
+// 逻辑同 AuthRequired(JWT 校验 + token_version 吊销检查)
+// 但失败时重定向到首页,而非返回 JSON
+// 成功时向 context 注入 uid/email/username/role
+func AdminAuth(cfg *config.Config, userRepo *repository.UserRepo) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ tokenStr, err := c.Cookie(common.CookieName)
+ if err != nil {
+ log.Printf("[AdminAuth] NO_COOKIE path=%s → 302", c.Request.URL.Path)
+ c.Redirect(http.StatusFound, "/")
+ c.Abort()
+ return
+ }
+
+ claims, err := parseToken(tokenStr, cfg.JWT.Secret)
+ if err != nil {
+ log.Printf("[AdminAuth] TOKEN_PARSE_FAIL path=%s err=%v → 302", c.Request.URL.Path, err)
+ common.ClearAuthCookies(c, cfg)
+ c.Redirect(http.StatusFound, "/")
+ c.Abort()
+ return
+ }
+
+ uid := uint(claims["uid"].(float64))
+ tokenVer := int(claims["ver"].(float64))
+
+ // 即时吊销检查
+ currentVer, err := userRepo.FindTokenVersion(uid)
+ if err != nil || tokenVer != currentVer {
+ if tokenVer != currentVer {
+ log.Printf("[AdminAuth] REVOKED: uid=%d tokenVer=%d dbVer=%d → 302", uid, tokenVer, currentVer)
+ } else {
+ log.Printf("[AdminAuth] DB_ERR: uid=%d err=%v → 302", uid, err)
+ }
+ common.ClearAuthCookies(c, cfg)
+ c.Redirect(http.StatusFound, "/")
+ c.Abort()
+ return
+ }
+
+ log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uid, claims["role"], c.Request.URL.Path)
+ c.Set("uid", uid)
+ c.Set("email", claims["email"])
+ c.Set("username", claims["username"])
+ c.Set("role", claims["role"])
+ c.Next()
+ }
+}
+
+// RequireMinRole 角色权限检查(用于 API 路由)
+// 不足时返回 JSON 403
+func RequireMinRole(minRole string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ role, exists := c.Get("role")
+ if !exists || !model.HasMinRole(role.(string), minRole) {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "success": false, "message": "权限不足",
+ })
+ return
+ }
+ c.Next()
+ }
+}
+
+// RequirePageRole 角色权限检查(用于 SSR 页面路由)
+// 不足时 302 跳转首页
+func RequirePageRole(minRole string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ role, exists := c.Get("role")
+ if !exists || !model.HasMinRole(role.(string), minRole) {
+ log.Printf("[RequirePageRole] DENY: role=%v need=%s path=%s → 302", role, minRole, c.Request.URL.Path)
+ c.Redirect(http.StatusFound, "/")
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go
new file mode 100644
index 0000000..0f1aacc
--- /dev/null
+++ b/internal/middleware/auth.go
@@ -0,0 +1,146 @@
+package middleware
+
+import (
+ "log"
+ "strings"
+ "time"
+
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/config"
+ "metazone.cc/metalab/internal/repository"
+
+ "github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
+type AuthMiddleware struct {
+ cfg *config.Config
+ userRepo *repository.UserRepo
+}
+
+// NewAuthMiddleware 构造函数
+func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthMiddleware {
+ return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
+}
+
+// Required 登录认证中间件:校验 JWT → 检查 token_version → 注入用户信息
+// 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销
+func (am *AuthMiddleware) Required() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ tokenStr, err := c.Cookie(common.CookieName)
+ if err != nil {
+ c.AbortWithStatusJSON(401, gin.H{
+ "success": false, "message": "请先登录",
+ })
+ return
+ }
+
+ claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
+ if err != nil {
+ common.ClearAuthCookies(c, am.cfg)
+ c.AbortWithStatusJSON(401, gin.H{
+ "success": false, "message": "登录已过期,请重新登录",
+ })
+ return
+ }
+
+ uid := uint(claims["uid"].(float64))
+ tokenVer := int(claims["ver"].(float64))
+
+ // 即时吊销检查:查询 DB 当前版本号,不匹配则拒绝
+ currentVer, err := am.userRepo.FindTokenVersion(uid)
+ if err != nil || tokenVer != currentVer {
+ if tokenVer != currentVer {
+ log.Printf("[AuthRequired] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
+ }
+ common.ClearAuthCookies(c, am.cfg)
+ c.AbortWithStatusJSON(401, gin.H{
+ "success": false, "message": "登录凭证已失效,请重新登录",
+ })
+ return
+ }
+
+ // 注入用户信息到上下文
+ c.Set("uid", uid)
+ c.Set("email", claims["email"])
+ c.Set("username", claims["username"])
+ c.Set("role", claims["role"])
+ c.Next()
+ }
+}
+
+// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie)
+func (am *AuthMiddleware) Optional() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ tokenStr, err := c.Cookie(common.CookieName)
+ if err != nil {
+ log.Println("[AuthOptional] no cookie for", c.Request.URL.Path)
+ c.Next()
+ return
+ }
+ claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
+ if err != nil {
+ log.Printf("[AuthOptional] JWT parse failed for %s: %v", c.Request.URL.Path, err)
+ c.Next()
+ return
+ }
+
+ uid := uint(claims["uid"].(float64))
+ tokenVer := int(claims["ver"].(float64))
+
+ // 即时吊销检查
+ currentVer, err := am.userRepo.FindTokenVersion(uid)
+ if err != nil || tokenVer != currentVer {
+ log.Printf("[AuthOptional] REVOKED or err: uid=%d tokenVer=%d dbVer=%d err=%v",
+ uid, tokenVer, currentVer, err)
+ // 已吊销:清除 Cookie,但仍放行(页面视图,前端自行处理)
+ c.Next()
+ return
+ }
+
+ log.Printf("[AuthOptional] OK: uid=%v username=%v path=%s", claims["uid"], claims["username"], c.Request.URL.Path)
+ c.Set("uid", uid)
+ c.Set("email", claims["email"])
+ c.Set("username", claims["username"])
+ c.Set("role", claims["role"])
+ c.Next()
+ }
+}
+
+// parseToken 解析并验证 JWT
+func parseToken(tokenStr, secret string) (jwt.MapClaims, error) {
+ now := time.Now()
+ token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
+ return []byte(secret), nil
+ })
+ if err != nil || !token.Valid {
+ log.Printf("[parseToken] FAIL: now=%v err=%v (secret_len=%d token_len=%d)",
+ now, err, len(secret), len(tokenStr))
+ return nil, err
+ }
+ claims, ok := token.Claims.(jwt.MapClaims)
+ if !ok {
+ return nil, jwt.ErrSignatureInvalid
+ }
+ // Debug: 打印 exp/iat 对比当前时间
+ if exp, exists := claims["exp"]; exists {
+ var expTime time.Time
+ switch v := exp.(type) {
+ case float64:
+ expTime = time.Unix(int64(v), 0)
+ case *jwt.NumericDate:
+ expTime = v.Time
+ }
+ if !expTime.IsZero() {
+ log.Printf("[parseToken] OK: exp=%v (%d) now=%v isExpired=%v",
+ expTime, expTime.Unix(), now, now.After(expTime))
+ }
+ }
+ return claims, nil
+}
+
+// IsLoginPage 检查是否已在登录状态,已登录用户跳过登录/注册页
+func IsLoginPage(c *gin.Context) bool {
+ return strings.HasPrefix(c.Request.URL.Path, "/auth/")
+}
diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go
new file mode 100644
index 0000000..75b3cc6
--- /dev/null
+++ b/internal/middleware/csrf.go
@@ -0,0 +1,112 @@
+package middleware
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "net/http"
+
+ "metazone.cc/metalab/internal/config"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ csrfCookieName = "mlb_csrf"
+ csrfHeaderName = "X-CSRF-Token"
+ csrfMetaName = "csrf_token"
+ csrfTokenLen = 32 // 字节
+)
+
+// CSRF 中间件:Double Submit Cookie 模式
+// 前端 JS 从 Cookie 中读取 token 并放入 X-CSRF-Token 请求头,后端验证两者一致
+// GET / HEAD / OPTIONS 请求自动放行
+func CSRF(cfg *config.Config) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // 安全读取豁免
+ if c.Request.Method == http.MethodGet ||
+ c.Request.Method == http.MethodHead ||
+ c.Request.Method == http.MethodOptions {
+ c.Next()
+ return
+ }
+
+ cookieToken, err := c.Cookie(csrfCookieName)
+ if err != nil || cookieToken == "" {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "success": false, "message": "CSRF 验证失败",
+ })
+ return
+ }
+
+ headerToken := c.GetHeader(csrfHeaderName)
+ // Fallback:如果请求头没有,尝试从表单字段读取(纯 HTML form 提交通道)
+ if headerToken == "" {
+ headerToken = c.PostForm(csrfMetaName)
+ }
+ if headerToken == "" {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "success": false, "message": "CSRF 验证失败:缺少令牌",
+ })
+ return
+ }
+
+ // 恒定时间比较防时序攻击
+ if !constantTimeEq(cookieToken, headerToken) {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "success": false, "message": "CSRF 验证失败",
+ })
+ return
+ }
+
+ // 注入到上下文,供前端 使用
+ c.Set(csrfMetaName, cookieToken)
+ c.Next()
+ }
+}
+
+// SetCSRFToken 在首次页面访问时下发 CSRF Cookie(由页面路由中间件调用)
+// 注意:此 Cookie HttpOnly=false,JS 可读——这是 Double Submit 模式的必要条件
+func SetCSRFToken(c *gin.Context, cfg *config.Config) string {
+ secure := cfg.Server.Mode != "debug"
+
+ // 如果已有 token 且未过期,复用(但必须注入 context,供模板 meta 标签使用)
+ if existing, err := c.Cookie(csrfCookieName); err == nil && existing != "" {
+ c.Set(csrfMetaName, existing)
+ return existing
+ }
+
+ token, err := generateCSRFToken()
+ if err != nil {
+ // 极端情况:随机数生成失败,使用短 token
+ token = "fallback-" + hex.EncodeToString([]byte("metazone"))
+ }
+
+ c.SetSameSite(http.SameSiteStrictMode)
+ // 30 天有效期,与 refresh token 对齐
+ maxAge := int(cfg.JWT.RememberExpire * 3600)
+ c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false)
+
+ c.Set(csrfMetaName, token)
+ return token
+}
+
+// generateCSRFToken 生成 64 字符十六进制随机 CSRF token
+func generateCSRFToken() (string, error) {
+ b := make([]byte, csrfTokenLen)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
+
+// constantTimeEq 恒定时间字符串比较(防时序攻击)
+func constantTimeEq(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ var result byte
+ for i := 0; i < len(a); i++ {
+ result |= a[i] ^ b[i]
+ }
+ return result == 0
+}
diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go
new file mode 100644
index 0000000..1a084d6
--- /dev/null
+++ b/internal/middleware/ratelimit.go
@@ -0,0 +1,149 @@
+package middleware
+
+import (
+ "fmt"
+ "sync"
+ "time"
+)
+
+// RateLimiter 双维度滑动窗口登录限流
+// 维度一(账户):同邮箱 1 分钟内失败 3 次 → 锁定 1 分钟
+// 维度二(IP):同 IP 1 分钟内失败 10 次 → 锁定 1 分钟
+type RateLimiter struct {
+ mu sync.RWMutex
+ // accountFailures key = 邮箱(小写)
+ accountFailures map[string]*windowState
+ // ipFailures key = IP
+ ipFailures map[string]*windowState
+}
+
+// windowState 单个维度的限流状态
+type windowState struct {
+ count int
+ windowStart time.Time
+ blockedUntil time.Time // 零值为未封锁
+}
+
+// RateLimitResult 限流检查结果
+type RateLimitResult struct {
+ Blocked bool
+ RetryAfter int // 剩余封锁秒数
+ Message string
+}
+
+const (
+ accountWindow = 1 * time.Minute
+ accountMaxFails = 3
+ accountBlockDur = 1 * time.Minute
+ ipWindow = 1 * time.Minute
+ ipMaxFails = 10
+ ipBlockDur = 1 * time.Minute
+ cleanupInterval = 2 * time.Minute
+ maxEntries = 10000 // 单维度最大条目数(防内存耗尽)
+)
+
+// NewRateLimiter 创建限流器并启动后台清理
+func NewRateLimiter() *RateLimiter {
+ rl := &RateLimiter{
+ accountFailures: make(map[string]*windowState),
+ ipFailures: make(map[string]*windowState),
+ }
+ go rl.cleanupLoop()
+ return rl
+}
+
+// AllowAccount 检查账户维度是否允许登录尝试
+// 返回 (result, 登录失败时应调用的记录函数)
+func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) {
+ return rl.check(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
+}
+
+// AllowIP 检查 IP 维度是否允许登录尝试
+func (rl *RateLimiter) AllowIP(ip string) (RateLimitResult, func()) {
+ return rl.check(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
+}
+
+// check 核心检查逻辑
+// lowKey: 是否需要脱敏日志(true = 暗示账户存在,仅泄漏给已知该邮箱的人)
+func (rl *RateLimiter) check(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) (RateLimitResult, func()) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ now := time.Now()
+ state, exists := m[key]
+
+ if !exists {
+ state = &windowState{}
+ if len(m) < maxEntries {
+ m[key] = state
+ }
+ }
+
+ // 检查是否处于封锁期
+ if !state.blockedUntil.IsZero() && now.Before(state.blockedUntil) {
+ retry := int(state.blockedUntil.Sub(now).Seconds()) + 1
+ msg := "请求过于频繁,请稍后重试"
+ if lowKey {
+ msg = fmt.Sprintf("该账号登录尝试过于频繁,请 %d 秒后重试", retry)
+ }
+ return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}, nil
+ }
+
+ // 窗口过期 → 重置
+ if now.Sub(state.windowStart) > window {
+ state.count = 0
+ state.windowStart = now
+ state.blockedUntil = time.Time{}
+ }
+
+ // 记录失败(由调用方在登录失败时调用)的闭包
+ recordFail := func() {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ s := m[key]
+ if s == nil {
+ return
+ }
+ if now.Sub(s.windowStart) > window {
+ s.count = 1
+ s.windowStart = now
+ return
+ }
+ s.count++
+ if s.count >= maxFails {
+ s.blockedUntil = now.Add(blockDur)
+ }
+ }
+
+ return RateLimitResult{Blocked: false}, recordFail
+}
+
+// Clear 登录成功后清除该 email 和 IP 的失败计数
+func (rl *RateLimiter) Clear(email, ip string) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ delete(rl.accountFailures, email)
+ delete(rl.ipFailures, ip)
+}
+
+// cleanupLoop 定期清理过期条目
+func (rl *RateLimiter) cleanupLoop() {
+ ticker := time.NewTicker(cleanupInterval)
+ defer ticker.Stop()
+ for range ticker.C {
+ rl.mu.Lock()
+ now := time.Now()
+ clean := func(m map[string]*windowState) {
+ for k, v := range m {
+ // 封锁期已过且窗口已过期 → 删除
+ if (v.blockedUntil.IsZero() || now.After(v.blockedUntil)) &&
+ now.Sub(v.windowStart) > accountWindow+ipBlockDur {
+ delete(m, k)
+ }
+ }
+ }
+ clean(rl.accountFailures)
+ clean(rl.ipFailures)
+ rl.mu.Unlock()
+ }
+}
diff --git a/internal/middleware/security.go b/internal/middleware/security.go
new file mode 100644
index 0000000..f94f184
--- /dev/null
+++ b/internal/middleware/security.go
@@ -0,0 +1,26 @@
+package middleware
+
+import (
+ "github.com/gin-gonic/gin"
+)
+
+// SecurityHeaders 添加安全相关 HTTP 响应头
+// 作为纵深防御,不影响业务逻辑,纯附加
+func SecurityHeaders() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Content-Security-Policy:仅允许本站资源 + 内联样式/脚本
+ c.Header("Content-Security-Policy",
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
+
+ // 禁止 MIME 类型嗅探
+ c.Header("X-Content-Type-Options", "nosniff")
+
+ // 禁止被 frame 嵌入(防点击劫持)
+ c.Header("X-Frame-Options", "DENY")
+
+ // 引用策略
+ c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
+
+ c.Next()
+ }
+}
diff --git a/internal/model/common.go b/internal/model/common.go
new file mode 100644
index 0000000..e997a92
--- /dev/null
+++ b/internal/model/common.go
@@ -0,0 +1,15 @@
+package model
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// BaseModel 所有模型的公共字段
+type BaseModel struct {
+ ID uint `gorm:"primarykey;column:uid" json:"uid"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
diff --git a/internal/model/dto.go b/internal/model/dto.go
new file mode 100644
index 0000000..3246f04
--- /dev/null
+++ b/internal/model/dto.go
@@ -0,0 +1,28 @@
+package model
+
+// RegisterRequest 注册请求
+type RegisterRequest struct {
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required,min=8"`
+ ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
+ RememberMe bool `json:"remember_me"`
+}
+
+// LoginRequest 登录请求
+type LoginRequest struct {
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required"`
+ RememberMe bool `json:"remember_me"`
+}
+
+// CheckEmailRequest 检查邮箱是否已注册
+type CheckEmailRequest struct {
+ Email string `json:"email" binding:"required,email"`
+}
+
+// AuthResponse 认证响应(含 JWT)
+type AuthResponse struct {
+ Token string `json:"token"`
+ RefreshToken string `json:"refresh_token"`
+ User User `json:"user"`
+}
diff --git a/internal/model/user.go b/internal/model/user.go
new file mode 100644
index 0000000..753d939
--- /dev/null
+++ b/internal/model/user.go
@@ -0,0 +1,45 @@
+package model
+
+// User 用户模型
+type User struct {
+ BaseModel
+
+ Email string `gorm:"type:varchar(255);not null" json:"email"`
+ PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
+ Username string `gorm:"type:varchar(16);uniqueIndex;not null" json:"username"`
+ Avatar string `gorm:"type:varchar(500);default:''" json:"avatar"`
+ Bio string `gorm:"type:text" json:"bio"`
+
+ Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
+ Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
+ TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
+}
+
+// 角色常量
+const (
+ RoleUser = "user"
+ RoleModerator = "moderator"
+ RoleAdmin = "admin"
+ RoleOwner = "owner"
+)
+
+// 状态常量
+const (
+ StatusActive = "active"
+ StatusBanned = "banned"
+ StatusDeleted = "deleted" // 用户主动注销(7 天内登录可恢复)
+ StatusLocked = "locked" // 永久锁定(管理员删除或注销满 7 天)
+)
+
+// roleLevel 角色层级映射(数字越大权限越高)
+var roleLevel = map[string]int{
+ RoleUser: 0,
+ RoleModerator: 1,
+ RoleAdmin: 2,
+ RoleOwner: 3,
+}
+
+// HasMinRole 检查 role 是否达到 minRole 的权限级别
+func HasMinRole(role, minRole string) bool {
+ return roleLevel[role] >= roleLevel[minRole]
+}
diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go
new file mode 100644
index 0000000..b5ba1e9
--- /dev/null
+++ b/internal/repository/user_repo.go
@@ -0,0 +1,183 @@
+package repository
+
+import (
+ "metazone.cc/metalab/internal/model"
+
+ "gorm.io/gorm"
+)
+
+// UserRepo 用户数据访问
+type UserRepo struct {
+ db *gorm.DB
+}
+
+// NewUserRepo 构造函数
+func NewUserRepo(db *gorm.DB) *UserRepo {
+ return &UserRepo{db: db}
+}
+
+// Create 创建用户
+func (r *UserRepo) Create(user *model.User) error {
+ return r.db.Create(user).Error
+}
+
+// FindByEmail 按邮箱查找用户
+func (r *UserRepo) FindByEmail(email string) (*model.User, error) {
+ var user model.User
+ err := r.db.Where("email = ?", email).First(&user).Error
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// FindByID 按 UID 查找用户
+func (r *UserRepo) FindByID(id uint) (*model.User, error) {
+ var user model.User
+ err := r.db.First(&user, id).Error
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// FindByUsername 按用户名查找
+func (r *UserRepo) FindByUsername(username string) (*model.User, error) {
+ var user model.User
+ err := r.db.Where("username = ?", username).First(&user).Error
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// ExistsByEmail 检查邮箱是否已注册
+func (r *UserRepo) ExistsByEmail(email string) (bool, error) {
+ var count int64
+ err := r.db.Model(&model.User{}).Where("email = ?", email).Count(&count).Error
+ return count > 0, err
+}
+
+// ExistsByUsername 检查用户名是否已存在
+func (r *UserRepo) ExistsByUsername(username string) (bool, error) {
+ var count int64
+ err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error
+ return count > 0, err
+}
+
+// ExistsByEmailExclude 检查邮箱是否被其他 active 用户占用(解锁前冲突检查)
+func (r *UserRepo) ExistsByEmailExclude(email string, excludeUID uint) (bool, error) {
+ var count int64
+ err := r.db.Model(&model.User{}).
+ Where("email = ? AND uid != ?", email, excludeUID).
+ Count(&count).Error
+ return count > 0, err
+}
+
+// IncrementTokenVersion 递增用户令牌版本,使所有已签发的 JWT 即时失效
+func (r *UserRepo) IncrementTokenVersion(userID uint) error {
+ return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", userID).
+ UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
+}
+
+// FindTokenVersion 按 UID 查询当前令牌版本(轻量查询,仅 SELECT token_version)
+func (r *UserRepo) FindTokenVersion(userID uint) (int, error) {
+ var version int
+ err := r.db.Model(&model.User{}).
+ Select("token_version").
+ Where("uid = ?", userID).
+ Scan(&version).Error
+ return version, err
+}
+
+// FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status/token_version
+// 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT *
+// 使用默认 scope(排除软删除),locked 用户不可持有有效 JWT
+func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
+ var user model.User
+ err := r.db.Select("uid", "email", "username", "role", "status", "token_version").
+ First(&user, userID).Error
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
+func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
+ var user model.User
+ err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "token_version", "deleted_at").
+ First(&user, userID).Error
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// UpdateStatus 更新用户状态(含软删除用户,locked 状态变更需要)
+func (r *UserRepo) UpdateStatus(uid uint, status string) error {
+ return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("status", status).Error
+}
+
+// UpdateRole 更新用户角色(仅 owner 调用)
+func (r *UserRepo) UpdateRole(uid uint, role string) error {
+ return r.db.Model(&model.User{}).Where("uid = ?", uid).Update("role", role).Error
+}
+
+// Update 全量更新用户信息
+func (r *UserRepo) Update(user *model.User) error {
+ return r.db.Save(user).Error
+}
+
+// SoftDelete GORM 软删除(设 DeletedAt),用于 locked 状态释放邮箱
+func (r *UserRepo) SoftDelete(uid uint) error {
+ return r.db.Where("uid = ?", uid).Delete(&model.User{}).Error
+}
+
+// Restore 恢复软删除(清 DeletedAt)
+func (r *UserRepo) Restore(uid uint) error {
+ return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
+}
+
+// SearchUsers 综合搜索(含软删除用户)
+func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
+ query := r.db.Unscoped().Model(&model.User{})
+ if keyword != "" {
+ like := "%" + keyword + "%"
+ query = query.Where("email LIKE ? OR username LIKE ?", like, like)
+ }
+ if role != "" {
+ query = query.Where("role = ?", role)
+ }
+ if status != "" {
+ query = query.Where("status = ?", status)
+ }
+ var users []model.User
+ err := query.Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
+ return users, err
+}
+
+// CountSearchUsers 搜索结果总数(含软删除用户)
+func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error) {
+ query := r.db.Unscoped().Model(&model.User{})
+ if keyword != "" {
+ like := "%" + keyword + "%"
+ query = query.Where("email LIKE ? OR username LIKE ?", like, like)
+ }
+ if role != "" {
+ query = query.Where("role = ?", role)
+ }
+ if status != "" {
+ query = query.Where("status = ?", status)
+ }
+ var count int64
+ err := query.Count(&count).Error
+ return count, err
+}
+
+// FindByStatus 按状态分页查询(回收站用,暂未暴露前端)
+func (r *UserRepo) FindByStatus(status string, offset, limit int) ([]model.User, error) {
+ var users []model.User
+ err := r.db.Where("status = ?", status).Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
+ return users, err
+}
diff --git a/internal/router/router.go b/internal/router/router.go
new file mode 100644
index 0000000..ecbd80c
--- /dev/null
+++ b/internal/router/router.go
@@ -0,0 +1,104 @@
+package router
+
+import (
+ "net/http"
+
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/config"
+ "metazone.cc/metalab/internal/controller"
+ adminCtrl "metazone.cc/metalab/internal/controller/admin"
+ "metazone.cc/metalab/internal/middleware"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/repository"
+ "metazone.cc/metalab/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// Setup 注册所有路由
+func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
+ // --- 全局中间件 ---
+ r.Use(middleware.SecurityHeaders())
+
+ // --- 依赖注入 ---
+ userRepo := repository.NewUserRepo(db)
+ tokenSvc := service.NewTokenService(cfg, userRepo)
+ authService := service.NewAuthService(userRepo, tokenSvc, cfg)
+ rateLimiter := middleware.NewRateLimiter()
+ authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
+
+ authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
+
+ // 管理后台
+ adminService := service.NewAdminService(userRepo)
+ adminController := adminCtrl.NewAdminController(adminService)
+
+ // --- 页面路由(CSRF 仅下发 token,不验证——页面 GET 被豁免) ---
+ pages := r.Group("/")
+ pages.Use(authMdw.Optional())
+ pages.Use(middleware.CSRF(cfg))
+ pages.Use(func(c *gin.Context) {
+ // 页面路由:确保每个页面都下发 CSRF Cookie
+ middleware.SetCSRFToken(c, cfg)
+ c.Next()
+ })
+ {
+ pages.GET("/", func(c *gin.Context) {
+ c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
+ "Title": "首页",
+ "ExtraCSS": "/static/css/home.css",
+ }))
+ })
+ }
+
+ // 认证页面(已登录自动跳走)
+ authPages := r.Group("/auth")
+ authPages.Use(authMdw.Optional())
+ authPages.Use(middleware.CSRF(cfg))
+ authPages.Use(func(c *gin.Context) {
+ middleware.SetCSRFToken(c, cfg)
+ c.Next()
+ })
+ {
+ authPages.GET("/register", authCtrl.RegisterPage)
+ authPages.GET("/login", authCtrl.LoginPage)
+ }
+
+ // --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
+ adminPages := r.Group("/admin")
+ adminPages.Use(middleware.AdminAuth(cfg, userRepo))
+ adminPages.Use(middleware.RequirePageRole(model.RoleModerator))
+ adminPages.Use(middleware.CSRF(cfg))
+ adminPages.Use(func(c *gin.Context) {
+ middleware.SetCSRFToken(c, cfg)
+ c.Next()
+ })
+ {
+ adminPages.GET("/", adminController.Dashboard)
+ adminPages.GET("/users", adminController.UsersPage,
+ middleware.RequirePageRole(model.RoleAdmin))
+ }
+
+ // --- 管理后台 API(JSON 响应) ---
+ adminAPI := r.Group("/api/admin")
+ adminAPI.Use(authMdw.Required())
+ adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
+ adminAPI.Use(middleware.CSRF(cfg))
+ {
+ adminAPI.GET("/users", adminController.ListUsers)
+ adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus)
+ adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken)
+ }
+
+ // --- API 路由(CSRF 严格验证) ---
+ api := r.Group("/api")
+ api.Use(middleware.CSRF(cfg))
+ {
+ api.POST("/auth/check-email", authCtrl.CheckEmail)
+ api.POST("/auth/register", authCtrl.Register)
+ api.POST("/auth/login", authCtrl.Login)
+ api.POST("/auth/logout", authCtrl.Logout)
+ api.POST("/auth/refresh", authCtrl.RefreshToken)
+ }
+}
diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go
new file mode 100644
index 0000000..68ca9c1
--- /dev/null
+++ b/internal/service/admin_service.go
@@ -0,0 +1,141 @@
+package service
+
+import (
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/repository"
+)
+
+// AdminService 管理后台业务逻辑
+// 集中处理所有权限边界检查,避免胖控制器
+type AdminService struct {
+ userRepo *repository.UserRepo
+}
+
+// NewAdminService 构造函数
+func NewAdminService(userRepo *repository.UserRepo) *AdminService {
+ return &AdminService{userRepo: userRepo}
+}
+
+// ListUsersParams 用户列表查询参数
+type ListUsersParams struct {
+ Keyword string // 搜索关键字(邮箱/用户名)
+ Role string // 角色筛选
+ Status string // 状态筛选
+ Page int // 页码
+ PageSize int // 每页条数
+}
+
+// ListUsersResult 用户列表查询结果
+type ListUsersResult struct {
+ Users []model.User `json:"users"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+}
+
+// ListUsers 分页搜索用户列表
+func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) {
+ if params.Page < 1 {
+ params.Page = 1
+ }
+ if params.PageSize < 1 || params.PageSize > 100 {
+ params.PageSize = 20
+ }
+ offset := (params.Page - 1) * params.PageSize
+
+ total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status)
+ if err != nil {
+ return nil, err
+ }
+
+ users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, offset, params.PageSize)
+ if err != nil {
+ return nil, err
+ }
+
+ return &ListUsersResult{
+ Users: users,
+ Total: total,
+ Page: params.Page,
+ }, nil
+}
+
+// UpdateUserStatus 修改用户状态
+// 权限规则:
+// - 不可操作自己
+// - Admin 只能设置 active/banned,且只能操作 RoleUser
+// - Owner 可设置 active/banned/locked,可操作任何人(除自己)
+func (s *AdminService) UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error {
+ // 校验状态值合法性
+ switch newStatus {
+ case model.StatusActive, model.StatusBanned, model.StatusLocked:
+ default:
+ return common.ErrPermissionDenied
+ }
+
+ return s.checkAndOperate(operatorUID, targetUID, func(operator, target *model.User) error {
+ // locked 相关操作仅 owner
+ if target.Status == model.StatusLocked || newStatus == model.StatusLocked {
+ if !model.HasMinRole(operator.Role, model.RoleOwner) {
+ return common.ErrPermissionDenied
+ }
+ }
+ if err := s.userRepo.UpdateStatus(target.ID, newStatus); err != nil {
+ return err
+ }
+ // 锁定(删除)→ GORM 软删除释放邮箱;解锁 → 先查邮箱冲突再恢复
+ if newStatus == model.StatusLocked {
+ if err := s.userRepo.SoftDelete(target.ID); err != nil {
+ return err
+ }
+ } else if target.Status == model.StatusLocked && newStatus == model.StatusActive {
+ // 检查邮箱是否已被新用户注册(软删期间邮箱释放了)
+ collision, err := s.userRepo.ExistsByEmailExclude(target.Email, target.ID)
+ if err != nil {
+ return err
+ }
+ if collision {
+ return common.ErrEmailExists
+ }
+ if err := s.userRepo.Restore(target.ID); err != nil {
+ return err
+ }
+ }
+ // 修改状态必须立刻让 JWT 失效
+ return s.userRepo.IncrementTokenVersion(target.ID)
+ })
+}
+
+// ResetUserToken 强制下线(递增 token_version)
+func (s *AdminService) ResetUserToken(operatorUID, targetUID uint) error {
+ return s.checkAndOperate(operatorUID, targetUID, func(_ *model.User, target *model.User) error {
+ return s.userRepo.IncrementTokenVersion(target.ID)
+ })
+}
+
+// checkAndOperate 通用权限检查 + 执行操作
+func (s *AdminService) checkAndOperate(operatorUID, targetUID uint, operate func(*model.User, *model.User) error) error {
+ // 1. 不可操作自己
+ if operatorUID == targetUID {
+ return common.ErrPermissionDenied
+ }
+
+ // 2. 查操作者
+ operator, err := s.userRepo.FindByIDUnscoped(operatorUID)
+ if err != nil {
+ return common.ErrUserNotFound
+ }
+
+ // 3. 查目标(含软删除用户,locked 解锁需要查到)
+ target, err := s.userRepo.FindByIDUnscoped(targetUID)
+ if err != nil {
+ return common.ErrUserNotFound
+ }
+
+ // 4. Admin 只能操作 user 角色
+ if operator.Role == model.RoleAdmin && target.Role != model.RoleUser {
+ return common.ErrPermissionDenied
+ }
+
+ return operate(operator, target)
+}
diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go
new file mode 100644
index 0000000..598f6fd
--- /dev/null
+++ b/internal/service/auth_service.go
@@ -0,0 +1,182 @@
+package service
+
+import (
+ "regexp"
+
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/config"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/repository"
+
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+)
+
+// AuthService 认证业务逻辑
+type AuthService struct {
+ userRepo *repository.UserRepo
+ tokenService *TokenService
+ cfg *config.Config
+}
+
+// NewAuthService 构造函数
+func NewAuthService(userRepo *repository.UserRepo, tokenSvc *TokenService, cfg *config.Config) *AuthService {
+ return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg}
+}
+
+var (
+ ErrEmailExists = common.ErrEmailExists
+ ErrUsernameTaken = common.ErrUsernameTaken
+ ErrInvalidCred = common.ErrInvalidCred
+ ErrUserNotFound = common.ErrUserNotFound
+ ErrUserBanned = common.ErrUserBanned
+ ErrUserLocked = common.ErrUserLocked
+ ErrUserDeleted = common.ErrUserDeleted
+ ErrWeakPassword = common.ErrWeakPassword
+ ErrTokenExpired = common.ErrTokenExpired
+ ErrTokenInvalid = common.ErrTokenInvalid
+ ErrTokenRevoked = common.ErrTokenRevoked
+ ErrPermissionDenied = common.ErrPermissionDenied
+)
+
+var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
+var pwDigit = regexp.MustCompile(`\d`)
+
+// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
+// 在 init() 中按当前 bcrypt 成本生成,确保时序与真实校验一致
+var dummyHash []byte
+
+func init() {
+ hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
+ if err != nil {
+ panic("failed to generate bcrypt dummy hash: " + err.Error())
+ }
+ dummyHash = hash
+}
+
+// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字
+func validatePassword(pw string) error {
+ if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) {
+ return ErrWeakPassword
+ }
+ return nil
+}
+
+// Register 注册
+// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
+// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
+func (s *AuthService) Register(req model.RegisterRequest) (string, string, *model.User, error) {
+ // 1. 密码强度
+ if err := validatePassword(req.Password); err != nil {
+ return "", "", nil, err
+ }
+
+ // 2. 检查邮箱
+ exists, err := s.userRepo.ExistsByEmail(req.Email)
+ if err != nil {
+ return "", "", nil, err
+ }
+ if exists {
+ return "", "", nil, ErrEmailExists
+ }
+
+ // 3. 哈希密码
+ hash, err := common.HashPassword(req.Password, s.cfg.Bcrypt.Cost)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ // 4. 生成唯一用户名
+ username, err := common.GenerateUsername(s.userRepo, 20)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ // 5. 创建用户
+ user := &model.User{
+ Email: req.Email,
+ PasswordHash: hash,
+ Username: username,
+ Role: model.RoleUser,
+ Status: model.StatusActive,
+ }
+ if err := s.userRepo.Create(user); err != nil {
+ return "", "", nil, err
+ }
+
+ // 6. 生成 access JWT
+ accessToken, err := s.tokenService.BuildAccessToken(user)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ // 7. 生成 refresh JWT(不勾选"记住我"也用 session cookie,关浏览器即清除)
+ refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ return accessToken, refreshToken, user, nil
+}
+
+// Login 登录
+// 流程:按邮箱查找 → 检查状态 → 验证密码 → access JWT + refresh JWT
+// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
+// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
+func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User, error) {
+ user, err := s.userRepo.FindByEmail(req.Email)
+ if err != nil {
+ _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
+ return "", "", nil, ErrInvalidCred
+ }
+
+ // 已注销(deleted)→ 自动恢复
+ if user.Status == model.StatusDeleted {
+ user.Status = model.StatusActive
+ user.DeletedAt = gorm.DeletedAt{}
+ if err := s.userRepo.Update(user); err != nil {
+ return "", "", nil, err
+ }
+ // 恢复后继续正常登录流程
+ }
+
+ // 永久锁定
+ if user.Status == model.StatusLocked {
+ _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
+ return "", "", nil, ErrUserLocked
+ }
+
+ // 封禁
+ if user.Status == model.StatusBanned {
+ return "", "", nil, ErrUserBanned
+ }
+
+ if !common.CheckPassword(req.Password, user.PasswordHash) {
+ return "", "", nil, ErrInvalidCred
+ }
+
+ // 生成 access JWT
+ accessToken, err := s.tokenService.BuildAccessToken(user)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ // 生成 refresh JWT(不勾选"记住我"也生成,Cookie 用 session 模式)
+ refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
+ if err != nil {
+ return "", "", nil, err
+ }
+
+ return accessToken, refreshToken, user, nil
+}
+
+// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
+func (s *AuthService) CheckEmail(email string) (bool, error) {
+ return s.userRepo.ExistsByEmail(email)
+}
+
+// InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录)
+// 适用场景:修改密码、账号被盗、管理员强制下线
+func (s *AuthService) InvalidateSessions(userID uint) error {
+ return s.userRepo.IncrementTokenVersion(userID)
+}
diff --git a/internal/service/token_service.go b/internal/service/token_service.go
new file mode 100644
index 0000000..ca900be
--- /dev/null
+++ b/internal/service/token_service.go
@@ -0,0 +1,123 @@
+package service
+
+import (
+ "log"
+ "time"
+
+ "metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/config"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/repository"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// TokenService JWT 令牌签发与刷新
+// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
+type TokenService struct {
+ cfg *config.Config
+ userRepo *repository.UserRepo
+}
+
+// NewTokenService 构造函数
+func NewTokenService(cfg *config.Config, userRepo *repository.UserRepo) *TokenService {
+ return &TokenService{cfg: cfg, userRepo: userRepo}
+}
+
+// BuildAccessToken 构建 access JWT(含 uid/email/username/role/ver/exp/iat)
+// ver = user.TokenVersion,用于即时吊销:版本号不匹配则拒绝
+func (ts *TokenService) BuildAccessToken(user *model.User) (string, error) {
+ expire := time.Duration(ts.cfg.JWT.AccessExpire) * time.Minute
+ now := time.Now()
+ expAt := now.Add(expire)
+ log.Printf("[BuildAccessToken] AccessExpire=%d min → expire=%v | now=%v | exp=%v (%d) | iat=%d ver=%d",
+ ts.cfg.JWT.AccessExpire, expire, now, expAt, expAt.Unix(), now.Unix(), user.TokenVersion)
+ claims := jwt.MapClaims{
+ "uid": user.ID,
+ "email": user.Email,
+ "username": user.Username,
+ "role": user.Role,
+ "ver": user.TokenVersion,
+ "exp": expAt.Unix(),
+ "iat": now.Unix(),
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString([]byte(ts.cfg.JWT.Secret))
+}
+
+// BuildRefreshToken 构建 refresh JWT(含 purpose:"refresh" 防止 access 冒充)
+// rememberMe=true → 使用 remember_expire(30天);false → 使用 refresh_expire(7天,session 模式)
+// ver = user.TokenVersion,刷新时校验:版本号不匹配则拒绝
+func (ts *TokenService) BuildRefreshToken(user *model.User, rememberMe bool) (string, error) {
+ var expireHours int
+ if rememberMe {
+ expireHours = ts.cfg.JWT.RememberExpire
+ } else {
+ expireHours = ts.cfg.JWT.RefreshExpire
+ }
+ expire := time.Duration(expireHours) * time.Hour
+ now := time.Now()
+ claims := jwt.MapClaims{
+ "uid": user.ID,
+ "email": user.Email,
+ "username": user.Username,
+ "role": user.Role,
+ "ver": user.TokenVersion,
+ "exp": now.Add(expire).Unix(),
+ "iat": now.Unix(),
+ "purpose": "refresh",
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString([]byte(ts.cfg.JWT.Secret))
+}
+
+// RefreshAccessToken 用 refresh token 换取新的 access token
+// 校验 token_version:若 DB 中版本已递增,拒绝刷新 → 即时吊销
+func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) {
+ if refreshTokenStr == "" {
+ return "", nil, ErrTokenInvalid
+ }
+
+ token, err := jwt.Parse(refreshTokenStr, func(t *jwt.Token) (interface{}, error) {
+ return []byte(ts.cfg.JWT.Secret), nil
+ })
+ if err != nil || !token.Valid {
+ return "", nil, ErrTokenExpired
+ }
+
+ claims, ok := token.Claims.(jwt.MapClaims)
+ if !ok {
+ return "", nil, ErrTokenInvalid
+ }
+
+ // 只接受 refresh 用途的 token,防止 access token 被用于刷新
+ if purpose, _ := claims["purpose"].(string); purpose != "refresh" {
+ return "", nil, ErrTokenInvalid
+ }
+
+ uid := uint(claims["uid"].(float64))
+ tokenVer := int(claims["ver"].(float64))
+
+ // 即时吊销检查 + 获取最新用户数据(角色/状态可能在 JWT 签发后已变更)
+ // 用 FindByIDForAuth 而非从 claims 重建,确保 access token 承载最新数据
+ user, err := ts.userRepo.FindByIDForAuth(uid)
+ if err != nil {
+ return "", nil, common.ErrTokenRevoked
+ }
+
+ if tokenVer != user.TokenVersion {
+ log.Printf("[RefreshAccessToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, user.TokenVersion)
+ return "", nil, common.ErrTokenRevoked
+ }
+
+ // 防止封禁用户通过 refresh 续期
+ if user.Status == model.StatusBanned {
+ return "", nil, common.ErrUserBanned
+ }
+
+ accessToken, err := ts.BuildAccessToken(user)
+ if err != nil {
+ return "", nil, err
+ }
+ return accessToken, user, nil
+}
diff --git a/internal/theme/loader.go b/internal/theme/loader.go
new file mode 100644
index 0000000..510c82b
--- /dev/null
+++ b/internal/theme/loader.go
@@ -0,0 +1,53 @@
+package theme
+
+import (
+ "html/template"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// TemplateRoot 模板根目录配置
+type TemplateRoot struct {
+ Dir string // 目录路径
+ Prefix string // 模板名前缀(如 "admin/" 表示管理后台)
+}
+
+// LoadTemplates 加载多个根目录的 .html 模板到同一模板集
+// 模板名 = prefix + 相对于 root.Dir 的相对路径
+func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
+ t := template.New("")
+ for _, root := range roots {
+ dir := filepath.Clean(root.Dir) + string(os.PathSeparator)
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || filepath.Ext(path) != ".html" {
+ return nil
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ // 模板名:prefix + 相对路径
+ name := root.Prefix + strings.TrimPrefix(path, dir)
+ _, err = t.New(name).Parse(string(content))
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+ return t, nil
+}
+
+// LoadContent 读取主题内容文件(准则等),返回不转义的 template.HTML。
+// 后期改为查数据库时,只需修改此函数实现,调用方不变。
+func LoadContent(path string) (template.HTML, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ return template.HTML(content), nil
+}
diff --git a/templates/MetaLab-2026/guidelines.html b/templates/MetaLab-2026/guidelines.html
new file mode 100644
index 0000000..e7e3df0
--- /dev/null
+++ b/templates/MetaLab-2026/guidelines.html
@@ -0,0 +1,29 @@
+致开发者:
+欢迎来到 MetaLab,一个为构建者准备的纯粹空间。为维护高质量的技术讨论环境,请您在参与前阅读本准则。我们的核心非常简单:平等交流、专注技术、开放分享。任何破坏这些基础的行为都将被处理。
+第一章 社区共识
+我们希望通过遵循以下共识,共同塑造理想的开发者家园:
+平等:这里没有特权,每个成员在发言和受尊重上权利平等。
+纯粹:讨论应围绕技术本身——代码、架构、逻辑与可验证的事实。
+开放:鼓励开源、协作与知识共享,相信开放是进步的动力。
+第二章 基本行为规范
+【核心要求】
+保持尊重,专注技术:可以就技术观点进行激烈辩论,但禁止任何形式的人身攻击、嘲讽、歧视、骚扰及引战行为。讨论应聚焦于软件开发、系统架构、技术原理等实质性内容。
+禁止广告与推广:严禁任何形式的广告、引流、软文、诱导私信、招募、付费服务推广等。分享个人或团队的非商业开源项目不在此限。
+保证内容质量与相关性:请发布对社区有建设性的内容。禁止发布与开发者社区主题无关、无意义的灌水、测试性内容,或令人不适的信息。
+【具体细则】
+善于提问与分享:提问前请先尝试搜索;提问时,请清晰描述问题、已尝试的方案及错误信息。鼓励分享高质量、有深度的原创内容或经过授权的转载(需明确标注来源)。
+遵守法律法规:严禁发布任何违反法律法规的内容,包括但不限于恶意代码、侵权盗版信息、违禁品交易信息等。
+鼓励理性讨论:表达不同意见时,应提供基于技术事实或逻辑的依据。
+第三章 内容发布与授权
+内容归属:您发布的原创内容,著作权归您本人所有。发布非原创内容(如翻译、转载)时,必须明确标注原作者及来源,并确保您的发布行为符合原内容的授权协议。
+默认授权(重要):
+为践行开源共享原则,您在本社区公开发布的原创内容,即视为默认同意按照知识共享 署名-相同方式共享 4.0 国际协议 (CC BY-SA 4.0) 进行授权。这意味着他人可以在标明您署名并以相同方式共享的前提下,使用、修改和再发布您的内容。
+质量倡议:我们强烈鼓励经过思考、调研的高质量内容。请避免发布过于随意、未经基本搜索或与社区主题完全无关的内容。
+第四章 社区治理
+治理原则:社区管理以本准则为依据,力求公开、公正。
+处理措施:对于违规行为,社区运营方可能采取 内容处置(修改、屏蔽、删除)或 账号处置(警告、临时或永久封禁)。具体尺度由运营方根据违规性质、情节及影响判断。
+申诉流程:如您认为处理有误,有权在决定作出后的 7 个工作日内,通过邮件(metazone@foxmail.com)向元域管理组提交申诉,邮件主题请注明"账号申诉",正文需附上详细理由及相关证据。
+第五章 其他
+免责声明:您应对在社区内的言行独立负责。用户发布的内容仅代表其个人观点,社区运营方无法实时审查,在法律允许的最大范围内不承担责任。
+准则修改:我们可能根据需要对准则进行修订。修订内容将在社区内显著位置公告。若您在新规生效后继续使用社区,即视为同意接受更新后的准则约束。
+遵守法律:您使用社区服务的行为必须遵守您所在地的法律法规;此外,本社区服务器位于中华人民共和国香港特别行政区,您还需遵守当地相关法律。
diff --git a/templates/MetaLab-2026/html/auth/login.html b/templates/MetaLab-2026/html/auth/login.html
new file mode 100644
index 0000000..90adf38
--- /dev/null
+++ b/templates/MetaLab-2026/html/auth/login.html
@@ -0,0 +1,48 @@
+{{template "layout/header.html" .}}
+
+
+{{template "layout/nav.html" .}}
+
+
+
+
登录 MetaLab
+
回到开发者社区
+
+
+
+
+
+
+
+{{template "layout/footer.html" .}}
+
+
+
+
+
+
+