初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可

This commit is contained in:
2026-05-26 13:46:33 +08:00
parent 1315df6501
commit 483fdd919f
56 changed files with 5804 additions and 40 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# 复制为 .env 后填入真实值
# .env 会覆盖 config.yaml 中的同名字段
DATABASE_PASSWORD=your_password_here
JWT_SECRET=generate-a-random-64-char-string-here

55
.gitignore vendored
View File

@ -1,52 +1,27 @@
# ---> Go # 环境变量(含密钥,绝不能提交)
# If you prefer the allow list template instead of the deny list, see community template: .env
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
# # 编译产物
# Binaries for programs and plugins /server
cmd/server/server
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
*.so *.so
*.dylib *.dylib
# Test binary, built with `go test -c`
*.test *.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
# Dependency directories (remove the comment below to include it) # Go workspace
# vendor/
# Go workspace file
go.work go.work
go.work.sum go.work.sum
# env file # 运行时数据
.env storage/uploads/
storage/logs/
# ---> 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
!*/
# IDE
.idea/
.vscode/
*.swp
*.swo

68
cmd/server/main.go Normal file
View File

@ -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)
}

23
config.yaml Normal file
View File

@ -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

111
docs/code-style.md Normal file
View File

@ -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 { ... }
```

531
docs/development.md Normal file
View File

@ -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/ConfirmPasswordbinding 标签)
→ Service.Register()
→ validatePassword()≥8位 + 含字母 + 含数字
→ userRepo.FindByEmail():邮箱查重
→ bcrypt.GenerateFromPassword(,12):密码哈希
→ generateUsername():生成 10 位随机用户名,重试 20 次去重
→ userRepo.Create():写入数据库
→ buildToken():生成 access JWTHS25615 分钟)
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWTHS25630 天)
→ SetAuthCookies(accessToken, refreshToken) → 设置 Cookie
→ 返回 JSON { success, message, data: user }
```
**关键点**注册即登录Cookie 自动设置前端无需额外操作勾选"记住我"则下发 refresh token + 标记 Cookie未勾选则不下发
**涉及文件**
- `internal/controller/auth_controller.go` `Register()` POST handler
- `internal/service/auth_service.go` `Register()``validatePassword()``generateUsername()``buildToken()``buildRefreshToken()`
- `internal/repository/user_repo.go` `Create()``FindByEmail()`
- `internal/model/dto.go` `RegisterRequest`binding 校验标签 + `remember_me`
---
### 5. 用户登录
**流程**
```
POST /api/auth/login
→ 验证 Email/Password
→ Service.Login()
→ userRepo.FindByEmail():查找用户
→ 检查 Status != banned封禁用户拒绝登录
→ bcrypt.CompareHashAndPassword():密码校验
→ buildToken():生成 access JWT
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT
→ SetAuthCookies(accessToken, refreshToken)
→ 返回 JSON { success, message, data: user }
```
**错误处理**根据错误类型返回不同 HTTP 状态码和消息
- 401邮箱不存在 / 密码错误
- 403用户已封禁
- 500服务器内部错误
**涉及文件**`auth_controller.go``Login()`)、`auth_service.go``Login()``ErrUserBanned``buildRefreshToken()`
---
### 6. JWT 认证中间件
| 中间件 | 用途 | 注入上下文的值 |
|---|---|---|
| `AuthRequired` | 严格认证失败返回 401 | `uid``email``username``role` |
| `AuthOptional` | 可选认证 token 就注入没有也放行 | 同上 token 时不注入 |
**Cookie 配置**
| Cookie | 名称 | HttpOnly | Secure | SameSite | 过期 |
|---|---|---|---|---|---|
| Access token | `mlb_token` | | ✅(debug 模式 HTTP | Lax | 15 min |
| Refresh token | `mlb_refresh` | | | Lax | 30 day记住我时 |
| 记住我标记 | `mlb_rm` | | | | 30 day记住我时 |
**中间件公开函数**
| 函数 | 用途 |
|---|---|
| `SetAuthCookies(c, accessToken, refreshToken, cfg)` | 设置 access + refresh+ mlb_rm 标记 |
| `SetAccessCookie(c, accessToken, cfg)` | 仅刷新 access cookie续期调用 |
| `ClearAuthCookies(c, cfg)` | 清除全部三个 Cookie登出/过期 |
**涉及文件**`internal/middleware/auth.go`
---
### 7. 登录状态注入SSR 模板渲染)
`internal/common/helper.go` 中的 `BuildPageData()`
```go
func BuildPageData(c *gin.Context, extra gin.H) gin.H
```
- `extra` 键值对复制到 `gin.H`
- Gin 上下文中存在 `username` `AuthOptional` 中间件注入自动添加
- `IsLoggedIn: true`
- `Username: "..."`
- 所有页面 Controller 通过此函数构建模板数据
---
### 8. 页面路由与模板
| 路由 | 方法 | 中间件 | 模板 | 说明 |
|---|---|---|---|---|
| `/` | GET | `AuthOptional` | `home/index.html` | 已登录显示首页未登录重定向 `/auth/login` |
| `/auth/register` | GET | `AuthOptional` | `auth/register.html` | 已登录重定向 `/` |
| `/auth/login` | GET | `AuthOptional` | `auth/login.html` | 已登录重定向 `/` |
---
### 9. NAV 导航栏逻辑
```html
{{if .IsLoggedIn}}
→ 显示用户名链接(指向 /settings+ "退出"按钮
{{else}}
→ 显示"登录"按钮(指向 /auth/login
{{end}}
```
**设计决策**NAV 只显示"登录"按钮不显示独立"注册"按钮注册入口在登录页面内部提供作为开发者社区用户应能理解)。已登录用户可通过"退出"按钮清除所有 Cookie 并返回首页
---
### 10. 模板加载器重构
**问题**原加载器使用 `info.Name()`纯文件名导致 `home/index.html` 注册为 `index.html`与路由引用的 `home_index.html` 不匹配模板渲染失败
**解决**改为使用相对路径作为模板名
```
layout/header.html
layout/nav.html
layout/footer.html
auth/register.html
auth/login.html
home/index.html
```
`{{template}}` 引用全部更新为这些相对路径名
**涉及文件**`internal/theme/loader.go`所有 `.html` 模板文件中的 `{{template}}` 引用
---
### 11. "记住我" + 刷新令牌Refresh Token
#### 11.1 设计目标
| 场景 | 行为 |
|---|---|
| 用户勾选"记住我" | Cookie`mlb_token`(15min) + `mlb_refresh`(30d) |
| 用户未勾选 | Cookie`mlb_token`(15min)过期需重新登录 |
| 页面持续开着 | 前端每 10 分钟静默调用 `/api/auth/refresh` 续期 access token |
| 关闭浏览器 15+ min 后回来 | access token 过期 JS 自动刷新 页面 reload 恢复登录态 |
| 30 天内任意时间回来 | refresh token 仍有效自动静默恢复登录 |
#### 11.2 Cookie 架构
| Cookie | 作用 | HttpOnly | JS 可读 | 过期时间 | 说明 |
|---|---|---|---|---|---|
| `mlb_token` | access token | | | 15 min | JWT `uid/email/username/role/exp/iat` |
| `mlb_refresh` | refresh token | | | 30 day勾选记住我时 | JWT `uid/email/username/role/exp/iat/purpose:"refresh"` |
| `mlb_rm` | 记住我标记 | | | 30 day勾选记住我时 | 值为 `"1"`仅标记位非凭据 |
**设计决策 — `mlb_rm` 标记 Cookie**
- 本质是**非安全标记位**not a credential用于 JS 判断"是否该尝试刷新"
- 如果不存在未勾选记住我`common.js` 跳过所有刷新逻辑 **不会产生 401 控制台报错**
- 值仅为 `"1"`即使被 XSS 读取也无法用于任何认证操作
#### 11.3 配置 (`config.yaml` + `config.go`)
```yaml
jwt:
access_expire: 15 # 分钟
refresh_expire: 168 # 小时 (7 天) — 备用字段,当前未启用
remember_expire: 720 # 小时 (30 天) — refresh token 有效期
```
`JWTConfig` 新增字段
```go
RememberExpire int `mapstructure:"remember_expire"` // 小时
```
**关键**`mapstructure` 标签**不可省略**否则 Viper 无法将 YAML 键映射到结构体字段导致过期时间全为 0
#### 11.4 请求 DTO (`dto.go`)
`LoginRequest` `RegisterRequest` 都新增了 `RememberMe` 字段
```go
RememberMe bool `json:"remember_me"`
```
#### 11.5 后端业务逻辑 (`auth_service.go`)
**变更的方法:**
| 方法 | 旧返回值 | 新返回值 |
|---|---|---|
| `Register()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
| `Login()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
`req.RememberMe == false` `refreshToken` 返回空字符串 `""`
**新增方法:**
| 方法 | 签名 | 说明 |
|---|---|---|
| `buildRefreshToken(user)` | `(string, error)` | 构建 30 天过期 JWT`purpose` claim `"refresh"` |
| `RefreshAccessToken(refreshTokenStr)` | `(string, error)` | 校验 refresh token 返回新 access token |
**`RefreshAccessToken` 校验流程**
```
1. 解析 refresh token JWT
→ 失败ErrTokenExpired登录已过期
2. 校验 claims["purpose"] == "refresh"
→ 不匹配ErrTokenInvalid防止 access token 被用于刷新)
3. 从 claims 提取 uid/email/username/role → 构建新 access token
```
**安全点**`purpose: "refresh"` 声明确保**access token 无法充当 refresh token**防止短生命周期令牌被滥用为长期凭据
#### 11.6 Session 恢复实现(前端 JS
`common.js` 中的自动刷新逻辑
```
页面加载(非 /auth/* 路径)
→ 检查 document.cookie 是否存在 mlb_rm
→ 不存在 → 跳过所有刷新(无 401 报错)
→ 存在 → fetch POST /api/auth/refresh
→ 成功:
1. 启动 10 分钟定时器
2. 页面未显示登录态(无 logoutBtn
→ sessionStorage 防重 → window.location.reload()
重载后 access token 存在 → SSR 正确渲染登录状态
→ 失败 → 停止定时器(用户需手动重新登录)
```
**关键实现细节**
- `fetch()` 而非 `XMLHttpRequest``fetch` HTTP 2xx 不会触发浏览器控制台静默日志
- `sessionStorage` 防无限循环`mlb_refreshed` 标志确保 reload 只执行一次
- 不在认证页面执行`/auth/login``/auth/register` 路径直接跳过
#### 11.7 界面
登录页和注册页各增加一个复选框
```html
<div class="form-group remember-group">
<label class="remember-label">
<input type="checkbox" id="rememberMe">
保持登录 30 天
</label>
<span class="remember-hint">非共享设备推荐</span>
</div>
```
**默认不勾选**——公共环境教室网吧共享电脑不会意外留下长期凭据
---
### 12. 登出功能
#### 12.1 后端 API
**路由**`POST /api/auth/logout`
**处理流程**`auth_controller.go` `Logout()`
```
1. middleware.ClearAuthCookies(c, cfg)
→ 清除 mlb_token、mlb_refresh、mlb_rm 三个 CookieMaxAge=-1
2. 返回 JSON { success: true, message: "已退出登录" }
```
#### 12.2 前端
**NAV 导航栏**`nav.html`
```html
{{if .IsLoggedIn}}
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{end}}
```
**JS 处理**`common.js`
```
点击"退出" → POST /api/auth/logout → Cookie 清除完毕 → 跳转首页
```
---
### 13. 刷新令牌 API
**路由**`POST /api/auth/refresh`
**处理流程**`auth_controller.go` `RefreshToken()`
```
1. 从 Cookie 读取 mlb_refresh
→ 为空401 "请重新登录"
2. 调用 authService.RefreshAccessToken(refreshToken)
→ 失败ClearAuthCookies() + 401 "登录已过期"
3. 成功SetAccessCookie() → 下发新 access token
→ 返回 200 { success: true }
```
**注意**此端点**只刷新 access token**不刷新 refresh tokenrefresh 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 # BaseModeluid 主键 + 时间戳 + 软删除)
│ ├── user.go # User 结构体 + Role/Status 常量
│ └── dto.go # RegisterRequest / LoginRequest 含 RememberMe
├── repository/
│ └── user_repo.go # UserRepoCreate/FindByEmail/FindByID/FindByUsername/ExistsByEmail/ExistsByUsername
├── service/
│ └── auth_service.go # AuthServiceLogin/Register/RefreshAccessToken + buildToken/buildRefreshToken + 8 个错误哨兵)
├── controller/
│ └── auth_controller.go # AuthControllerRegisterPage/LoginPage/Register/Login/Logout/RefreshToken/CheckEmail
├── middleware/
│ └── auth.go # AuthRequired/AuthOptional/SetAuthCookies/SetAccessCookie/ClearAuthCookies/parseToken
│ # 三个 Cookiemlb_token / mlb_refresh / mlb_rm
├── router/
│ └── router.go # Setup() — 路由注册 + 依赖注入链(含 /auth/logout、/auth/refresh
├── common/
│ └── helper.go # BuildPageData() — SSR 登录状态注入
└── theme/
└── loader.go # LoadTemplates() — 相对路径模板加载 + LoadContent()
```
### 前端(模板 + 静态资源)
```
templates/MetaLab-2026/
├── html/
│ ├── layout/
│ │ ├── header.html # HTML headtitle/meta/CSS 引入)
│ │ ├── nav.html # 导航栏(已登录显示用户名+退出按钮 / 未登录显示登录按钮)
│ │ └── footer.html # 页脚
│ ├── auth/
│ │ ├── register.html # 注册页(含准则模态框 + 60s 倒计时 + 记住我复选框)
│ │ └── login.html # 登录页(简洁表单 + 记住我复选框)
│ └── home/
│ └── index.html # 首页Hero 区域 + 欢迎信息)
├── static/
│ ├── css/
│ │ ├── common.css # 全局样式 + NAV 样式(含 .nav-logout
│ │ └── auth.css # 认证页样式表单、模态框、Toast、.remember-group/.remember-label/.remember-hint
│ └── js/
│ ├── common.js # 通用工具Toast + 登出 + 自动令牌刷新 + mlb_rm 检测)
│ ├── register.js # 注册页逻辑(表单验证/AJAX 提交/准则模态框/倒计时/记住我)
│ └── login.js # 登录页逻辑(表单验证/AJAX 提交/Toast/记住我)
```
### 配置文件
```
config.yaml # 主配置(服务/数据库/JWT 含 remember_expire/BCrypt
.env # 敏感信息(密码/JWT密钥→ .gitignore
.env.example # 敏感信息模板(无真实值)
```
---
## 五、待做事项
按优先级排序
| 优先级 | 功能 | 状态 | 说明 |
|---|---|---|---|
| **P0** | 邮箱验证码 | 待做 | 注册时发送验证码`auth/verify-email`预留了路由和接口 |
| ~~**P0**~~ | ~~登出功能~~ | 已完成 | `POST /api/auth/logout` 清除三个 Cookie + 跳转首页 |
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域需添加帖子列表话题导航等 |
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
| **P1** | 用户设置页 | 待做 | `/settings` 修改用户名/头像/密码/个人简介 |
| **P2** | 管理后台 | 待做 | `/admin` 用户管理内容审核主题切换 |
| ~~**P2**~~ | ~~刷新令牌~~ | 已完成 | 记住我 + access/refresh Token + 自动续期 + 登出 |
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` `MetaLab-2027` 切换 |
---
## 六、约定速查
| 类别 | 约定 |
|---|---|
| 数据库主键 | Go: `ID uint`DB: `uid`bigint 自增JSON: `"uid"` |
| Access Token Cookie | `mlb_token`JWT(HS256)15 minHttpOnly + Secure + SameSite=Lax |
| Refresh Token Cookie | `mlb_refresh`JWT(HS256)30 day记住我时HttpOnly + Secure + SameSite=Lax `purpose:"refresh"` 声明 |
| 记住我标记 Cookie | `mlb_rm` `"1"`30 day记住我时 HttpOnlyJS 可读非凭据 |
| 密码哈希 | 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 静默调用 |

