初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal 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
55
.gitignore
vendored
@ -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
68
cmd/server/main.go
Normal 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
23
config.yaml
Normal 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
111
docs/code-style.md
Normal 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
531
docs/development.md
Normal 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/ConfirmPassword(binding 标签)
|
||||||
|
→ Service.Register()
|
||||||
|
→ validatePassword():≥8位 + 含字母 + 含数字
|
||||||
|
→ userRepo.FindByEmail():邮箱查重
|
||||||
|
→ bcrypt.GenerateFromPassword(,12):密码哈希
|
||||||
|
→ generateUsername():生成 10 位随机用户名,重试 20 次去重
|
||||||
|
→ userRepo.Create():写入数据库
|
||||||
|
→ buildToken():生成 access JWT(HS256,15 分钟)
|
||||||
|
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT(HS256,30 天)
|
||||||
|
→ SetAuthCookies(accessToken, refreshToken) → 设置 Cookie
|
||||||
|
→ 返回 JSON { success, message, data: user }
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:注册即登录,Cookie 自动设置,前端无需额外操作。勾选"记住我"则下发 refresh token + 标记 Cookie,未勾选则不下发。
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/controller/auth_controller.go` — `Register()` POST handler
|
||||||
|
- `internal/service/auth_service.go` — `Register()`、`validatePassword()`、`generateUsername()`、`buildToken()`、`buildRefreshToken()`
|
||||||
|
- `internal/repository/user_repo.go` — `Create()`、`FindByEmail()`
|
||||||
|
- `internal/model/dto.go` — `RegisterRequest`(binding 校验标签 + `remember_me`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 用户登录
|
||||||
|
|
||||||
|
**流程**:
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
→ 验证 Email/Password
|
||||||
|
→ Service.Login()
|
||||||
|
→ userRepo.FindByEmail():查找用户
|
||||||
|
→ 检查 Status != banned(封禁用户拒绝登录)
|
||||||
|
→ bcrypt.CompareHashAndPassword():密码校验
|
||||||
|
→ buildToken():生成 access JWT
|
||||||
|
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT
|
||||||
|
→ SetAuthCookies(accessToken, refreshToken)
|
||||||
|
→ 返回 JSON { success, message, data: user }
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误处理**:根据错误类型返回不同 HTTP 状态码和消息:
|
||||||
|
- 401:邮箱不存在 / 密码错误
|
||||||
|
- 403:用户已封禁
|
||||||
|
- 500:服务器内部错误
|
||||||
|
|
||||||
|
**涉及文件**:`auth_controller.go`(`Login()`)、`auth_service.go`(`Login()`、`ErrUserBanned`、`buildRefreshToken()`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. JWT 认证中间件
|
||||||
|
|
||||||
|
| 中间件 | 用途 | 注入上下文的值 |
|
||||||
|
|---|---|---|
|
||||||
|
| `AuthRequired` | 严格认证,失败返回 401 | `uid`、`email`、`username`、`role` |
|
||||||
|
| `AuthOptional` | 可选认证,有 token 就注入,没有也放行 | 同上(无 token 时不注入) |
|
||||||
|
|
||||||
|
**Cookie 配置**:
|
||||||
|
|
||||||
|
| Cookie | 名称 | HttpOnly | Secure | SameSite | 过期 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Access token | `mlb_token` | ✅ | ✅(debug 模式 HTTP) | Lax | 15 min |
|
||||||
|
| Refresh token | `mlb_refresh` | ✅ | ✅ | Lax | 30 day(记住我时) |
|
||||||
|
| 记住我标记 | `mlb_rm` | ❌ | ✅ | — | 30 day(记住我时) |
|
||||||
|
|
||||||
|
**中间件公开函数**:
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `SetAuthCookies(c, accessToken, refreshToken, cfg)` | 设置 access + refresh(+ mlb_rm 标记) |
|
||||||
|
| `SetAccessCookie(c, accessToken, cfg)` | 仅刷新 access cookie(续期调用) |
|
||||||
|
| `ClearAuthCookies(c, cfg)` | 清除全部三个 Cookie(登出/过期) |
|
||||||
|
|
||||||
|
**涉及文件**:`internal/middleware/auth.go`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 登录状态注入(SSR 模板渲染)
|
||||||
|
|
||||||
|
`internal/common/helper.go` 中的 `BuildPageData()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func BuildPageData(c *gin.Context, extra gin.H) gin.H
|
||||||
|
```
|
||||||
|
|
||||||
|
- 将 `extra` 键值对复制到 `gin.H`
|
||||||
|
- 若 Gin 上下文中存在 `username`(由 `AuthOptional` 中间件注入),自动添加:
|
||||||
|
- `IsLoggedIn: true`
|
||||||
|
- `Username: "..."`
|
||||||
|
- 所有页面 Controller 通过此函数构建模板数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 页面路由与模板
|
||||||
|
|
||||||
|
| 路由 | 方法 | 中间件 | 模板 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `/` | GET | `AuthOptional` | `home/index.html` | 已登录显示首页,未登录重定向 `/auth/login` |
|
||||||
|
| `/auth/register` | GET | `AuthOptional` | `auth/register.html` | 已登录重定向 `/` |
|
||||||
|
| `/auth/login` | GET | `AuthOptional` | `auth/login.html` | 已登录重定向 `/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. NAV 导航栏逻辑
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{if .IsLoggedIn}}
|
||||||
|
→ 显示用户名链接(指向 /settings)+ "退出"按钮
|
||||||
|
{{else}}
|
||||||
|
→ 显示"登录"按钮(指向 /auth/login)
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设计决策**:NAV 只显示"登录"按钮,不显示独立"注册"按钮。注册入口在登录页面内部提供(作为开发者社区,用户应能理解)。已登录用户可通过"退出"按钮清除所有 Cookie 并返回首页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 模板加载器重构
|
||||||
|
|
||||||
|
**问题**:原加载器使用 `info.Name()`(纯文件名),导致 `home/index.html` 注册为 `index.html`,与路由引用的 `home_index.html` 不匹配,模板渲染失败。
|
||||||
|
|
||||||
|
**解决**:改为使用相对路径作为模板名:
|
||||||
|
|
||||||
|
```
|
||||||
|
layout/header.html
|
||||||
|
layout/nav.html
|
||||||
|
layout/footer.html
|
||||||
|
auth/register.html
|
||||||
|
auth/login.html
|
||||||
|
home/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
`{{template}}` 引用全部更新为这些相对路径名。
|
||||||
|
|
||||||
|
**涉及文件**:`internal/theme/loader.go`、所有 `.html` 模板文件中的 `{{template}}` 引用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. "记住我" + 刷新令牌(Refresh Token)
|
||||||
|
|
||||||
|
#### 11.1 设计目标
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 用户勾选"记住我" | 双 Cookie:`mlb_token`(15min) + `mlb_refresh`(30d) |
|
||||||
|
| 用户未勾选 | 单 Cookie:`mlb_token`(15min),过期需重新登录 |
|
||||||
|
| 页面持续开着 | 前端每 10 分钟静默调用 `/api/auth/refresh` 续期 access token |
|
||||||
|
| 关闭浏览器 15+ min 后回来 | access token 过期 → JS 自动刷新 → 页面 reload 恢复登录态 |
|
||||||
|
| 30 天内任意时间回来 | refresh token 仍有效,自动静默恢复登录 |
|
||||||
|
|
||||||
|
#### 11.2 Cookie 架构
|
||||||
|
|
||||||
|
| Cookie 名 | 作用 | HttpOnly | JS 可读 | 过期时间 | 说明 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `mlb_token` | access token | ✅ | ❌ | 15 min | JWT,含 `uid/email/username/role/exp/iat` |
|
||||||
|
| `mlb_refresh` | refresh token | ✅ | ❌ | 30 day(勾选记住我时) | JWT,含 `uid/email/username/role/exp/iat/purpose:"refresh"` |
|
||||||
|
| `mlb_rm` | 记住我标记 | ❌ | ✅ | 30 day(勾选记住我时) | 值为 `"1"`,仅标记位,非凭据 |
|
||||||
|
|
||||||
|
**设计决策 — `mlb_rm` 标记 Cookie**:
|
||||||
|
- 本质是**非安全标记位**(not a credential),用于 JS 判断"是否该尝试刷新"
|
||||||
|
- 如果不存在(未勾选记住我),`common.js` 跳过所有刷新逻辑 → **不会产生 401 控制台报错**
|
||||||
|
- 值仅为 `"1"`,即使被 XSS 读取也无法用于任何认证操作
|
||||||
|
|
||||||
|
#### 11.3 配置 (`config.yaml` + `config.go`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jwt:
|
||||||
|
access_expire: 15 # 分钟
|
||||||
|
refresh_expire: 168 # 小时 (7 天) — 备用字段,当前未启用
|
||||||
|
remember_expire: 720 # 小时 (30 天) — refresh token 有效期
|
||||||
|
```
|
||||||
|
|
||||||
|
`JWTConfig` 新增字段:
|
||||||
|
```go
|
||||||
|
RememberExpire int `mapstructure:"remember_expire"` // 小时
|
||||||
|
```
|
||||||
|
**关键**:`mapstructure` 标签**不可省略**,否则 Viper 无法将 YAML 键映射到结构体字段,导致过期时间全为 0。
|
||||||
|
|
||||||
|
#### 11.4 请求 DTO (`dto.go`)
|
||||||
|
|
||||||
|
`LoginRequest` 和 `RegisterRequest` 都新增了 `RememberMe` 字段:
|
||||||
|
```go
|
||||||
|
RememberMe bool `json:"remember_me"`
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 11.5 后端业务逻辑 (`auth_service.go`)
|
||||||
|
|
||||||
|
**变更的方法:**
|
||||||
|
|
||||||
|
| 方法 | 旧返回值 | 新返回值 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Register()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
|
||||||
|
| `Login()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
|
||||||
|
|
||||||
|
当 `req.RememberMe == false` 时,`refreshToken` 返回空字符串 `""`。
|
||||||
|
|
||||||
|
**新增方法:**
|
||||||
|
|
||||||
|
| 方法 | 签名 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `buildRefreshToken(user)` | `(string, error)` | 构建 30 天过期 JWT,`purpose` claim 为 `"refresh"` |
|
||||||
|
| `RefreshAccessToken(refreshTokenStr)` | `(string, error)` | 校验 refresh token → 返回新 access token |
|
||||||
|
|
||||||
|
**`RefreshAccessToken` 校验流程**:
|
||||||
|
```
|
||||||
|
1. 解析 refresh token JWT
|
||||||
|
→ 失败:ErrTokenExpired(登录已过期)
|
||||||
|
2. 校验 claims["purpose"] == "refresh"
|
||||||
|
→ 不匹配:ErrTokenInvalid(防止 access token 被用于刷新)
|
||||||
|
3. 从 claims 提取 uid/email/username/role → 构建新 access token
|
||||||
|
```
|
||||||
|
|
||||||
|
**安全点**:`purpose: "refresh"` 声明确保**access token 无法充当 refresh token**,防止短生命周期令牌被滥用为长期凭据。
|
||||||
|
|
||||||
|
#### 11.6 Session 恢复实现(前端 JS)
|
||||||
|
|
||||||
|
`common.js` 中的自动刷新逻辑:
|
||||||
|
|
||||||
|
```
|
||||||
|
页面加载(非 /auth/* 路径)
|
||||||
|
→ 检查 document.cookie 是否存在 mlb_rm
|
||||||
|
→ 不存在 → 跳过所有刷新(无 401 报错)
|
||||||
|
→ 存在 → fetch POST /api/auth/refresh
|
||||||
|
→ 成功:
|
||||||
|
1. 启动 10 分钟定时器
|
||||||
|
2. 页面未显示登录态(无 logoutBtn)?
|
||||||
|
→ sessionStorage 防重 → window.location.reload()
|
||||||
|
重载后 access token 存在 → SSR 正确渲染登录状态
|
||||||
|
→ 失败 → 停止定时器(用户需手动重新登录)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键实现细节**:
|
||||||
|
- 用 `fetch()` 而非 `XMLHttpRequest`:`fetch` 对 HTTP 非 2xx 不会触发浏览器控制台静默日志
|
||||||
|
- `sessionStorage` 防无限循环:`mlb_refreshed` 标志确保 reload 只执行一次
|
||||||
|
- 不在认证页面执行:`/auth/login`、`/auth/register` 路径直接跳过
|
||||||
|
|
||||||
|
#### 11.7 界面
|
||||||
|
|
||||||
|
登录页和注册页各增加一个复选框:
|
||||||
|
```html
|
||||||
|
<div class="form-group remember-group">
|
||||||
|
<label class="remember-label">
|
||||||
|
<input type="checkbox" id="rememberMe">
|
||||||
|
保持登录 30 天
|
||||||
|
</label>
|
||||||
|
<span class="remember-hint">非共享设备推荐</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
**默认不勾选**——公共环境(教室、网吧、共享电脑)不会意外留下长期凭据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. 登出功能
|
||||||
|
|
||||||
|
#### 12.1 后端 API
|
||||||
|
|
||||||
|
**路由**:`POST /api/auth/logout`
|
||||||
|
|
||||||
|
**处理流程**(`auth_controller.go` → `Logout()`):
|
||||||
|
```
|
||||||
|
1. middleware.ClearAuthCookies(c, cfg)
|
||||||
|
→ 清除 mlb_token、mlb_refresh、mlb_rm 三个 Cookie(MaxAge=-1)
|
||||||
|
2. 返回 JSON { success: true, message: "已退出登录" }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 12.2 前端
|
||||||
|
|
||||||
|
**NAV 导航栏**(`nav.html`):
|
||||||
|
```html
|
||||||
|
{{if .IsLoggedIn}}
|
||||||
|
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
|
||||||
|
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**JS 处理**(`common.js`):
|
||||||
|
```
|
||||||
|
点击"退出" → POST /api/auth/logout → Cookie 清除完毕 → 跳转首页
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. 刷新令牌 API
|
||||||
|
|
||||||
|
**路由**:`POST /api/auth/refresh`
|
||||||
|
|
||||||
|
**处理流程**(`auth_controller.go` → `RefreshToken()`):
|
||||||
|
```
|
||||||
|
1. 从 Cookie 读取 mlb_refresh
|
||||||
|
→ 为空:401 "请重新登录"
|
||||||
|
2. 调用 authService.RefreshAccessToken(refreshToken)
|
||||||
|
→ 失败:ClearAuthCookies() + 401 "登录已过期"
|
||||||
|
3. 成功:SetAccessCookie() → 下发新 access token
|
||||||
|
→ 返回 200 { success: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:此端点**只刷新 access token**,不刷新 refresh token。refresh token 有 30 天固定有效期,到期后用户需手动登录。这避免了无限续期带来的安全隐患(长期不活跃的 refresh token 最终会自然过期)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、解决方案建议
|
||||||
|
|
||||||
|
### 2.1 PostgreSQL 密码认证(问题一记录)
|
||||||
|
|
||||||
|
**系统信息**:
|
||||||
|
- 分布:Linux Mint
|
||||||
|
- 数据库用户:`metazone`
|
||||||
|
- 连接方式:TCP `127.0.0.1:5432`(非 Unix socket)
|
||||||
|
- 认证方式:`scram-sha-256`
|
||||||
|
- 密码:`MetalabDev2026!`(存储于 `.env` → `DATABASE_PASSWORD`)
|
||||||
|
- 数据库:`metalab_dev`
|
||||||
|
|
||||||
|
**验证命令**:
|
||||||
|
```bash
|
||||||
|
# 测试连接
|
||||||
|
PGPASSWORD='MetalabDev2026!' psql -h 127.0.0.1 -U metazone -d metalab_dev -c "SELECT current_user, version();"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 .env 文件结构
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 必填
|
||||||
|
DATABASE_PASSWORD=MetalabDev2026!
|
||||||
|
JWT_SECRET=p2BVUsELbny4W4sYNvoyFesyglD2TlPRT+bzpDk8Dq/E2s9tZFzT6jC8N3AvQpzE
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` 已加入 `.gitignore`,提供 `.env.example` 模板文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、代码规范新增/修改
|
||||||
|
|
||||||
|
### 3.1 新增规范
|
||||||
|
|
||||||
|
| 编号 | 规范 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| C01 | **`BuildPageData()` 统一模板数据注入** | 所有页面 Controller 必须通过此函数构建 `gin.H`,禁止手动拼装 `IsLoggedIn`/`Username` |
|
||||||
|
| C02 | **Cookie 认证优先** | 使用 HttpOnly Cookie(`mlb_token`)传递 JWT,而非 `Authorization` Header,兼顾安全与 SSR |
|
||||||
|
| C03 | **主键列名:`uid`** | Go 侧字段名 `ID`,GORM 标签 `column:uid`,JSON 序列化 `"uid"` |
|
||||||
|
| C04 | **用户名:10 位随机 `[a-z0-9]`** | 使用 `crypto/rand`(非 `math/rand`),20 次重试去重 |
|
||||||
|
| C05 | **配置 env 注入模式** | `.env` 文件通过 `os.Setenv()` 注入环境变量,Viper 通过 `BindEnv` + `AutomaticEnv` 读取。不再使用 viper 的 `.env` 文件加载 |
|
||||||
|
| C06 | **模板名:相对路径** | `layout/header.html`、`auth/register.html` 等。禁止用纯文件名避免冲突 |
|
||||||
|
|
||||||
|
### 3.2 修改的已有规范
|
||||||
|
|
||||||
|
| 编号 | 变更 | 旧 | 新 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M01 | 用户标识字段 | `nickname`(非唯一) | `username`(全局唯一,varchar(16)) |
|
||||||
|
| M02 | JSON 序列化字段名 | `"id"` | `"uid"` |
|
||||||
|
| M03 | `BaseModel.ID` GORM 列名 | `id`(默认) | `uid`(`column:uid`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、当前文件清单
|
||||||
|
|
||||||
|
### 后端(Go)
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
├── config/
|
||||||
|
│ └── config.go # Config 结构体 + Load() + loadEnvFile() + DSN()
|
||||||
|
│ # JWTConfig 含 AccessExpire/RefreshExpire/RememberExpire
|
||||||
|
├── model/
|
||||||
|
│ ├── common.go # BaseModel(uid 主键 + 时间戳 + 软删除)
|
||||||
|
│ ├── user.go # User 结构体 + Role/Status 常量
|
||||||
|
│ └── dto.go # RegisterRequest / LoginRequest 含 RememberMe
|
||||||
|
├── repository/
|
||||||
|
│ └── user_repo.go # UserRepo(Create/FindByEmail/FindByID/FindByUsername/ExistsByEmail/ExistsByUsername)
|
||||||
|
├── service/
|
||||||
|
│ └── auth_service.go # AuthService(Login/Register/RefreshAccessToken + buildToken/buildRefreshToken + 8 个错误哨兵)
|
||||||
|
├── controller/
|
||||||
|
│ └── auth_controller.go # AuthController(RegisterPage/LoginPage/Register/Login/Logout/RefreshToken/CheckEmail)
|
||||||
|
├── middleware/
|
||||||
|
│ └── auth.go # AuthRequired/AuthOptional/SetAuthCookies/SetAccessCookie/ClearAuthCookies/parseToken
|
||||||
|
│ # 三个 Cookie:mlb_token / mlb_refresh / mlb_rm
|
||||||
|
├── router/
|
||||||
|
│ └── router.go # Setup() — 路由注册 + 依赖注入链(含 /auth/logout、/auth/refresh)
|
||||||
|
├── common/
|
||||||
|
│ └── helper.go # BuildPageData() — SSR 登录状态注入
|
||||||
|
└── theme/
|
||||||
|
└── loader.go # LoadTemplates() — 相对路径模板加载 + LoadContent()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(模板 + 静态资源)
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/MetaLab-2026/
|
||||||
|
├── html/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── header.html # HTML head(title/meta/CSS 引入)
|
||||||
|
│ │ ├── nav.html # 导航栏(已登录显示用户名+退出按钮 / 未登录显示登录按钮)
|
||||||
|
│ │ └── footer.html # 页脚
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── register.html # 注册页(含准则模态框 + 60s 倒计时 + 记住我复选框)
|
||||||
|
│ │ └── login.html # 登录页(简洁表单 + 记住我复选框)
|
||||||
|
│ └── home/
|
||||||
|
│ └── index.html # 首页(Hero 区域 + 欢迎信息)
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ ├── common.css # 全局样式 + NAV 样式(含 .nav-logout)
|
||||||
|
│ │ └── auth.css # 认证页样式(表单、模态框、Toast、.remember-group/.remember-label/.remember-hint)
|
||||||
|
│ └── js/
|
||||||
|
│ ├── common.js # 通用工具(Toast + 登出 + 自动令牌刷新 + mlb_rm 检测)
|
||||||
|
│ ├── register.js # 注册页逻辑(表单验证/AJAX 提交/准则模态框/倒计时/记住我)
|
||||||
|
│ └── login.js # 登录页逻辑(表单验证/AJAX 提交/Toast/记住我)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
```
|
||||||
|
config.yaml # 主配置(服务/数据库/JWT 含 remember_expire/BCrypt)
|
||||||
|
.env # 敏感信息(密码/JWT密钥)→ .gitignore
|
||||||
|
.env.example # 敏感信息模板(无真实值)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、待做事项
|
||||||
|
|
||||||
|
按优先级排序:
|
||||||
|
|
||||||
|
| 优先级 | 功能 | 状态 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0** | 邮箱验证码 | 待做 | 注册时发送验证码(`auth/verify-email`),预留了路由和接口 |
|
||||||
|
| ~~**P0**~~ | ~~登出功能~~ | ✅ 已完成 | `POST /api/auth/logout` → 清除三个 Cookie + 跳转首页 |
|
||||||
|
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域,需添加帖子列表、话题导航等 |
|
||||||
|
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
|
||||||
|
| **P1** | 用户设置页 | 待做 | `/settings` — 修改用户名/头像/密码/个人简介 |
|
||||||
|
| **P2** | 管理后台 | 待做 | `/admin` — 用户管理、内容审核、主题切换 |
|
||||||
|
| ~~**P2**~~ | ~~刷新令牌~~ | ✅ 已完成 | 记住我 + access/refresh 双 Token + 自动续期 + 登出 |
|
||||||
|
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` → `MetaLab-2027` 切换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、约定速查
|
||||||
|
|
||||||
|
| 类别 | 约定 |
|
||||||
|
|---|---|
|
||||||
|
| 数据库主键 | Go: `ID uint`,DB: `uid`(bigint 自增),JSON: `"uid"` |
|
||||||
|
| Access Token Cookie | `mlb_token`,JWT(HS256),15 min,HttpOnly + Secure + SameSite=Lax |
|
||||||
|
| Refresh Token Cookie | `mlb_refresh`,JWT(HS256),30 day(记住我时),HttpOnly + Secure + SameSite=Lax,含 `purpose:"refresh"` 声明 |
|
||||||
|
| 记住我标记 Cookie | `mlb_rm`,值 `"1"`,30 day(记住我时),非 HttpOnly(JS 可读),非凭据 |
|
||||||
|
| 密码哈希 | bcrypt(12),Go 字段 `PasswordHash`,JSON 不暴露 |
|
||||||
|
| 用户名 | 10 位 `[a-z0-9]`,`crypto/rand` 生成,全局唯一 |
|
||||||
|
| 角色 | `user` / `moderator` / `admin`(string,非 bool) |
|
||||||
|
| 配置 | `.env` → `os.Setenv` → viper `BindEnv` + `AutomaticEnv` |
|
||||||
|
| 模板名 | 相对路径:`layout/header.html`、`auth/register.html` |
|
||||||
|
| API 响应 | `common.Ok(ctx, data)` / `common.Error(ctx, code, msg)` |
|
||||||
|
| 页面数据 | `common.BuildPageData(c, extra)` — 自动注入 `IsLoggedIn` / `Username` |
|
||||||
|
| 包依赖方向 | Router → Controller → Service → Repository → Model(不可反向) |
|
||||||
|
| 令牌刷新 | `POST /api/auth/refresh`,前端每 10 min 静默调用 |
|
||||||
119
docs/routing.md
Normal file
119
docs/routing.md
Normal 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
128
docs/structure.md
Normal 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` 放 BaseModel(ID/CreatedAt/UpdatedAt)
|
||||||
|
- `dto.go` 放请求/响应传输对象
|
||||||
|
|
||||||
|
### Middleware(中间件)
|
||||||
|
|
||||||
|
- 每个中间件一个文件,≤ 60 行
|
||||||
|
- 按关注点注册到路由组上,不要逐个路由单独加
|
||||||
|
|
||||||
|
### Router(路由)
|
||||||
|
|
||||||
|
- 按路由组拆文件:`web.go`(页面)、`api.go`(API)、`admin.go`(后台)
|
||||||
|
- 只做路由映射,不写 handler 逻辑
|
||||||
|
|
||||||
|
### Theme(主题管理)
|
||||||
|
|
||||||
|
- `loader.go`:模板文件加载、内容文件读取
|
||||||
|
- `manager.go`:主题扫描、切换、获取当前
|
||||||
|
- `resolver.go`:静态资源路径解析(`/static/` → 当前主题 static 目录)
|
||||||
|
- `theme.go`:主题接口定义 + ThemeMeta 结构体
|
||||||
|
|
||||||
|
### Common(公共工具)
|
||||||
|
|
||||||
|
- `response.go`:统一 JSON 响应格式
|
||||||
|
- `error_code.go`:错误码常量
|
||||||
|
- `validator.go`:参数校验
|
||||||
|
- `pagination.go`:分页工具
|
||||||
|
|
||||||
|
## import 规则
|
||||||
|
|
||||||
|
```
|
||||||
|
允许的依赖方向(从上到下,不可反向):
|
||||||
|
|
||||||
|
controller → service → repository → model
|
||||||
|
controller → common
|
||||||
|
service → common + repository + model
|
||||||
|
middleware → common + service(可选)
|
||||||
|
router → controller + middleware
|
||||||
|
theme → 无(独立工具包)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Controller **不可** import Repository
|
||||||
|
- Controller **不可** import 另一个 Controller
|
||||||
|
- Service **不可** import Controller
|
||||||
|
- Repository **不可** import Service
|
||||||
|
|
||||||
|
## 模板目录规范
|
||||||
|
|
||||||
|
- 每个主题 `templates/{主题名}/` 下有独立完整的 `html/` 和 `static/`
|
||||||
|
- 主题之间互不引用、互不依赖
|
||||||
|
- 长文本内容(准则、关于页面等)放主题根目录(`guidelines.html`),不在 `html/` 下
|
||||||
|
- 这些内容文件**不是模板**(不含 `{{}}` 语法),由 Controller 读取后注入
|
||||||
|
- 系统级模板(email 等)放 `templates/system/`,与主题无关
|
||||||
|
- 切换主题时,Gin 引擎重新指向新主题的 `html/` 目录
|
||||||
59
go.mod
Normal file
59
go.mod
Normal 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
140
go.sum
Normal 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
62
internal/common/cookie.go
Normal 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 Cookie(refresh 续期调用)
|
||||||
|
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
|
||||||
|
secure := cfg.Server.Mode != "debug"
|
||||||
|
c.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAuthCookies 清除所有认证 Cookie(logout 调用)
|
||||||
|
func ClearAuthCookies(c *gin.Context, cfg *config.Config) {
|
||||||
|
secure := cfg.Server.Mode != "debug"
|
||||||
|
c.SetCookie(CookieName, "", -1, "/", "", secure, true)
|
||||||
|
c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true)
|
||||||
|
c.SetCookie(RMCookieName, "", -1, "/", "", secure, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCookie 写入一个 HttpOnly Cookie
|
||||||
|
func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
|
||||||
|
log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure)
|
||||||
|
c.SetCookie(name, value, maxAge, "/", "", secure, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPlainCookie 写入非 HttpOnly Cookie(JS 可读)
|
||||||
|
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
|
||||||
|
c.SetCookie(name, value, maxAge, "/", "", secure, false)
|
||||||
|
}
|
||||||
20
internal/common/crypto.go
Normal file
20
internal/common/crypto.go
Normal 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
22
internal/common/errors.go
Normal 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
44
internal/common/helper.go
Normal 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
|
||||||
|
}
|
||||||
51
internal/common/pagination.go
Normal file
51
internal/common/pagination.go
Normal 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
|
||||||
20
internal/common/response.go
Normal file
20
internal/common/response.go
Normal 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})
|
||||||
|
}
|
||||||
51
internal/common/username.go
Normal file
51
internal/common/username.go
Normal 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
119
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
131
internal/controller/admin/admin_controller.go
Normal file
131
internal/controller/admin/admin_controller.go
Normal 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, "操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
229
internal/controller/auth_controller.go
Normal file
229
internal/controller/auth_controller.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
92
internal/middleware/admin.go
Normal file
92
internal/middleware/admin.go
Normal 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 管理后台页面认证中间件
|
||||||
|
// 逻辑同 AuthRequired(JWT 校验 + token_version 吊销检查)
|
||||||
|
// 但失败时重定向到首页,而非返回 JSON
|
||||||
|
// 成功时向 context 注入 uid/email/username/role
|
||||||
|
func AdminAuth(cfg *config.Config, userRepo *repository.UserRepo) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tokenStr, err := c.Cookie(common.CookieName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[AdminAuth] NO_COOKIE path=%s → 302", c.Request.URL.Path)
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := parseToken(tokenStr, cfg.JWT.Secret)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[AdminAuth] TOKEN_PARSE_FAIL path=%s err=%v → 302", c.Request.URL.Path, err)
|
||||||
|
common.ClearAuthCookies(c, cfg)
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := uint(claims["uid"].(float64))
|
||||||
|
tokenVer := int(claims["ver"].(float64))
|
||||||
|
|
||||||
|
// 即时吊销检查
|
||||||
|
currentVer, err := userRepo.FindTokenVersion(uid)
|
||||||
|
if err != nil || tokenVer != currentVer {
|
||||||
|
if tokenVer != currentVer {
|
||||||
|
log.Printf("[AdminAuth] REVOKED: uid=%d tokenVer=%d dbVer=%d → 302", uid, tokenVer, currentVer)
|
||||||
|
} else {
|
||||||
|
log.Printf("[AdminAuth] DB_ERR: uid=%d err=%v → 302", uid, err)
|
||||||
|
}
|
||||||
|
common.ClearAuthCookies(c, cfg)
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uid, claims["role"], c.Request.URL.Path)
|
||||||
|
c.Set("uid", uid)
|
||||||
|
c.Set("email", claims["email"])
|
||||||
|
c.Set("username", claims["username"])
|
||||||
|
c.Set("role", claims["role"])
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireMinRole 角色权限检查(用于 API 路由)
|
||||||
|
// 不足时返回 JSON 403
|
||||||
|
func RequireMinRole(minRole string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
role, exists := c.Get("role")
|
||||||
|
if !exists || !model.HasMinRole(role.(string), minRole) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false, "message": "权限不足",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirePageRole 角色权限检查(用于 SSR 页面路由)
|
||||||
|
// 不足时 302 跳转首页
|
||||||
|
func RequirePageRole(minRole string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
role, exists := c.Get("role")
|
||||||
|
if !exists || !model.HasMinRole(role.(string), minRole) {
|
||||||
|
log.Printf("[RequirePageRole] DENY: role=%v need=%s path=%s → 302", role, minRole, c.Request.URL.Path)
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
146
internal/middleware/auth.go
Normal file
146
internal/middleware/auth.go
Normal 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
112
internal/middleware/csrf.go
Normal 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=false,JS 可读——这是 Double Submit 模式的必要条件
|
||||||
|
func SetCSRFToken(c *gin.Context, cfg *config.Config) string {
|
||||||
|
secure := cfg.Server.Mode != "debug"
|
||||||
|
|
||||||
|
// 如果已有 token 且未过期,复用(但必须注入 context,供模板 meta 标签使用)
|
||||||
|
if existing, err := c.Cookie(csrfCookieName); err == nil && existing != "" {
|
||||||
|
c.Set(csrfMetaName, existing)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateCSRFToken()
|
||||||
|
if err != nil {
|
||||||
|
// 极端情况:随机数生成失败,使用短 token
|
||||||
|
token = "fallback-" + hex.EncodeToString([]byte("metazone"))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetSameSite(http.SameSiteStrictMode)
|
||||||
|
// 30 天有效期,与 refresh token 对齐
|
||||||
|
maxAge := int(cfg.JWT.RememberExpire * 3600)
|
||||||
|
c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false)
|
||||||
|
|
||||||
|
c.Set(csrfMetaName, token)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCSRFToken 生成 64 字符十六进制随机 CSRF token
|
||||||
|
func generateCSRFToken() (string, error) {
|
||||||
|
b := make([]byte, csrfTokenLen)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// constantTimeEq 恒定时间字符串比较(防时序攻击)
|
||||||
|
func constantTimeEq(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var result byte
|
||||||
|
for i := 0; i < len(a); i++ {
|
||||||
|
result |= a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return result == 0
|
||||||
|
}
|
||||||
149
internal/middleware/ratelimit.go
Normal file
149
internal/middleware/ratelimit.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/middleware/security.go
Normal file
26
internal/middleware/security.go
Normal 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
15
internal/model/common.go
Normal 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
28
internal/model/dto.go
Normal 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
45
internal/model/user.go
Normal 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]
|
||||||
|
}
|
||||||
183
internal/repository/user_repo.go
Normal file
183
internal/repository/user_repo.go
Normal 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
104
internal/router/router.go
Normal 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理后台 API(JSON 响应) ---
|
||||||
|
adminAPI := r.Group("/api/admin")
|
||||||
|
adminAPI.Use(authMdw.Required())
|
||||||
|
adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
|
adminAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
adminAPI.GET("/users", adminController.ListUsers)
|
||||||
|
adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus)
|
||||||
|
adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API 路由(CSRF 严格验证) ---
|
||||||
|
api := r.Group("/api")
|
||||||
|
api.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
api.POST("/auth/check-email", authCtrl.CheckEmail)
|
||||||
|
api.POST("/auth/register", authCtrl.Register)
|
||||||
|
api.POST("/auth/login", authCtrl.Login)
|
||||||
|
api.POST("/auth/logout", authCtrl.Logout)
|
||||||
|
api.POST("/auth/refresh", authCtrl.RefreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
internal/service/admin_service.go
Normal file
141
internal/service/admin_service.go
Normal 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)
|
||||||
|
}
|
||||||
182
internal/service/auth_service.go
Normal file
182
internal/service/auth_service.go
Normal 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)
|
||||||
|
}
|
||||||
123
internal/service/token_service.go
Normal file
123
internal/service/token_service.go
Normal 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_expire(30天);false → 使用 refresh_expire(7天,session 模式)
|
||||||
|
// ver = user.TokenVersion,刷新时校验:版本号不匹配则拒绝
|
||||||
|
func (ts *TokenService) BuildRefreshToken(user *model.User, rememberMe bool) (string, error) {
|
||||||
|
var expireHours int
|
||||||
|
if rememberMe {
|
||||||
|
expireHours = ts.cfg.JWT.RememberExpire
|
||||||
|
} else {
|
||||||
|
expireHours = ts.cfg.JWT.RefreshExpire
|
||||||
|
}
|
||||||
|
expire := time.Duration(expireHours) * time.Hour
|
||||||
|
now := time.Now()
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"uid": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"username": user.Username,
|
||||||
|
"role": user.Role,
|
||||||
|
"ver": user.TokenVersion,
|
||||||
|
"exp": now.Add(expire).Unix(),
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"purpose": "refresh",
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(ts.cfg.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAccessToken 用 refresh token 换取新的 access token
|
||||||
|
// 校验 token_version:若 DB 中版本已递增,拒绝刷新 → 即时吊销
|
||||||
|
func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) {
|
||||||
|
if refreshTokenStr == "" {
|
||||||
|
return "", nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.Parse(refreshTokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.cfg.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return "", nil, ErrTokenExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return "", nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只接受 refresh 用途的 token,防止 access token 被用于刷新
|
||||||
|
if purpose, _ := claims["purpose"].(string); purpose != "refresh" {
|
||||||
|
return "", nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := uint(claims["uid"].(float64))
|
||||||
|
tokenVer := int(claims["ver"].(float64))
|
||||||
|
|
||||||
|
// 即时吊销检查 + 获取最新用户数据(角色/状态可能在 JWT 签发后已变更)
|
||||||
|
// 用 FindByIDForAuth 而非从 claims 重建,确保 access token 承载最新数据
|
||||||
|
user, err := ts.userRepo.FindByIDForAuth(uid)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, common.ErrTokenRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenVer != user.TokenVersion {
|
||||||
|
log.Printf("[RefreshAccessToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, user.TokenVersion)
|
||||||
|
return "", nil, common.ErrTokenRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止封禁用户通过 refresh 续期
|
||||||
|
if user.Status == model.StatusBanned {
|
||||||
|
return "", nil, common.ErrUserBanned
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := ts.BuildAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return accessToken, user, nil
|
||||||
|
}
|
||||||
53
internal/theme/loader.go
Normal file
53
internal/theme/loader.go
Normal 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
|
||||||
|
}
|
||||||
29
templates/MetaLab-2026/guidelines.html
Normal file
29
templates/MetaLab-2026/guidelines.html
Normal 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>
|
||||||
48
templates/MetaLab-2026/html/auth/login.html
Normal file
48
templates/MetaLab-2026/html/auth/login.html
Normal 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>
|
||||||
80
templates/MetaLab-2026/html/auth/register.html
Normal file
80
templates/MetaLab-2026/html/auth/register.html
Normal 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">×</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>
|
||||||
22
templates/MetaLab-2026/html/home/index.html
Normal file
22
templates/MetaLab-2026/html/home/index.html
Normal 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>
|
||||||
5
templates/MetaLab-2026/html/layout/footer.html
Normal file
5
templates/MetaLab-2026/html/layout/footer.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<footer>
|
||||||
|
<div class="container footer-content">
|
||||||
|
<p class="copyright">© 2024-2026 MetaLab | Framework: MetaZone</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
12
templates/MetaLab-2026/html/layout/header.html
Normal file
12
templates/MetaLab-2026/html/layout/header.html
Normal 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>
|
||||||
17
templates/MetaLab-2026/html/layout/nav.html
Normal file
17
templates/MetaLab-2026/html/layout/nav.html
Normal 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>
|
||||||
351
templates/MetaLab-2026/static/css/auth.css
Normal file
351
templates/MetaLab-2026/static/css/auth.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
240
templates/MetaLab-2026/static/css/common.css
Normal file
240
templates/MetaLab-2026/static/css/common.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
354
templates/MetaLab-2026/static/css/home.css
Normal file
354
templates/MetaLab-2026/static/css/home.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
templates/MetaLab-2026/static/js/common.js
Normal file
194
templates/MetaLab-2026/static/js/common.js
Normal 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)
|
||||||
|
})();
|
||||||
80
templates/MetaLab-2026/static/js/login.js
Normal file
80
templates/MetaLab-2026/static/js/login.js
Normal 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 }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
305
templates/MetaLab-2026/static/js/register.js
Normal file
305
templates/MetaLab-2026/static/js/register.js
Normal 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})();
|
||||||
9
templates/MetaLab-2026/theme.json
Normal file
9
templates/MetaLab-2026/theme.json
Normal 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"
|
||||||
|
}
|
||||||
30
templates/admin/html/dashboard/index.html
Normal file
30
templates/admin/html/dashboard/index.html
Normal 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" .}}
|
||||||
38
templates/admin/html/layout/base.html
Normal file
38
templates/admin/html/layout/base.html
Normal 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">
|
||||||
8
templates/admin/html/layout/footer.html
Normal file
8
templates/admin/html/layout/footer.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/admin/static/js/common.js"></script>
|
||||||
|
{{if .ExtraJS}}
|
||||||
|
<script src="{{.ExtraJS}}"></script>
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
templates/admin/html/users/index.html
Normal file
50
templates/admin/html/users/index.html
Normal 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" .}}
|
||||||
295
templates/admin/static/css/common.css
Normal file
295
templates/admin/static/css/common.css
Normal 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); } }
|
||||||
12
templates/admin/static/css/users.css
Normal file
12
templates/admin/static/css/users.css
Normal 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; }
|
||||||
122
templates/admin/static/js/common.js
Normal file
122
templates/admin/static/js/common.js
Normal 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>`;
|
||||||
|
}
|
||||||
156
templates/admin/static/js/users.js
Normal file
156
templates/admin/static/js/users.js
Normal 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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user