初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可
This commit is contained in:
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/` 目录
|
||||
Reference in New Issue
Block a user