119
docs/routing.md Normal file
View File

@ -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))
```

128
docs/structure.md Normal file
View File

@ -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` 放 BaseModelID/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/` 目录

59
go.mod Normal file
View File

@ -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
)

140
go.sum Normal file
View File

@ -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=

62
internal/common/cookie.go Normal file
View File

@ -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 Cookierefresh 续期调用)
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 清除所有认证 Cookielogout 调用)
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 CookieJS 可读)
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
c.SetCookie(name, value, maxAge, "/", "", secure, false)
}

20
internal/common/crypto.go Normal file
View File

@ -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
}

22
internal/common/errors.go Normal file
View File

@ -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("权限不足")
)

44
internal/common/helper.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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})
}

View File

@ -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
}

119
internal/config/config.go Normal file
View File

@ -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
}

View File

@ -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, "操作失败")
}
}

View File

@ -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,
})
}

View File

@ -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 管理后台页面认证中间件
// 逻辑同 AuthRequiredJWT 校验 + 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()
}
}

146
internal/middleware/auth.go Normal file
View File

@ -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/")
}

112
internal/middleware/csrf.go Normal file
View File

@ -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
}
// 注入到上下文,供前端 <meta> 使用
c.Set(csrfMetaName, cookieToken)
c.Next()
}
}
// SetCSRFToken 在首次页面访问时下发 CSRF Cookie由页面路由中间件调用
// 注意:此 Cookie HttpOnly=falseJS 可读——这是 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
}

View File

@ -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()
}
}

View File

@ -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()
}
}

15
internal/model/common.go Normal file
View File

@ -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:"-"`
}

