From 483fdd919f2c4e4de170014c9323e0d1d12db486 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Tue, 26 May 2026 13:46:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=EF=BC=9A=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=20+=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=AE=A4=E8=AF=81=20+=20=E5=90=8E=E5=8F=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=B3=BB=E7=BB=9F=20+=20AGPL=203.0=20=E8=AE=B8?= =?UTF-8?q?=E5=8F=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + .gitignore | 55 +- cmd/server/main.go | 68 +++ config.yaml | 23 + docs/code-style.md | 111 ++++ docs/development.md | 531 ++++++++++++++++++ docs/routing.md | 119 ++++ docs/structure.md | 128 +++++ go.mod | 59 ++ go.sum | 140 +++++ internal/common/cookie.go | 62 ++ internal/common/crypto.go | 20 + internal/common/errors.go | 22 + internal/common/helper.go | 44 ++ internal/common/pagination.go | 51 ++ internal/common/response.go | 20 + internal/common/username.go | 51 ++ internal/config/config.go | 119 ++++ internal/controller/admin/admin_controller.go | 131 +++++ internal/controller/auth_controller.go | 229 ++++++++ internal/middleware/admin.go | 92 +++ internal/middleware/auth.go | 146 +++++ internal/middleware/csrf.go | 112 ++++ internal/middleware/ratelimit.go | 149 +++++ internal/middleware/security.go | 26 + internal/model/common.go | 15 + internal/model/dto.go | 28 + internal/model/user.go | 45 ++ internal/repository/user_repo.go | 183 ++++++ internal/router/router.go | 104 ++++ internal/service/admin_service.go | 141 +++++ internal/service/auth_service.go | 182 ++++++ internal/service/token_service.go | 123 ++++ internal/theme/loader.go | 53 ++ templates/MetaLab-2026/guidelines.html | 29 + templates/MetaLab-2026/html/auth/login.html | 48 ++ .../MetaLab-2026/html/auth/register.html | 80 +++ templates/MetaLab-2026/html/home/index.html | 22 + .../MetaLab-2026/html/layout/footer.html | 5 + .../MetaLab-2026/html/layout/header.html | 12 + templates/MetaLab-2026/html/layout/nav.html | 17 + templates/MetaLab-2026/static/css/auth.css | 351 ++++++++++++ templates/MetaLab-2026/static/css/common.css | 240 ++++++++ templates/MetaLab-2026/static/css/home.css | 354 ++++++++++++ templates/MetaLab-2026/static/js/common.js | 194 +++++++ templates/MetaLab-2026/static/js/login.js | 80 +++ templates/MetaLab-2026/static/js/register.js | 305 ++++++++++ templates/MetaLab-2026/theme.json | 9 + templates/admin/html/dashboard/index.html | 30 + templates/admin/html/layout/base.html | 38 ++ templates/admin/html/layout/footer.html | 8 + templates/admin/html/users/index.html | 50 ++ templates/admin/static/css/common.css | 295 ++++++++++ templates/admin/static/css/users.css | 12 + templates/admin/static/js/common.js | 122 ++++ templates/admin/static/js/users.js | 156 +++++ 56 files changed, 5804 insertions(+), 40 deletions(-) create mode 100644 .env.example create mode 100644 cmd/server/main.go create mode 100644 config.yaml create mode 100644 docs/code-style.md create mode 100644 docs/development.md create mode 100644 docs/routing.md create mode 100644 docs/structure.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/common/cookie.go create mode 100644 internal/common/crypto.go create mode 100644 internal/common/errors.go create mode 100644 internal/common/helper.go create mode 100644 internal/common/pagination.go create mode 100644 internal/common/response.go create mode 100644 internal/common/username.go create mode 100644 internal/config/config.go create mode 100644 internal/controller/admin/admin_controller.go create mode 100644 internal/controller/auth_controller.go create mode 100644 internal/middleware/admin.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/csrf.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/middleware/security.go create mode 100644 internal/model/common.go create mode 100644 internal/model/dto.go create mode 100644 internal/model/user.go create mode 100644 internal/repository/user_repo.go create mode 100644 internal/router/router.go create mode 100644 internal/service/admin_service.go create mode 100644 internal/service/auth_service.go create mode 100644 internal/service/token_service.go create mode 100644 internal/theme/loader.go create mode 100644 templates/MetaLab-2026/guidelines.html create mode 100644 templates/MetaLab-2026/html/auth/login.html create mode 100644 templates/MetaLab-2026/html/auth/register.html create mode 100644 templates/MetaLab-2026/html/home/index.html create mode 100644 templates/MetaLab-2026/html/layout/footer.html create mode 100644 templates/MetaLab-2026/html/layout/header.html create mode 100644 templates/MetaLab-2026/html/layout/nav.html create mode 100644 templates/MetaLab-2026/static/css/auth.css create mode 100644 templates/MetaLab-2026/static/css/common.css create mode 100644 templates/MetaLab-2026/static/css/home.css create mode 100644 templates/MetaLab-2026/static/js/common.js create mode 100644 templates/MetaLab-2026/static/js/login.js create mode 100644 templates/MetaLab-2026/static/js/register.js create mode 100644 templates/MetaLab-2026/theme.json create mode 100644 templates/admin/html/dashboard/index.html create mode 100644 templates/admin/html/layout/base.html create mode 100644 templates/admin/html/layout/footer.html create mode 100644 templates/admin/html/users/index.html create mode 100644 templates/admin/static/css/common.css create mode 100644 templates/admin/static/css/users.css create mode 100644 templates/admin/static/js/common.js create mode 100644 templates/admin/static/js/users.js 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" .}} + + + + + + + diff --git a/templates/MetaLab-2026/html/auth/register.html b/templates/MetaLab-2026/html/auth/register.html new file mode 100644 index 0000000..00ccd23 --- /dev/null +++ b/templates/MetaLab-2026/html/auth/register.html @@ -0,0 +1,80 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +
    +
    +

    创建账号

    +

    加入 MetaLab 开发者社区

    +
    + +
    + + + 请输入有效的邮箱地址 +
    +
    + + + 密码至少 8 位,需包含字母与数字 +
    +
    + + + 两次输入的密码不一致 +
    +
    + + 非共享设备推荐 +
    + + +
    + +
    +
    + + + + +
    + +{{template "layout/footer.html" .}} + + + + + + + diff --git a/templates/MetaLab-2026/html/home/index.html b/templates/MetaLab-2026/html/home/index.html new file mode 100644 index 0000000..2b258fe --- /dev/null +++ b/templates/MetaLab-2026/html/home/index.html @@ -0,0 +1,22 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +
    +
    +

    MetaLab

    +

    下一代开发者社区

    + {{if .IsLoggedIn}} +

    欢迎回来,{{.Username}}

    + {{else}} +

    一个为构建者准备的纯粹空间

    + {{end}} +
    +
    + +{{template "layout/footer.html" .}} + + + + diff --git a/templates/MetaLab-2026/html/layout/footer.html b/templates/MetaLab-2026/html/layout/footer.html new file mode 100644 index 0000000..2083568 --- /dev/null +++ b/templates/MetaLab-2026/html/layout/footer.html @@ -0,0 +1,5 @@ + diff --git a/templates/MetaLab-2026/html/layout/header.html b/templates/MetaLab-2026/html/layout/header.html new file mode 100644 index 0000000..f1965e6 --- /dev/null +++ b/templates/MetaLab-2026/html/layout/header.html @@ -0,0 +1,12 @@ + + + + + +{{if .CSRFToken}}{{end}} +{{.Title}} - MetaLab + +{{if .ExtraCSS}} + +{{end}} + diff --git a/templates/MetaLab-2026/html/layout/nav.html b/templates/MetaLab-2026/html/layout/nav.html new file mode 100644 index 0000000..2a91271 --- /dev/null +++ b/templates/MetaLab-2026/html/layout/nav.html @@ -0,0 +1,17 @@ +
    + +
    diff --git a/templates/MetaLab-2026/static/css/auth.css b/templates/MetaLab-2026/static/css/auth.css new file mode 100644 index 0000000..2cd8db4 --- /dev/null +++ b/templates/MetaLab-2026/static/css/auth.css @@ -0,0 +1,351 @@ +/* ============================================================ + MetaLab Auth Styles — 注册/登录页面专属样式 + ============================================================ */ + +/* ---------- Auth Page Layout ---------- */ +.auth-page { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 20px; +} + +.auth-card { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 2.5rem; + width: 100%; + max-width: 420px; + border: 1px solid var(--color-border); +} + +.auth-card h2 { + font-size: 1.6rem; + font-weight: 700; + color: var(--color-primary); + text-align: center; + margin-bottom: .4rem; +} + +.auth-card .auth-subtitle { + text-align: center; + color: var(--color-secondary); + font-size: .95rem; + margin-bottom: 2rem; +} + +/* ---------- Form ---------- */ +.form-group { + margin-bottom: 1.2rem; +} + +.form-group label { + display: block; + font-size: .9rem; + font-weight: 600; + color: var(--color-primary); + margin-bottom: .4rem; +} + +.form-group input { + width: 100%; + padding: .75rem 1rem; + border: 1.5px solid var(--color-border); + border-radius: var(--radius); + font-size: 1rem; + transition: var(--transition); + outline: none; + font-family: inherit; +} + +.form-group input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(52,152,219,.15); +} + +.form-group input.error { + border-color: var(--color-danger); +} + +.form-error { + color: var(--color-danger); + font-size: .8rem; + margin-top: .3rem; + display: none; +} + +.guidelines-link { + font-size: .85rem; + color: var(--color-text-light); + text-align: center; + margin: 1.2rem 0; +} + +.guidelines-link a { + color: var(--color-accent); + font-weight: 500; + transition: var(--transition); +} + +.guidelines-link a:hover { + color: #2980b9; + text-decoration: underline; +} + +.submit-btn { + width: 100%; + padding: .85rem; + background: var(--color-accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 1.05rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); +} + +.submit-btn:hover { + background: #2980b9; +} + +.submit-btn:disabled { + background: #b0bec5; + cursor: not-allowed; +} + +/* ---------- Remember Me ---------- */ +.remember-group { + margin-bottom: 1rem; +} +.remember-label { + display: inline-flex; + align-items: center; + gap: .5rem; + font-size: .9rem; + cursor: pointer; + user-select: none; +} +.remember-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--color-accent); + cursor: pointer; +} +.remember-hint { + display: block; + font-size: .78rem; + color: var(--color-secondary); + margin-top: .3rem; + margin-left: 24px; +} + +.auth-footer { + text-align: center; + margin-top: 1.5rem; + font-size: .9rem; + color: var(--color-secondary); +} + +.auth-footer a { + color: var(--color-accent); + font-weight: 500; + transition: var(--transition); +} + +.auth-footer a:hover { + color: #2980b9; + text-decoration: underline; +} + +/* ---------- Modal ---------- */ +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.5); + z-index: 2000; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-overlay.active { + display: flex; +} + +.modal { + background: var(--color-surface); + border-radius: var(--radius); + width: 100%; + max-width: 700px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 10px 40px rgba(0,0,0,.2); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.2rem 1.5rem; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.modal-header h3 { + font-size: 1.2rem; + color: var(--color-primary); +} + +.modal-close { + background: none; + border: none; + font-size: 1.4rem; + color: var(--color-secondary); + cursor: pointer; + padding: .2rem; + line-height: 1; + transition: var(--transition); +} + +.modal-close:hover { + color: var(--color-danger); +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 1.5rem 1.5rem 1rem; + font-size: .92rem; + line-height: 1.8; + color: var(--color-text); +} + +.modal-body h4 { + font-size: 1.05rem; + color: var(--color-primary); + margin: 1.2rem 0 .5rem; +} + +.modal-body h4:first-child { + margin-top: 0; +} + +.modal-body p { + margin-bottom: .8rem; +} + +.modal-body .red { + color: var(--color-danger); + font-weight: 600; +} + +.modal-body .highlight { + color: var(--color-danger); + font-weight: 700; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-top: 1px solid var(--color-border); + flex-shrink: 0; + flex-wrap: wrap; + gap: .6rem; +} + +.timer { + display: flex; + align-items: center; + gap: .4rem; + color: var(--color-secondary); + font-size: .9rem; + font-weight: 500; +} + +.timer .count { + color: var(--color-accent); + font-weight: 700; + min-width: 1.5em; + text-align: center; +} + +.timer.done .count { + color: #27ae60; +} + +.confirm-btn { + padding: .6rem 1.8rem; + background: var(--color-accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: .95rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); +} + +.confirm-btn:hover { + background: #2980b9; +} + +.confirm-btn:disabled { + background: #b0bec5; + cursor: not-allowed; +} + +.scroll-hint { + color: var(--color-danger); + font-size: .82rem; + font-weight: 500; + display: flex; + align-items: center; + gap: .3rem; +} + +/* ---------- Toast ---------- */ +.toast { + position: fixed; + top: 1.5rem; + left: 50%; + transform: translateX(-50%) translateY(-120px); + background: #27ae60; + color: #fff; + padding: .85rem 2rem; + border-radius: var(--radius); + font-weight: 600; + font-size: 1rem; + z-index: 3000; + box-shadow: 0 6px 20px rgba(39,174,96,.35); + transition: transform .4s ease; + pointer-events: none; +} + +.toast.show { + transform: translateX(-50%) translateY(0); +} + +.toast.error { + background: var(--color-danger); + box-shadow: 0 6px 20px rgba(231,76,60,.35); +} + +/* ---------- Responsive ---------- */ +@media (max-width: 768px) { + .auth-card { + padding: 1.8rem; + } + .modal { + max-height: 90vh; + } + .modal-body { + font-size: .85rem; + } +} diff --git a/templates/MetaLab-2026/static/css/common.css b/templates/MetaLab-2026/static/css/common.css new file mode 100644 index 0000000..6abf913 --- /dev/null +++ b/templates/MetaLab-2026/static/css/common.css @@ -0,0 +1,240 @@ +/* ============================================================ + MetaLab Common Styles — 全站共享样式(Reset / 变量 / 导航 / 页脚) + 每个页面加载此文件后,按需加载页面专属 CSS + ============================================================ */ + +:root { + --color-bg: #f8f9fa; + --color-surface: #fff; + --color-primary: #2c3e50; + --color-secondary: #7f8c8d; + --color-accent: #3498db; + --color-danger: #e74c3c; + --color-border: #e0e0e0; + --color-text: #333; + --color-text-light: #666; + --shadow: 0 4px 12px rgba(0,0,0,.08); + --radius: 8px; + --transition: all .3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; +} + +/* ---------- Reset ---------- */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-snap-type: y mandatory; +} + +body { + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; + overflow-x: hidden; +} + +a { + text-decoration: none; + color: inherit; +} + +/* ---------- Layout ---------- */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +.icon { + width: 1em; + height: 1em; + vertical-align: -.125em; + display: inline-block; + flex-shrink: 0; +} + +/* ---------- Header / Nav ---------- */ +header { + background: var(--color-surface); + box-shadow: var(--shadow); + position: sticky; + top: 0; + z-index: 1000; +} + +.nav-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: .6rem 0; +} + +.logo { + font-size: 1.4rem; + font-weight: 700; + color: var(--color-primary); +} + +.logo span { + color: var(--color-accent); +} + +.nav-links { + display: flex; + list-style: none; + gap: 2rem; +} + +.nav-links a { + font-weight: 500; + padding: .5rem 0; + position: relative; + transition: var(--transition); +} + +.nav-links a:hover, +.nav-links a.nav-active { + color: var(--color-accent); +} + +.nav-links a::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 0; + height: 2px; + background: var(--color-accent); + transition: width .3s ease; +} + +.nav-links a:hover::after, +.nav-links a.nav-active::after { + width: 100%; +} + +.nav-login { + color: var(--color-accent) !important; + font-weight: 600 !important; +} + +.nav-user { + color: var(--color-accent) !important; + font-weight: 600 !important; +} + +.nav-logout { + color: var(--color-secondary) !important; +} + +.mobile-menu-btn { + display: none; + background: none; + border: none; + font-size: 1.5rem; + color: var(--color-primary); + cursor: pointer; +} + +/* ---------- Contact / Footer ---------- */ +.contact-bar { + text-align: center; + padding: .6rem 0; + background: #e8f0f8; + border-top: 1px solid var(--color-border); + font-size: .95rem; + color: var(--color-text-light); +} + +.contact-bar a { + color: var(--color-accent); + font-weight: 600; + transition: var(--transition); +} + +.contact-bar a:hover { + color: #2980b9; + text-decoration: underline; +} + +footer { + background: var(--color-primary); + color: #bdc3c7; + padding: .8rem 0; +} + +.footer-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.copyright { + font-size: .9rem; + color: #95a5a6; + width: 100%; +} + +/* ---------- Scroll-to-top ---------- */ +.scroll-top-btn { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--color-accent); + color: #fff; + border: none; + box-shadow: 0 4px 12px rgba(52,152,219,.35); + cursor: pointer; + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + opacity: 0; + visibility: hidden; + transform: translateY(10px); + transition: opacity .3s ease, visibility .3s ease, transform .3s ease; +} + +.scroll-top-btn.visible { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.scroll-top-btn:hover { + background: #2980b9; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(52,152,219,.45); +} + +/* ---------- Responsive (共享:导航) ---------- */ +@media (max-width: 768px) { + .nav-links { + display: none; + position: absolute; + top: 100%; + left: 0; + width: 100%; + background: var(--color-surface); + flex-direction: column; + padding: 1.5rem; + box-shadow: 0 10px 15px rgba(0,0,0,.1); + text-align: center; + } + .nav-links.active { + display: flex; + } + .mobile-menu-btn { + display: block; + } +} diff --git a/templates/MetaLab-2026/static/css/home.css b/templates/MetaLab-2026/static/css/home.css new file mode 100644 index 0000000..d620098 --- /dev/null +++ b/templates/MetaLab-2026/static/css/home.css @@ -0,0 +1,354 @@ +/* ============================================================ + MetaLab Home Styles — 首页专属样式(Hero / Plans / Features / Timeline) + 仅在首页加载,通过 ExtraCSS 注入 + ============================================================ */ + +/* ---------- Hero ---------- */ +.hero { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: 4rem 0; + text-align: center; + background: linear-gradient(180deg, #fff 0%, #f0f4f8 100%); + scroll-snap-align: start; +} + +.hero h1 { + font-size: 4rem; + font-weight: 800; + color: var(--color-primary); + margin-bottom: .6rem; + line-height: 1.1; + letter-spacing: -1px; +} + +.hero h1 span { + color: var(--color-accent); +} + +.hero-tagline { + font-size: 1.5rem; + color: var(--color-secondary); + margin-bottom: 1.5rem; + font-weight: 400; +} + +.hero-desc { + font-size: 1.15rem; + color: var(--color-text-light); + max-width: 500px; + margin: 0 auto 2.2rem; + line-height: 1.8; +} + +.hero-actions { + display: flex; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; +} + +.cta-button, +.secondary-button { + display: inline-block; + padding: .9rem 2.2rem; + border-radius: var(--radius); + font-weight: 600; + font-size: 1.1rem; + transition: var(--transition); +} + +.cta-button { + background: var(--color-accent); + color: #fff; + border: 2px solid var(--color-accent); +} + +.cta-button:hover { + background: transparent; + color: var(--color-accent); +} + +.secondary-button { + background: transparent; + color: var(--color-accent); + border: 2px solid var(--color-accent); +} + +.secondary-button:hover { + background: var(--color-accent); + color: #fff; +} + +/* ---------- Shared section headings ---------- */ +.section-title { + text-align: center; + font-size: 2.2rem; + color: var(--color-primary); + margin-bottom: .8rem; +} + +.section-subtitle { + text-align: center; + color: var(--color-secondary); + font-size: 1.1rem; + max-width: 600px; + margin: 0 auto 3.5rem; +} + +/* ---------- Plans ---------- */ +.plans { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: 1.5rem 0; + scroll-snap-align: start; +} + +.plans .section-title { + font-size: 2rem; + margin-bottom: .4rem; +} + +.plans .section-subtitle { + margin-bottom: 1.2rem; +} + +.plans-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.25rem; +} + +.plan-card { + background: var(--color-surface); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: var(--shadow); + transition: var(--transition); + border: 1px solid var(--color-border); + display: flex; + flex-direction: column; + position: relative; +} + +.plan-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0,0,0,.12); +} + +.plan-icon { + width: 50px; + height: 50px; + background: rgba(52,152,219,.1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; + color: var(--color-accent); + font-size: 1.6rem; +} + +.plan-card h3 { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-primary); + margin-bottom: .5rem; +} + +.plan-card p { + color: var(--color-text-light); + margin-bottom: 1rem; + flex-grow: 1; + font-size: .95rem; +} + +.plan-status { + display: inline-block; + background: #e8f4fc; + color: var(--color-accent); + padding: .3rem .9rem; + border-radius: 20px; + font-size: .85rem; + font-weight: 500; +} + +/* ---------- Features ---------- */ +.features { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: 4rem 0; + background: var(--color-surface); + scroll-snap-align: start; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2.5rem; + margin-top: 3rem; +} + +.feature { + text-align: center; +} + +.feature .icon { + font-size: 2.5rem; + color: var(--color-accent); + margin-bottom: 1.5rem; +} + +.feature h3 { + font-size: 1.3rem; + margin-bottom: .8rem; + color: var(--color-primary); +} + +.feature p { + color: var(--color-text-light); +} + +/* ---------- Timeline ---------- */ +.section-group { + height: 100vh; + display: flex; + flex-direction: column; + scroll-snap-align: start; +} + +.timeline-vision { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding: 1.5rem 0 1rem; + overflow: hidden; +} + +.timeline-vision .section-title { + margin-bottom: .3rem; +} + +.timeline-vision .section-subtitle { + margin-bottom: 1.5rem; +} + +.timeline { + max-width: 1100px; + margin: 0 auto; + position: relative; + display: flex; + justify-content: center; + gap: 2rem; + padding: 2rem 0 1rem; + overflow-x: auto; + overflow-y: visible; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; +} + +.timeline::before { + content: ''; + position: absolute; + left: 4%; + top: 1.9rem; + width: 92%; + height: 3px; + background: var(--color-accent); + border-radius: 2px; +} + +.timeline-item { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + flex-shrink: 0; + min-width: 200px; + max-width: 240px; + padding: 0 .5rem; +} + +.timeline-marker { + width: 24px; + height: 24px; + background: var(--color-accent); + border-radius: 50%; + border: 4px solid var(--color-surface); + box-shadow: var(--shadow); + margin-bottom: 1.5rem; + z-index: 1; + flex-shrink: 0; +} + +.timeline-content { + background: var(--color-surface); + border-radius: var(--radius); + padding: 1.5rem 1.2rem; + box-shadow: var(--shadow); + border: 1px solid var(--color-border); + text-align: center; + width: 100%; + transition: var(--transition); +} + +.timeline-item:hover .timeline-content { + transform: translateY(-4px); + box-shadow: 0 10px 20px rgba(0,0,0,.12); +} + +.timeline-title { + font-size: 1.15rem; + font-weight: 600; + color: var(--color-primary); + margin-bottom: .5rem; +} + +.timeline-desc { + color: var(--color-text-light); + font-size: .95rem; +} + +/* ---------- Responsive (首页专属) ---------- */ +@media (max-width: 768px) { + .hero h1 { + font-size: 3rem; + } + .hero-tagline { + font-size: 1.25rem; + } + .hero-desc { + font-size: 1.05rem; + } + .timeline { + justify-content: flex-start; + gap: 1.5rem; + padding: 2rem 20px 1.5rem; + scroll-snap-type: x mandatory; + } + .timeline-item { + min-width: 240px; + max-width: 280px; + scroll-snap-align: start; + } + .timeline::before { + left: 20px; + width: calc(240px * 4 + 1.5rem * 3 + 20px); + } + .cta-button, + .secondary-button { + display: block; + width: 100%; + margin: .5rem 0; + } + .secondary-button { + margin-left: 0; + } +} diff --git a/templates/MetaLab-2026/static/js/common.js b/templates/MetaLab-2026/static/js/common.js new file mode 100644 index 0000000..f5d2ac5 --- /dev/null +++ b/templates/MetaLab-2026/static/js/common.js @@ -0,0 +1,194 @@ +/* ============================================================ + MetaLab Common JS — 全站共享脚本 + ============================================================ */ +(function () { + 'use strict'; + + // ---- Shared utils (exposed to other scripts via window.MetaLab) ---- + window.MetaLab = window.MetaLab || {}; + var toastTimer = null; + window.MetaLab.toast = function (el, msg, isError) { + if (!el) return; + clearTimeout(toastTimer); + el.textContent = msg; + el.className = 'toast' + (isError ? ' error' : ''); + void el.offsetWidth; + el.classList.add('show'); + toastTimer = setTimeout(function () { + el.classList.remove('show'); + }, 3000); + }; + + // ---- CSRF token helper (reads from tag rendered by server) ---- + window.MetaLab.csrfToken = function () { + var meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; + }; + + // ---- Auto-attach X-CSRF-Token to all state-changing requests ---- + // Monkey-patch XMLHttpRequest and fetch to inject CSRF header + (function () { + var token = window.MetaLab.csrfToken(); + if (!token) return; + + // Patch XMLHttpRequest (used by register.js / login.js / common.js) + var origXHROpen = XMLHttpRequest.prototype.open; + var origXHRSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { + this._method = method; + this._url = url; + return origXHROpen.apply(this, arguments); + }; + var SAME_ORIGIN = /^\/(?!\/)/; + XMLHttpRequest.prototype.send = function () { + if (SAME_ORIGIN.test(this._url) && + this._method && !/^(GET|HEAD|OPTIONS)$/i.test(this._method)) { + this.setRequestHeader('X-CSRF-Token', token); + } + return origXHRSend.apply(this, arguments); + }; + + // Patch fetch (used by auto-refresh) + var origFetch = window.fetch; + window.fetch = function (url, options) { + options = options || {}; + var headers = options.headers || {}; + var method = (options.method || 'GET').toUpperCase(); + var urlStr = (typeof url === 'string') ? url : ''; + if (SAME_ORIGIN.test(urlStr) && !/^(GET|HEAD|OPTIONS)$/i.test(method)) { + if (headers instanceof Headers) { + headers.set('X-CSRF-Token', token); + } else { + headers['X-CSRF-Token'] = token; + } + options.headers = headers; + } + return origFetch(url, options); + }; + })(); + + var menuBtn = document.getElementById('mobileMenuBtn'); + var navLinks = document.getElementById('navLinks'); + var scrollBtn = document.getElementById('scrollTopBtn'); + + var menuIcon = ''; + var closeIcon = ''; + + // ---- Mobile menu toggle ---- + if (menuBtn && navLinks) { + menuBtn.addEventListener('click', function () { + navLinks.classList.toggle('active'); + menuBtn.innerHTML = navLinks.classList.contains('active') ? closeIcon : menuIcon; + }); + } + + // ---- Close mobile menu on nav click ---- + var links = document.querySelectorAll('.nav-links a'); + for (var i = 0; i < links.length; i++) { + links[i].addEventListener('click', function () { + if (navLinks) { + navLinks.classList.remove('active'); + } + if (menuBtn) { + menuBtn.innerHTML = menuIcon; + } + }); + } + + // ---- Smooth scroll for anchor links ---- + var anchors = document.querySelectorAll('a[href^="#"]'); + for (var j = 0; j < anchors.length; j++) { + anchors[j].addEventListener('click', function (e) { + var href = this.getAttribute('href'); + if (href === '#') return; + var target = document.querySelector(href); + if (target) { + e.preventDefault(); + window.scrollTo({ top: target.offsetTop - 55, behavior: 'smooth' }); + } + }); + } + + // ---- Scroll-to-top button ---- + if (scrollBtn) { + window.addEventListener('scroll', function () { + scrollBtn.classList.toggle('visible', window.scrollY > window.innerHeight * 0.5); + }); + scrollBtn.addEventListener('click', function () { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + } + + // ---- Dev greeting ---- + console.log('%c🔨 嘿,开发者朋友!', 'color:#2ecc71;font-size:18px;font-weight:bold'); + console.log('%c欢迎来到 MetaLab 的蓝图页。', 'color:#34495e'); + console.log('%c我们正在搭建一个纯净、友好的下一代开发者社区。', 'color:#34495e'); + console.log('%c有任何想法或愿意参与早期测试?欢迎联系我们:metazone@foxmail.com', 'color:#e74c3c;font-style:italic'); + + // ---- Logout ---- + var logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', function () { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/auth/logout', true); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + window.location.href = '/'; + } + }; + xhr.send(); + }); + } + + // ---- Auto-refresh token (silent) ---- + // 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期) + // 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效 + // → 页面先渲染为未登录 → 刷新成功后 reload 一次获得登录态 + // 不在认证页面执行刷新(登录/注册页无需续期) + var onAuthPage = /^\/auth\//.test(window.location.pathname); + if (!onAuthPage) { + + var REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes + var refreshTimer = null; + + function tryRefresh(callback) { + fetch('/api/auth/refresh', { method: 'POST' }) + .then(function (res) { + if (res.ok) { + if (callback) callback(); + } else { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + }) + .catch(function () { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + }); + } + + // 仅当存在"记住我"标记 Cookie 时才尝试刷新(避免无需刷新时产生 401 控制台报错) + function hasCookie(name) { + return document.cookie.split(';').some(function (c) { + return c.trim().startsWith(name + '='); + }); + } + + if (hasCookie('mlb_rm')) { + tryRefresh(function () { + refreshTimer = setInterval(function () { tryRefresh(null); }, REFRESH_INTERVAL); + + if (!document.getElementById('logoutBtn') && !sessionStorage.getItem('mlb_refreshed')) { + sessionStorage.setItem('mlb_refreshed', '1'); + window.location.reload(); + } + }); + sessionStorage.removeItem('mlb_refreshed'); + } + + } // end if (!onAuthPage) +})(); diff --git a/templates/MetaLab-2026/static/js/login.js b/templates/MetaLab-2026/static/js/login.js new file mode 100644 index 0000000..e4a59ec --- /dev/null +++ b/templates/MetaLab-2026/static/js/login.js @@ -0,0 +1,80 @@ +/* ============================================================ + MetaLab Login JS — 登录页面专属脚本 + ============================================================ */ +(function () { + 'use strict'; + + var loginForm = document.getElementById('loginForm'); + var emailInput = document.getElementById('email'); + var passwordInput = document.getElementById('password'); + var rememberMeCheckbox = document.getElementById('rememberMe'); + var emailError = document.getElementById('emailError'); + var passwordError = document.getElementById('passwordError'); + var submitBtn = document.getElementById('submitBtn'); + var toast = document.getElementById('toast'); + + if (!loginForm) return; + + function checkFormValidity() { + var email = emailInput.value.trim(); + var password = passwordInput.value; + submitBtn.disabled = !(email && password); + } + + emailInput.addEventListener('input', function () { + var v = emailInput.value.trim(); + if (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { + emailInput.classList.add('error'); + emailError.style.display = 'block'; + } else { + emailInput.classList.remove('error'); + emailError.style.display = 'none'; + } + checkFormValidity(); + }); + + passwordInput.addEventListener('input', checkFormValidity); + + loginForm.addEventListener('submit', function (e) { + e.preventDefault(); + + var email = emailInput.value.trim(); + var password = passwordInput.value; + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + emailInput.classList.add('error'); + emailError.style.display = 'block'; + return; + } + if (!password) { + passwordInput.classList.add('error'); + passwordError.style.display = 'block'; + return; + } + + doLogin(email, password); + }); + + function doLogin(email, password) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/auth/login', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + var resp; + try { resp = JSON.parse(xhr.responseText); } catch (err) { resp = null; } + + if (xhr.status === 200 && resp && resp.success) { + window.MetaLab.toast(toast, '登录成功', false); + setTimeout(function () { + window.location.href = '/'; + }, 800); + } else { + var msg = (resp && resp.message) ? resp.message : '登录失败,请稍后重试'; + window.MetaLab.toast(toast, msg, true); + } + } + }; + xhr.send(JSON.stringify({ email: email, password: password, remember_me: rememberMeCheckbox.checked })); + } +})(); diff --git a/templates/MetaLab-2026/static/js/register.js b/templates/MetaLab-2026/static/js/register.js new file mode 100644 index 0000000..91da7a5 --- /dev/null +++ b/templates/MetaLab-2026/static/js/register.js @@ -0,0 +1,305 @@ +/* ============================================================ + MetaLab Register JS — 注册页面专属脚本 + ============================================================ */ +(function () { + 'use strict'; + + // ---- DOM refs ---- + var openGuidelines = document.getElementById('openGuidelines'); + var modalOverlay = document.getElementById('modalOverlay'); + var modalBody = document.getElementById('modalBody'); + var modalClose = document.getElementById('modalClose'); + var modalEnforce = document.getElementById('modalEnforce'); + var confirmBtn = document.getElementById('confirmBtn'); + var previewCloseBtn = document.getElementById('previewCloseBtn'); + var timerCount = document.getElementById('timerCount'); + var timerLabel = document.getElementById('timerLabel'); + var timerUnit = document.getElementById('timerUnit'); + var timerDone = document.getElementById('timerDone'); + var timer = document.getElementById('timer'); + var scrollHint = document.getElementById('scrollHint'); + var submitBtn = document.getElementById('submitBtn'); + var registerForm = document.getElementById('registerForm'); + var toast = document.getElementById('toast'); + var emailInput = document.getElementById('email'); + var passwordInput = document.getElementById('password'); + var confirmPasswordInput = document.getElementById('confirmPassword'); + var rememberMeCheckbox = document.getElementById('rememberMe'); + var emailError = document.getElementById('emailError'); + var passwordError = document.getElementById('passwordError'); + var confirmPasswordError = document.getElementById('confirmPasswordError'); + + // Guard: exit if not on auth page + if (!registerForm) return; + + var countdown = 60; + var timerInterval = null; + var reachedBottom = false; + var agreed = false; + var isRegistrationMode = false; + var countdownPaused = false; + + // ---- Modal Open ---- + function openModal(onAgreed) { + isRegistrationMode = typeof onAgreed === 'function'; + clearInterval(timerInterval); + countdown = 60; + reachedBottom = false; + agreed = false; + modalBody.scrollTop = 0; + modalOverlay.classList.add('active'); + modalOverlay._onAgreed = isRegistrationMode ? onAgreed : null; + + if (isRegistrationMode) { + modalEnforce.style.display = ''; + confirmBtn.style.display = ''; + previewCloseBtn.style.display = 'none'; + timerCount.textContent = '60'; + timerLabel.style.display = ''; + timerCount.style.display = ''; + timerUnit.style.display = ''; + timerDone.style.display = 'none'; + timer.classList.remove('done'); + countdownPaused = false; + confirmBtn.disabled = true; + scrollHint.style.display = 'flex'; + scrollHint.textContent = '▼ 请滚动至底部完成阅读'; + scrollHint.style.color = 'var(--color-danger)'; + startCountdown(); + } else { + modalEnforce.style.display = 'none'; + confirmBtn.style.display = 'none'; + previewCloseBtn.style.display = ''; + } + } + + // ---- Countdown ---- + function startCountdown() { + timerInterval = setInterval(function () { + if (countdownPaused) return; + countdown--; + timerCount.textContent = countdown; + if (countdown <= 0) { + clearInterval(timerInterval); + timerLabel.style.display = 'none'; + timerCount.style.display = 'none'; + timerUnit.style.display = 'none'; + timerDone.style.display = ''; + timer.classList.add('done'); + scrollHint.textContent = '▼ 请滚动至底部完成阅读'; + updateConfirmState(); + } + }, 1000); + } + + // ---- Scroll Detection ---- + modalBody.addEventListener('scroll', function () { + if (reachedBottom) return; + if (modalBody.scrollTop + modalBody.clientHeight >= modalBody.scrollHeight - 5) { + reachedBottom = true; + scrollHint.textContent = '✓ 已阅读至底部'; + scrollHint.style.color = '#27ae60'; + updateConfirmState(); + } + }); + + // ---- Pause countdown on tab/window inactive ---- + document.addEventListener('visibilitychange', function () { + if (!isRegistrationMode) return; + countdownPaused = document.hidden; + }); + window.addEventListener('blur', function () { + if (isRegistrationMode) countdownPaused = true; + }); + window.addEventListener('focus', function () { + if (isRegistrationMode) countdownPaused = false; + }); + + // ---- Update Confirm Button ---- + function updateConfirmState() { + if (countdown <= 0 && reachedBottom) { + confirmBtn.disabled = false; + scrollHint.style.display = 'none'; + } + } + + // ---- Confirm (agree to guidelines) ---- + confirmBtn.addEventListener('click', function () { + agreed = true; + modalOverlay.classList.remove('active'); + if (modalOverlay._onAgreed) { + modalOverlay._onAgreed(); + modalOverlay._onAgreed = null; + } + }); + + // ---- Close Modal ---- + modalClose.addEventListener('click', function () { + modalOverlay.classList.remove('active'); + }); + previewCloseBtn.addEventListener('click', function () { + modalOverlay.classList.remove('active'); + }); + modalOverlay.addEventListener('click', function (e) { + if (e.target === modalOverlay) { + modalOverlay.classList.remove('active'); + } + }); + + // ---- Open Guidelines Link (preview only) ---- + openGuidelines.addEventListener('click', function (e) { + e.preventDefault(); + openModal(null); + }); + + // ---- Form Validation ---- + function checkFormValidity() { + var email = emailInput.value.trim(); + var password = passwordInput.value; + var confirm = confirmPasswordInput.value; + var emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + var passwordValid = password.length >= 8 && /[a-zA-Z]/.test(password) && /\d/.test(password); + var confirmValid = passwordValid && confirm === password; + submitBtn.disabled = !(emailValid && passwordValid && confirmValid); + } + + emailInput.addEventListener('input', function () { + var v = emailInput.value.trim(); + if (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { + emailInput.classList.add('error'); + emailError.style.display = 'block'; + } else { + emailInput.classList.remove('error'); + emailError.style.display = 'none'; + } + checkFormValidity(); + }); + + passwordInput.addEventListener('input', function () { + var v = passwordInput.value; + if (v && (v.length < 8 || !/[a-zA-Z]/.test(v) || !/\d/.test(v))) { + passwordInput.classList.add('error'); + passwordError.style.display = 'block'; + } else { + passwordInput.classList.remove('error'); + passwordError.style.display = 'none'; + } + if (confirmPasswordInput.value) { + validateConfirmPassword(); + } + checkFormValidity(); + }); + + confirmPasswordInput.addEventListener('input', function () { + validateConfirmPassword(); + checkFormValidity(); + }); + + function validateConfirmPassword() { + var confirm = confirmPasswordInput.value; + var password = passwordInput.value; + if (confirm && confirm !== password) { + confirmPasswordInput.classList.add('error'); + confirmPasswordError.style.display = 'block'; + } else { + confirmPasswordInput.classList.remove('error'); + confirmPasswordError.style.display = 'none'; + } + } + + // ---- Form Submit: validate → check email → open guidelines → on agree → submit ---- + registerForm.addEventListener('submit', function (e) { + e.preventDefault(); + + var email = emailInput.value.trim(); + var password = passwordInput.value; + var confirm = confirmPasswordInput.value; + + // Client-side validation + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + emailInput.classList.add('error'); + emailError.style.display = 'block'; + return; + } + if (password.length < 8 || !/[a-zA-Z]/.test(password) || !/\d/.test(password)) { + passwordInput.classList.add('error'); + passwordError.style.display = 'block'; + return; + } + if (confirm !== password) { + confirmPasswordInput.classList.add('error'); + confirmPasswordError.style.display = 'block'; + return; + } + + // Disable submit to prevent double-click + submitBtn.disabled = true; + submitBtn.textContent = '检查中...'; + + // Step 1: Check if email already registered (before showing 60s guidelines) + var checkXhr = new XMLHttpRequest(); + checkXhr.open('POST', '/api/auth/check-email', true); + checkXhr.setRequestHeader('Content-Type', 'application/json'); + checkXhr.onreadystatechange = function () { + if (checkXhr.readyState !== 4) return; + + submitBtn.disabled = true; // will be re-enabled by checkFormValidity if needed + + var resp; + try { resp = JSON.parse(checkXhr.responseText); } catch (err) { resp = null; } + + if (checkXhr.status === 200 && resp && resp.success) { + if (resp.data && resp.data.exists) { + // Already registered → notify user immediately, skip guidelines + window.MetaLab.toast(toast, '该邮箱已注册,请直接登录', true); + submitBtn.textContent = '注 册'; + submitBtn.disabled = false; + } else { + // Not registered → proceed to guidelines modal + submitBtn.textContent = '注 册'; + openModal(function () { + doRegister(email, password, confirm); + }); + } + } else { + // API error → show message, don't block + var msg = (resp && resp.message) ? resp.message : '网络异常,请稍后重试'; + window.MetaLab.toast(toast, msg, true); + submitBtn.textContent = '注 册'; + submitBtn.disabled = false; + } + }; + checkXhr.send(JSON.stringify({ email: email })); + }); + + // ---- AJAX Register ---- + function doRegister(email, password, confirmPassword) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/auth/register', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + var resp; + try { resp = JSON.parse(xhr.responseText); } catch (err) { resp = null; } + + if (xhr.status === 200 && resp && resp.success) { + window.MetaLab.toast(toast, '🎉 注册成功!欢迎加入 MetaLab', false); + // Token 已通过 HttpOnly Cookie 自动设置,JS 无需处理 + // 注册即登录,跳转首页 + setTimeout(function () { + window.location.href = '/'; + }, 1500); + } else { + var msg = (resp && resp.message) ? resp.message : '注册失败,请稍后重试'; + window.MetaLab.toast(toast, msg, true); + } + } + }; + xhr.send(JSON.stringify({ + email: email, + password: password, + confirm_password: confirmPassword, + remember_me: rememberMeCheckbox.checked + })); + } +})(); diff --git a/templates/MetaLab-2026/theme.json b/templates/MetaLab-2026/theme.json new file mode 100644 index 0000000..46b78dd --- /dev/null +++ b/templates/MetaLab-2026/theme.json @@ -0,0 +1,9 @@ +{ + "name": "MetaLab-2026", + "display_name": "MetaLab 2026 标准主题", + "version": "1.0.0", + "author": "MetaZone", + "description": "默认社区主题,清爽简约", + "preview": "preview.png", + "compatible_version": ">=1.0.0" +} diff --git a/templates/admin/html/dashboard/index.html b/templates/admin/html/dashboard/index.html new file mode 100644 index 0000000..665b002 --- /dev/null +++ b/templates/admin/html/dashboard/index.html @@ -0,0 +1,30 @@ +{{template "admin/layout/base.html" .}} + + +
    +
    +
    +
    用户总数
    +
    +
    +
    +
    帖子总数
    +
    +
    +
    +
    当前角色
    {{.Role}}
    +
    +
    + +
    +

    快速开始

    + +
    +{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/html/layout/base.html b/templates/admin/html/layout/base.html new file mode 100644 index 0000000..0e38e38 --- /dev/null +++ b/templates/admin/html/layout/base.html @@ -0,0 +1,38 @@ + + + + + +{{if .CSRFToken}}{{end}} +{{.Title}} - MetaLab CP + +{{if .ExtraCSS}} + +{{end}} + + +
    +
    + +
    + {{.Username}} + ← 返回前台 +
    +
    +
    +
    + +
    diff --git a/templates/admin/html/layout/footer.html b/templates/admin/html/layout/footer.html new file mode 100644 index 0000000..d929b4f --- /dev/null +++ b/templates/admin/html/layout/footer.html @@ -0,0 +1,8 @@ +
    +
    + +{{if .ExtraJS}} + +{{end}} + + diff --git a/templates/admin/html/users/index.html b/templates/admin/html/users/index.html new file mode 100644 index 0000000..b4478d2 --- /dev/null +++ b/templates/admin/html/users/index.html @@ -0,0 +1,50 @@ +{{template "admin/layout/base.html" .}} + + + +
    + + + + +
    + + +
    + + + + + + + + + + + + + + + +
    UID用户名邮箱角色状态注册时间操作
    加载中...
    +
    + + +
    + +{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/static/css/common.css b/templates/admin/static/css/common.css new file mode 100644 index 0000000..a2876d0 --- /dev/null +++ b/templates/admin/static/css/common.css @@ -0,0 +1,295 @@ +/* ===================================================== + MetaLab 管理面板 - 通用样式 + 单一职责:布局、导航、通用组件(按钮/表格/卡片/弹窗等) + 不包含任何页面特有样式 + ===================================================== */ + +/* --- 变量 --- */ +:root { + --bg: #f5f6fa; + --bg-card: #ffffff; + --text: #2d3436; + --text-muted: #636e72; + --border: #e0e0e0; + --primary: #0984e3; + --primary-hover: #0773c5; + --danger: #d63031; + --danger-hover: #c0392b; + --success: #00b894; + --warning: #e17055; + --sidebar-bg: #1e272e; + --sidebar-text: #b2bec3; + --sidebar-active: #0984e3; + --header-bg: #2d3436; + --radius: 6px; + --shadow: 0 1px 3px rgba(0,0,0,0.08); +} + +/* --- 重置 --- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + color: var(--text); + background: var(--bg); + line-height: 1.6; +} +a { color: var(--primary); text-decoration: none; } +a:hover { color: var(--primary-hover); } + +/* --- 顶部导航 --- */ +.admin-header { + background: var(--header-bg); + color: #fff; + height: 52px; + position: fixed; top: 0; left: 0; right: 0; + z-index: 100; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} +.admin-header-inner { + max-width: 100%; + height: 100%; + padding: 0 20px; + display: flex; align-items: center; justify-content: space-between; +} +.admin-logo { + font-size: 18px; font-weight: 700; + color: #fff; letter-spacing: 0.5px; + display: flex; align-items: center; gap: 6px; +} +.admin-logo span { + font-weight: 400; font-size: 14px; + color: var(--sidebar-text); +} +.admin-logo:hover { color: #fff; } +.admin-header-right { + display: flex; align-items: center; gap: 16px; +} +.admin-user { color: #dfe6e9; font-size: 13px; } +.admin-back-btn { + font-size: 12px; color: #b2bec3; + padding: 4px 10px; border: 1px solid #636e72; + border-radius: 4px; transition: all 0.2s; +} +.admin-back-btn:hover { color: #fff; border-color: #fff; } + +/* --- 整体布局 --- */ +.admin-container { + display: flex; + margin-top: 52px; + min-height: calc(100vh - 52px); +} + +/* --- 侧边栏 --- */ +.admin-sidebar { + width: 200px; min-width: 200px; + background: var(--sidebar-bg); + padding: 16px 0; + position: sticky; top: 52px; + height: calc(100vh - 52px); + overflow-y: auto; +} +.sidebar-item { + display: flex; align-items: center; gap: 10px; + padding: 10px 20px; + color: var(--sidebar-text); + font-size: 13px; + transition: all 0.15s; + border-left: 3px solid transparent; +} +.sidebar-item:hover { color: #fff; background: rgba(255,255,255,0.05); } +.sidebar-item.active { + color: #fff; background: rgba(9,132,227,0.2); + border-left-color: var(--sidebar-active); +} +.sidebar-icon { width: 18px; height: 18px; flex-shrink: 0; } +.sidebar-icon svg { width: 100%; height: 100%; } + +/* --- 主内容区 --- */ +.admin-main { + flex: 1; padding: 28px 32px; + max-width: 1200px; min-width: 0; +} + +/* --- 页面标题 --- */ +.page-header { margin-bottom: 24px; } +.page-header h1 { font-size: 22px; font-weight: 600; color: var(--text); } +.page-desc { color: var(--text-muted); font-size: 13px; margin-top: 4px; } + +/* --- 统计卡片 --- */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} +.stat-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + display: flex; align-items: center; gap: 16px; +} +.stat-icon { + width: 44px; height: 44px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; +} +.stat-icon svg { width: 24px; height: 24px; } +.stat-blue { background: #e3f2fd; color: var(--primary); } +.stat-green { background: #e8f5e9; color: var(--success); } +.stat-orange { background: #fff3e0; color: var(--warning); } +.stat-label { font-size: 12px; color: var(--text-muted); } +.stat-value { font-size: 22px; font-weight: 600; color: var(--text); } + +/* --- 信息卡片 --- */ +.info-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 20px 24px; + box-shadow: var(--shadow); +} +.info-card h3 { font-size: 16px; margin-bottom: 12px; } +.info-card ul { padding-left: 20px; color: var(--text-muted); font-size: 13px; line-height: 2; } + +/* --- 筛选栏 --- */ +.filter-bar { + display: flex; gap: 10px; margin-bottom: 16px; + flex-wrap: wrap; +} +.filter-input { + flex: 1; min-width: 180px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} +.filter-input:focus { border-color: var(--primary); } +.filter-select { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + outline: none; + background: #fff; + cursor: pointer; +} +.filter-select:focus { border-color: var(--primary); } + +/* --- 按钮 --- */ +.btn { + padding: 8px 18px; border: none; border-radius: var(--radius); + font-size: 13px; cursor: pointer; transition: all 0.2s; + font-weight: 500; +} +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-hover); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { background: var(--danger-hover); } +.btn-sm { padding: 5px 12px; font-size: 12px; } +.btn-outline { + background: transparent; border: 1px solid var(--border); + color: var(--text); +} +.btn-outline:hover { border-color: var(--primary); color: var(--primary); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* --- 表格容器 --- */ +.table-container { + background: var(--bg-card); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow-x: auto; +} + +/* --- 数据表格 --- */ +.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.data-table th { + text-align: left; padding: 12px 16px; + background: #fafafa; color: var(--text-muted); + font-weight: 600; font-size: 12px; text-transform: uppercase; + letter-spacing: 0.5px; border-bottom: 2px solid var(--border); + white-space: nowrap; +} +.data-table td { + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; + vertical-align: middle; +} +.data-table tbody tr:hover { background: #f8f9ff; } +.table-loading td { text-align: center; padding: 40px; color: var(--text-muted); } + +/* --- 状态标签 --- */ +.badge { + display: inline-block; padding: 2px 8px; + border-radius: 12px; font-size: 11px; font-weight: 600; +} +.badge-active { background: #e8f5e9; color: var(--success); } +.badge-banned { background: #ffebee; color: var(--danger); } +.badge-deleted { background: #fff3e0; color: var(--warning); } +.badge-locked { background: #eceff1; color: #546e7a; } + +/* --- 角色标签 --- */ +.role-tag { + display: inline-block; padding: 2px 8px; + border-radius: 4px; font-size: 11px; font-weight: 600; +} +.role-user { background: #e3f2fd; color: #1976d2; } +.role-moderator { background: #e8f5e9; color: #2e7d32; } +.role-admin { background: #fce4ec; color: #c62828; } +.role-owner { background: #fff3e0; color: #e65100; } + +/* --- 操作按钮组 --- */ +.action-group { display: flex; gap: 6px; } + +/* --- 分页 --- */ +.pagination-bar { + display: flex; align-items: center; justify-content: center; + gap: 8px; margin-top: 20px; +} +.pagination-bar button { + padding: 6px 14px; border: 1px solid var(--border); + background: #fff; border-radius: 4px; font-size: 13px; + cursor: pointer; transition: all 0.15s; +} +.pagination-bar button:hover:not(:disabled) { + border-color: var(--primary); color: var(--primary); +} +.pagination-bar button:disabled { opacity: 0.4; cursor: not-allowed; } +.pagination-bar .page-current { + padding: 6px 14px; font-size: 13px; color: var(--text-muted); +} + +/* --- 确认弹窗 --- */ +.modal-overlay { + display: none; position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.4); z-index: 200; + align-items: center; justify-content: center; +} +.modal-overlay.show { display: flex; } +.modal-box { + background: #fff; border-radius: 8px; + padding: 28px 32px; min-width: 340px; + box-shadow: 0 8px 30px rgba(0,0,0,0.15); +} +.modal-box h3 { margin-bottom: 12px; font-size: 17px; } +.modal-box p { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; } +.modal-actions { display: flex; gap: 10px; justify-content: flex-end; } + +/* --- Toast 通知 --- */ +.toast-container { + position: fixed; top: 64px; right: 20px; + z-index: 300; display: flex; flex-direction: column; gap: 8px; +} +.toast { + padding: 12px 20px; border-radius: 6px; + font-size: 13px; color: #fff; font-weight: 500; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + animation: toastIn 0.3s ease; + max-width: 360px; +} +.toast-success { background: var(--success); } +.toast-error { background: var(--danger); } +@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } } diff --git a/templates/admin/static/css/users.css b/templates/admin/static/css/users.css new file mode 100644 index 0000000..c1bca0a --- /dev/null +++ b/templates/admin/static/css/users.css @@ -0,0 +1,12 @@ +/* ===================================================== + MetaLab 管理面板 - 用户管理页样式 + 单一职责:仅用户管理页面特有样式 + ===================================================== */ + +.data-table .col-uid { width: 60px; } +.data-table .col-username { width: 120px; } +.data-table .col-email { min-width: 180px; } +.data-table .col-role { width: 90px; } +.data-table .col-status { width: 90px; } +.data-table .col-time { width: 140px; } +.data-table .col-actions { width: 160px; } diff --git a/templates/admin/static/js/common.js b/templates/admin/static/js/common.js new file mode 100644 index 0000000..246c654 --- /dev/null +++ b/templates/admin/static/js/common.js @@ -0,0 +1,122 @@ +/* ===================================================== + MetaLab 管理面板 - 通用 JS + 单一职责:AJAX 封装、Toast 通知、确认弹窗 + 不包含任何页面特有逻辑 + ===================================================== */ + +// --- AJAX 封装 --- +const api = { + async get(url) { + const res = await fetch(url, { + headers: { 'Accept': 'application/json' } + }); + return res.json(); + }, + async put(url, body) { + const res = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCSRFToken() + }, + body: JSON.stringify(body) + }); + return res.json(); + }, + async post(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCSRFToken() + }, + body: body ? JSON.stringify(body) : undefined + }); + return res.json(); + } +}; + +function getCSRFToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; +} + +// --- Toast 通知 --- +function showToast(message, type) { + const container = document.querySelector('.toast-container') || createToastContainer(); + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transition = 'opacity 0.3s'; + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +function createToastContainer() { + const el = document.createElement('div'); + el.className = 'toast-container'; + document.body.appendChild(el); + return el; +} + +// --- 确认弹窗 --- +function showConfirm(title, message) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay show'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + + overlay.querySelector('#modalCancel').onclick = () => { + overlay.remove(); + resolve(false); + }; + overlay.querySelector('#modalConfirm').onclick = () => { + overlay.remove(); + resolve(true); + }; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { overlay.remove(); resolve(false); } + }); + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// --- 时间格式化 --- +function formatTime(isoStr) { + if (!isoStr) return '—'; + const d = new Date(isoStr); + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0') + ' ' + + String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0'); +} + +// --- 角色标签 --- +function roleLabel(role) { + const map = { user: '普通用户', moderator: '版主', admin: '管理员', owner: '站长' }; + return `${map[role] || role}`; +} + +// --- 状态标签 --- +function statusLabel(status) { + const map = { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' }; + return `${map[status] || status}`; +} diff --git a/templates/admin/static/js/users.js b/templates/admin/static/js/users.js new file mode 100644 index 0000000..1c60e40 --- /dev/null +++ b/templates/admin/static/js/users.js @@ -0,0 +1,156 @@ +/* ===================================================== + MetaLab 管理面板 - 用户管理 JS + 单一职责:用户列表渲染、搜索筛选、封禁/解封、强制下线 + ===================================================== */ + +let currentPage = 1; +const pageSize = 20; + +document.addEventListener('DOMContentLoaded', () => { + loadUsers(); + + document.getElementById('searchBtn').addEventListener('click', () => { + currentPage = 1; + loadUsers(); + }); + document.getElementById('searchInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { currentPage = 1; loadUsers(); } + }); + document.getElementById('roleFilter').addEventListener('change', () => { + currentPage = 1; loadUsers(); + }); + document.getElementById('statusFilter').addEventListener('change', () => { + currentPage = 1; loadUsers(); + }); +}); + +async function loadUsers() { + const keyword = document.getElementById('searchInput').value.trim(); + const role = document.getElementById('roleFilter').value; + const status = document.getElementById('statusFilter').value; + + const params = new URLSearchParams({ + page: currentPage, + page_size: pageSize, + keyword, role, status + }); + + try { + const res = await api.get('/api/admin/users?' + params); + if (res.success) { + renderTable(res.data.users); + renderPagination(res.data.total, res.data.page); + } else { + showToast(res.message || '加载失败', 'error'); + } + } catch (e) { + showToast('网络错误', 'error'); + } +} + +function renderTable(users) { + const tbody = document.getElementById('userTableBody'); + if (!users || users.length === 0) { + tbody.innerHTML = '没有找到用户'; + return; + } + tbody.innerHTML = users.map(u => ` + + ${u.uid} + ${escapeHtml(u.username)} + ${escapeHtml(u.email)} + ${roleLabel(u.role)} + ${statusLabel(u.status)} + ${formatTime(u.created_at)} + +
    + ${renderActions(u)} +
    + + + `).join(''); +} + +function renderActions(u) { + // 不可操作自己 + if (u.uid === window.__currentUid) return '—'; + + let btns = ''; + const isOwner = window.__isOwner || false; + // 封禁/解封按钮 + if (u.status === 'active') { + btns += ``; + } else if (u.status === 'banned') { + btns += ``; + } + // 强制下线按钮 + if (u.status === 'active' || u.status === 'banned') { + btns += ``; + } + // 删除按钮(仅 owner 可见,且目标不是 locked/deleted) + if (isOwner && u.status !== 'locked' && u.status !== 'deleted') { + btns += ``; + } + // 解锁按钮(仅 owner 可见,状态为 locked) + if (isOwner && u.status === 'locked') { + btns += ``; + } + return btns || '—'; +} + +async function banUser(uid, username) { + const ok = await showConfirm('封禁用户', `确定要封禁用户「${username}」吗?封禁后该用户将无法登录。`); + if (!ok) return; + const res = await api.put(`/api/admin/users/${uid}/status`, { status: 'banned' }); + if (res.success) { showToast(res.message, 'success'); loadUsers(); } + else { showToast(res.message || '操作失败', 'error'); } +} + +async function unbanUser(uid, username) { + const ok = await showConfirm('解封用户', `确定要解封用户「${username}」吗?`); + if (!ok) return; + const res = await api.put(`/api/admin/users/${uid}/status`, { status: 'active' }); + if (res.success) { showToast(res.message, 'success'); loadUsers(); } + else { showToast(res.message || '操作失败', 'error'); } +} + +async function deleteUser(uid, username) { + const ok = await showConfirm('删除用户', `确定要永久删除用户「${username}」吗?此操作不可撤销,该账号将被立即锁定。`); + if (!ok) return; + const res = await api.put(`/api/admin/users/${uid}/status`, { status: 'locked' }); + if (res.success) { showToast(res.message, 'success'); loadUsers(); } + else { showToast(res.message || '操作失败', 'error'); } +} + +async function unlockUser(uid, username) { + const ok = await showConfirm('解锁用户', `确定要解锁用户「${username}」吗?该账号将恢复为正常状态。`); + if (!ok) return; + const res = await api.put(`/api/admin/users/${uid}/status`, { status: 'active' }); + if (res.success) { showToast(res.message, 'success'); loadUsers(); } + else { showToast(res.message || '操作失败', 'error'); } +} + +async function resetToken(uid, username) { + const ok = await showConfirm('强制下线', `确定要强制「${username}」下线吗?该用户需要重新登录。`); + if (!ok) return; + const res = await api.post(`/api/admin/users/${uid}/reset-token`); + if (res.success) { showToast(res.message, 'success'); } + else { showToast(res.message || '操作失败', 'error'); } +} + +function renderPagination(total, page) { + const totalPages = Math.ceil(total / pageSize); + const bar = document.getElementById('paginationBar'); + if (totalPages <= 1) { bar.innerHTML = ''; return; } + + let html = ``; + html += `第 ${page} / ${totalPages} 页(共 ${total} 条)`; + html += ``; + bar.innerHTML = html; +} + +function goPage(p) { + currentPage = p; + loadUsers(); + window.scrollTo({ top: 0 }); +}