28
internal/model/dto.go Normal file
View File

@ -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"`
}

45
internal/model/user.go Normal file
View File

@ -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]
}

View File

@ -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
}

104
internal/router/router.go Normal file
View File

@ -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))
}
// --- 管理后台 APIJSON 响应) ---
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)
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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_expire30天false → 使用 refresh_expire7天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
}

53
internal/theme/loader.go Normal file
View File

@ -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
}

View File

@ -0,0 +1,29 @@
<p><b>致开发者:</b></p>
<p>欢迎来到 MetaLab一个为构建者准备的纯粹空间。为维护高质量的技术讨论环境请您在参与前阅读本准则。我们的核心非常简单<b>平等交流、专注技术、开放分享</b>。任何破坏这些基础的行为都将被处理。</p>
<h4>第一章 社区共识</h4>
<p>我们希望通过遵循以下共识,共同塑造理想的开发者家园:</p>
<p><b>平等:</b>这里没有特权,每个成员在发言和受尊重上权利平等。</p>
<p><b>纯粹:</b>讨论应围绕技术本身——代码、架构、逻辑与可验证的事实。</p>
<p><b>开放:</b>鼓励开源、协作与知识共享,相信开放是进步的动力。</p>
<h4>第二章 基本行为规范</h4>
<p><b>【核心要求】</b></p>
<p><b>保持尊重,专注技术:</b>可以就技术观点进行激烈辩论,但<span class="red">禁止任何形式的人身攻击、嘲讽、歧视、骚扰及引战行为</span>。讨论应聚焦于软件开发、系统架构、技术原理等实质性内容。</p>
<p><span class="red">禁止广告与推广:</span>严禁任何形式的广告、引流、软文、诱导私信、招募、付费服务推广等。分享个人或团队的非商业开源项目不在此限。</p>
<p><b>保证内容质量与相关性:</b>请发布对社区有建设性的内容。禁止发布与开发者社区主题无关、无意义的灌水、测试性内容,或令人不适的信息。</p>
<p><b>【具体细则】</b></p>
<p><b>善于提问与分享:</b>提问前请先尝试搜索;提问时,请清晰描述问题、已尝试的方案及错误信息。鼓励分享高质量、有深度的原创内容或经过授权的转载(需明确标注来源)。</p>
<p><span class="red">遵守法律法规:</span>严禁发布任何违反法律法规的内容,包括但不限于恶意代码、侵权盗版信息、违禁品交易信息等。</p>
<p><b>鼓励理性讨论:</b>表达不同意见时,应提供基于技术事实或逻辑的依据。</p>
<h4>第三章 内容发布与授权</h4>
<p><b>内容归属:</b>您发布的原创内容,著作权归您本人所有。发布非原创内容(如翻译、转载)时,必须明确标注原作者及来源,并确保您的发布行为符合原内容的授权协议。</p>
<p><span class="highlight">默认授权(重要):</span></p>
<p><span class="highlight">为践行开源共享原则,您在本社区公开发布的原创内容,即视为默认同意按照知识共享 署名-相同方式共享 4.0 国际协议 (CC BY-SA 4.0) 进行授权。这意味着他人可以在标明您署名并以相同方式共享的前提下,使用、修改和再发布您的内容。</span></p>
<p><b>质量倡议:</b>我们强烈鼓励经过思考、调研的高质量内容。请避免发布过于随意、未经基本搜索或与社区主题完全无关的内容。</p>
<h4>第四章 社区治理</h4>
<p><b>治理原则:</b>社区管理以本准则为依据,力求公开、公正。</p>
<p><span class="red">处理措施:</span>对于违规行为,社区运营方可能采取 <b>内容处置</b>(修改、屏蔽、删除)或 <b>账号处置</b>(警告、临时或永久封禁)。具体尺度由运营方根据违规性质、情节及影响判断。</p>
<p><b>申诉流程:</b>如您认为处理有误,有权在决定作出后的 7 个工作日内通过邮件metazone@foxmail.com向元域管理组提交申诉邮件主题请注明"账号申诉",正文需附上详细理由及相关证据。</p>
<h4>第五章 其他</h4>
<p><b>免责声明:</b>您应对在社区内的言行独立负责。用户发布的内容仅代表其个人观点,社区运营方无法实时审查,在法律允许的最大范围内不承担责任。</p>
<p><b>准则修改:</b>我们可能根据需要对准则进行修订。修订内容将在社区内显著位置公告。若您在新规生效后继续使用社区,即视为同意接受更新后的准则约束。</p>
<p><b>遵守法律:</b>您使用社区服务的行为必须遵守您所在地的法律法规;此外,本社区服务器位于中华人民共和国香港特别行政区,您还需遵守当地相关法律。</p>

View File

@ -0,0 +1,48 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<main class="auth-page">
<div class="auth-card">
<h2>登录 MetaLab</h2>
<p class="auth-subtitle">回到开发者社区</p>
<form id="loginForm" novalidate>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="email">邮箱地址</label>
<input type="email" id="email" placeholder="your@email.com" required>
<span class="form-error" id="emailError">请输入有效的邮箱地址</span>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" placeholder="输入密码" required>
<span class="form-error" id="passwordError">请输入密码</span>
</div>
<div class="form-group remember-group">
<label class="remember-label">
<input type="checkbox" id="rememberMe">
保持登录 30 天
</label>
<span class="remember-hint">非共享设备推荐</span>
</div>
<button type="submit" class="submit-btn" id="submitBtn">登 录</button>
</form>
<div class="auth-footer">
还没有账号?<a href="/auth/register">立即注册</a>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
{{template "layout/footer.html" .}}
<button class="scroll-top-btn" id="scrollTopBtn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
<script src="/static/js/common.js"></script>
<script src="/static/js/login.js"></script>
</body>
</html>

View File

@ -0,0 +1,80 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<main class="auth-page">
<div class="auth-card">
<h2>创建账号</h2>
<p class="auth-subtitle">加入 MetaLab 开发者社区</p>
<form id="registerForm" novalidate>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="email">邮箱地址</label>
<input type="email" id="email" placeholder="your@email.com" required>
<span class="form-error" id="emailError">请输入有效的邮箱地址</span>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" placeholder="至少 8 位,包含字母与数字" required minlength="8">
<span class="form-error" id="passwordError">密码至少 8 位,需包含字母与数字</span>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input type="password" id="confirmPassword" placeholder="请再次输入密码" required>
<span class="form-error" id="confirmPasswordError">两次输入的密码不一致</span>
</div>
<div class="form-group remember-group">
<label class="remember-label">
<input type="checkbox" id="rememberMe">
保持登录 30 天
</label>
<span class="remember-hint">非共享设备推荐</span>
</div>
<p class="guidelines-link">点击注册即表示同意 <a href="javascript:void(0)" id="openGuidelines">《MetaLab 社区用户准则》</a></p>
<button type="submit" class="submit-btn" id="submitBtn">注 册</button>
</form>
<div class="auth-footer">
已有账号?<a href="/auth/login">立即登录</a>
</div>
</div>
</main>
<!-- 社区准则模态框 -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<h3>MetaLab 社区用户准则</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body" id="modalBody">
{{.Guidelines}}
</div>
<div class="modal-footer">
<div id="modalEnforce">
<div class="timer" id="timer">
<span id="timerLabel">剩余阅读时间</span>
<span class="count" id="timerCount">60</span>
<span id="timerUnit"></span>
<span id="timerDone" style="display:none">✓ 阅读时间已满足</span>
</div>
<span class="scroll-hint" id="scrollHint">▼ 请滚动至底部完成阅读</span>
</div>
<button class="confirm-btn" id="confirmBtn" disabled>我已完整阅读并同意</button>
<button class="confirm-btn" id="previewCloseBtn" style="display:none">关 闭</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
{{template "layout/footer.html" .}}
<button class="scroll-top-btn" id="scrollTopBtn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
<script src="/static/js/common.js"></script>
<script src="/static/js/register.js"></script>
</body>
</html>

View File

@ -0,0 +1,22 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<main class="hero">
<div class="container">
<h1>Meta<span>Lab</span></h1>
<p class="hero-tagline">下一代开发者社区</p>
{{if .IsLoggedIn}}
<p class="hero-desc">欢迎回来,{{.Username}}</p>
{{else}}
<p class="hero-desc">一个为构建者准备的纯粹空间</p>
{{end}}
</div>
</main>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script>
</body>
</html>

View File

@ -0,0 +1,5 @@
<footer>
<div class="container footer-content">
<p class="copyright">&copy; 2024-2026 MetaLab | Framework: MetaZone</p>
</div>
</footer>

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
<title>{{.Title}} - MetaLab</title>
<link rel="stylesheet" href="/static/css/common.css">
{{if .ExtraCSS}}
<link rel="stylesheet" href="{{.ExtraCSS}}">
{{end}}
</head>

View File

@ -0,0 +1,17 @@
<header>
<div class="container nav-container">
<a href="/" class="logo">Meta<span>Lab</span></a>
<button class="mobile-menu-btn" id="mobileMenuBtn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<ul class="nav-links" id="navLinks">
<li><a href="/">首页</a></li>
{{if .IsLoggedIn}}
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{else}}
<li><a href="/auth/login" class="nav-login">登录</a></li>
{{end}}
</ul>
</div>
</header>

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 <meta> 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 = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
var closeIcon = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
// ---- 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)
})();

View File

@ -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 }));
}
})();

View File

@ -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
}));
}
})();

View File

@ -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"
}

View File

@ -0,0 +1,30 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>欢迎,{{.Username}}</h1>
<p class="page-desc">MetaLab 管理控制面板</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon stat-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg></div>
<div class="stat-info"><div class="stat-label">用户总数</div><div class="stat-value"></div></div>
</div>
<div class="stat-card">
<div class="stat-icon stat-green"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="9" y1="13" x2="15" y2="13"/></svg></div>
<div class="stat-info"><div class="stat-label">帖子总数</div><div class="stat-value"></div></div>
</div>
<div class="stat-card">
<div class="stat-icon stat-orange"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>
<div class="stat-info"><div class="stat-label">当前角色</div><div class="stat-value">{{.Role}}</div></div>
</div>
</div>
<div class="info-card">
<h3>快速开始</h3>
<ul>
<li>在左侧导航选择管理模块</li>
<li>使用「用户管理」查看、搜索和封禁用户</li>
<li>使用「返回前台」链接回到主站</li>
</ul>
</div>
{{template "admin/layout/footer.html" .}}

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
<title>{{.Title}} - MetaLab CP</title>
<link rel="stylesheet" href="/admin/static/css/common.css">
{{if .ExtraCSS}}
<link rel="stylesheet" href="{{.ExtraCSS}}">
{{end}}
</head>
<body>
<header class="admin-header">
<div class="admin-header-inner">
<a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
<div class="admin-header-right">
<span class="admin-user">{{.Username}}</span>
<a href="/" class="admin-back-btn">← 返回前台</a>
</div>
</div>
</header>
<div class="admin-container">
<aside class="admin-sidebar">
<nav>
<a href="/admin" class="sidebar-item {{if eq .CurrentPath "/admin"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></span>
管理首页
</a>
{{if .CanManageUsers}}
<a href="/admin/users" class="sidebar-item {{if eq .CurrentPath "/admin/users"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg></span>
用户管理
</a>
{{end}}
</nav>
</aside>
<main class="admin-main">

View File

@ -0,0 +1,8 @@
</main>
</div>
<script src="/admin/static/js/common.js"></script>
{{if .ExtraJS}}
<script src="{{.ExtraJS}}"></script>
{{end}}
</body>
</html>

View File

@ -0,0 +1,50 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>用户管理</h1>
<p class="page-desc">搜索、查看和管理本站用户</p>
</div>
<!-- 搜索与筛选 -->
<div class="filter-bar">
<input type="text" id="searchInput" class="filter-input" placeholder="搜索邮箱或用户名..." autocomplete="off">
<select id="roleFilter" class="filter-select">
<option value="">全部角色</option>
<option value="user">普通用户</option>
<option value="moderator">版主</option>
<option value="admin">管理员</option>
<option value="owner">站长</option>
</select>
<select id="statusFilter" class="filter-select">
<option value="">全部状态</option>
<option value="active">正常</option>
<option value="banned">已封禁</option>
<option value="deleted">已注销</option>
<option value="locked">已锁定</option>
</select>
<button id="searchBtn" class="btn btn-primary">搜索</button>
</div>
<!-- 用户表格 -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>UID</th>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>状态</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="userTableBody">
<tr class="table-loading"><td colspan="7">加载中...</td></tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination-bar" id="paginationBar"></div>
<script>window.__currentUid = {{.UID}}; window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};</script>
{{template "admin/layout/footer.html" .}}

View File

@ -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); } }

View File

@ -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; }

View File

@ -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 = `
<div class="modal-box">
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(message)}</p>
<div class="modal-actions">
<button class="btn btn-outline" id="modalCancel">取消</button>
<button class="btn btn-danger" id="modalConfirm">确认</button>
</div>
</div>`;
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 `<span class="role-tag role-${role}">${map[role] || role}</span>`;
}
// --- 状态标签 ---
function statusLabel(status) {
const map = { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' };
return `<span class="badge badge-${status}">${map[status] || status}</span>`;
}

View File

@ -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 = '<tr><td colspan="7" style="text-align:center;padding:40px;color:#636e72;">没有找到用户</td></tr>';
return;
}
tbody.innerHTML = users.map(u => `
<tr>
<td class="col-uid">${u.uid}</td>
<td class="col-username">${escapeHtml(u.username)}</td>
<td class="col-email">${escapeHtml(u.email)}</td>
<td class="col-role">${roleLabel(u.role)}</td>
<td class="col-status">${statusLabel(u.status)}</td>
<td class="col-time">${formatTime(u.created_at)}</td>
<td class="col-actions">
<div class="action-group">
${renderActions(u)}
</div>
</td>
</tr>
`).join('');
}
function renderActions(u) {
// 不可操作自己
if (u.uid === window.__currentUid) return '—';
let btns = '';
const isOwner = window.__isOwner || false;
// 封禁/解封按钮
if (u.status === 'active') {
btns += `<button class="btn btn-danger btn-sm" onclick="banUser(${u.uid}, '${escapeHtml(u.username)}')">封禁</button>`;
} else if (u.status === 'banned') {
btns += `<button class="btn btn-outline btn-sm" onclick="unbanUser(${u.uid}, '${escapeHtml(u.username)}')">解封</button>`;
}
// 强制下线按钮
if (u.status === 'active' || u.status === 'banned') {
btns += `<button class="btn btn-outline btn-sm" onclick="resetToken(${u.uid}, '${escapeHtml(u.username)}')">踢下线</button>`;
}
// 删除按钮(仅 owner 可见,且目标不是 locked/deleted
if (isOwner && u.status !== 'locked' && u.status !== 'deleted') {
btns += `<button class="btn btn-danger btn-sm" onclick="deleteUser(${u.uid}, '${escapeHtml(u.username)}')">删除</button>`;
}
// 解锁按钮(仅 owner 可见,状态为 locked
if (isOwner && u.status === 'locked') {
btns += `<button class="btn btn-outline btn-sm" onclick="unlockUser(${u.uid}, '${escapeHtml(u.username)}')">解锁</button>`;
}
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 = `<button onclick="goPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>`;
html += `<span class="page-current">第 ${page} / ${totalPages} 页(共 ${total} 条)</span>`;
html += `<button onclick="goPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>`;
bar.innerHTML = html;
}
function goPage(p) {
currentPage = p;
loadUsers();
window.scrollTo({ top: 0 });
}