Files
pangolin/server/CONVENTIONS.md
T
wangjia b64c002a33 feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
apierr:
- Add New() constructor, StatusFor() HTTP-status mapping
- Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors
- Add chi-compatible Middleware for panic(*Error) → JSON recovery
- Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware)

idgen:
- Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0)
- Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes)
- Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check)

codes:
- Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen
- All existing codes generator tests continue to pass unchanged

server:
- Add CONVENTIONS.md covering package structure, error handling, ID generation,
  database conventions, handler templates, auth context, testing, and logging rules
- Move google/uuid from indirect to direct dependency in go.mod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:58:38 +08:00

267 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Pangolin 后端编码规范 (CONVENTIONS)
> 本文件是给 Claude Code 和所有后端开发者的**强制性**编码规约。
> 与 `design/server/ARCHITECTURE.md` 配合使用:架构文档说「做什么」,本文说「怎么做」。
---
## 0. 铁律(违反即阻断 PR
1. **API 错误文案双语**:所有面向客户端的错误必须使用 `apierr.Error`(包含 `message_zh` + `message_en`)。
2. **脱敏**:任何错误文案、日志、注释中禁止出现「VPN」「翻墙」「科学上网」。
3. **明文激活码禁止入库**:只能存储 `idgen.HashCode(canonical)`;明文只能存在于生成响应和 CSV 导出的内存流中。
4. **无日志口径**:不记录用户的目的地址、DNS 查询、流量内容;只记录 `usage_daily` 的字节数和分钟数。
---
## 1. 包结构
```
server/
├── cmd/ 应用入口(main 包),每个二进制一个子目录
│ ├── api/ 控制面 HTTP 服务
│ └── agent/ 节点 agentgRPC 服务)
├── internal/ 私有实现,不对外暴露
│ ├── apierr/ API 错误类型与辅助函数(零依赖)
│ ├── idgen/ ID 生成(UUID v7 + Crockford Base32
│ ├── auth/ 认证(邮箱验证码 / argon2id / JWT RS256
│ ├── codes/ 激活码生命周期(生成 / webhook / 兑换 / 审计)
│ ├── devices/ 设备管理
│ ├── nodes/ 节点目录 + connect/disconnect
│ ├── usage/ 用量聚合 + 广告解锁
│ ├── admin/ 管理端(内部,独立端口)
│ ├── config/ 配置加载
│ ├── db/ 数据库连接 + migration
│ ├── redisutil/ Redis 连接
│ ├── store/ 跨模块的共享 DB 查询
│ └── mtls/ 节点 mTLS 证书管理
├── api/ OpenAPI 契约(openapi.yaml
├── migrations/ SQL migration 文件(golang-migrate 格式)
└── CONVENTIONS.md 本文件
```
**包命名**:小写单词,无缩写(`redisutil` 可接受);禁止包名与 Go 标准库重名。
**导入顺序**`goimports` / `golangci-lint` 强制):
1. 标准库
2. 第三方包(`github.com/xxx/yyy`,非本项目)
3. 本项目内部包(`github.com/wangjia/pangolin/server/internal/…`
各组之间留一个空行。
---
## 2. 错误处理(`internal/apierr`
### 2.1 规则
- **所有 HTTP handler 的错误响应**必须经过 `apierr.WriteJSON`
- **绝不**直接 `http.Error(w, "...", status)``json.Encode(map[string]string{...})`
- 若使用 `apierr.Middleware`handler 可以 `panic(apierr.ErrXxx)` 代替 `WriteJSON + return`;但不可混用两种风格。
### 2.2 使用模式
```go
// ❌ 错误示例
http.Error(w, "unauthorized", http.StatusUnauthorized)
// ✅ 正确:使用预定义错误
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
// ✅ 正确:使用 New 创建临时错误
apierr.WriteJSON(w, http.StatusBadRequest, apierr.New(
"DEVICE_LIMIT_EXCEEDED",
"设备数量已达套餐上限",
"Device limit reached for your plan",
))
return
```
### 2.3 HTTP 状态码映射
| 场景 | 错误 Code | HTTP 状态 |
|------|-----------|-----------|
| 请求体格式错误 / 缺少字段 | `BAD_REQUEST` | 400 |
| JWT 缺失或过期 | `UNAUTHORIZED` | 401 |
| 操作不允许(权限不足) | `FORBIDDEN` | 403 |
| 资源不存在 | `NOT_FOUND` | 404 |
| 状态冲突(幂等操作重复但语义不同) | `CONFLICT` | 409 |
| 限流 / 账户锁定 | `RATE_LIMITED` / `ACCOUNT_LOCKED` | 429 |
| 未预期的内部错误 | `INTERNAL_ERROR` | 500 |
`apierr.StatusFor(e)` 实现了上述映射;显式指定 status 时应与映射一致。
### 2.4 错误日志
- 业务错误(4xx):**不记录日志**(用户侧错误,无需报警)。
- 内部错误(5xx):使用 `slog.Error("...", "error", err)` 记录,不在响应体中暴露原始 error。
---
## 3. ID 生成(`internal/idgen`
### 3.1 实体 IDUUID v7
所有数据库实体(users、devices、subscriptions、nodes……)使用 UUID v7
```go
import "github.com/wangjia/pangolin/server/internal/idgen"
// 新建记录时
id := idgen.NewString() // "018efa4b-xxxx-7xxx-xxxx-xxxxxxxxxxxx"
```
UUID v7 优点:时间有序(B-tree 索引友好)+ 全局唯一 + 不暴露自增 ID。
**禁止**使用数据库自增整型 `AUTO_INCREMENT` 作为主键(整型 ID 易被枚举,且不适合分布式)。
### 3.2 激活码(Crockford Base32
激活码通过 `idgen.GenerateCode()` 生成,格式为 16 字符 Crockford Base32(含 mod-37 校验位)。
**只存储 hash,不存明文**
```go
code, err := idgen.GenerateCode() // "0123456789ABCDE3" (明文,一次性)
hash := idgen.HashCode(code) // sha256 hex,存入 DB
canonical, err := idgen.CanonicalizeCode(userInput) // 输入规范化 + 校验
```
### 3.3 Redis Key 命名
```
<module>:<entity>:<id> → e.g. redeem:fail:018efa4b-... (失败计数)
<module>:<noun> → e.g. node:version (全局版本号)
webhook:nonce:<nonce> → webhook 去重
```
---
## 4. 数据库操作
### 4.1 原则
- 每个模块拥有自己的 `store.go`(只操作该模块的表)。
- 跨模块读取(只读)允许在自己的 `store.go` 中实现 JOIN;跨模块写入必须通过各模块的 Service。
- 事务(`sql.Tx`)在 Service 层开启和提交,Store 方法接受 `*sql.Tx` 参数。
### 4.2 命名
| 类型 | 命名 |
|------|------|
| 查询单行 | `Find<Entity>By<Key>` |
| 查询多行 | `List<Entities>By<Filter>` |
| 插入 | `Insert<Entity>` |
| 更新 | `Update<Entity>` |
| 删除 | `Delete<Entity>` |
| 带锁查询 | `Find<Entity>By<Key>ForUpdate` |
### 4.3 Migration 文件
文件名格式:`NNN_<snake_case_description>.up.sql` / `.down.sql`golang-migrate 规范)。
每个 migration 必须可回滚(down.sql 不得为空)。
---
## 5. HTTP Handler 结构
每个模块的 handler 遵循以下模板:
```go
// Handler 结构体持有 Service 引用(不直接持有 Store)。
type RedeemHandler struct {
svc *Service
}
func NewRedeemHandler(svc *Service) *RedeemHandler { ... }
// ServeHTTP 方法:
// 1. 路由方法校验
// 2. 从 Context 取认证信息
// 3. 解析请求体
// 4. 调用 Service
// 5. 写响应(成功 2xx JSON / 失败 apierr.WriteJSON
func (h *RedeemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }
```
**禁止** Handler 直接操作数据库(不注入 Store / DB)。
---
## 6. 认证上下文
JWT 中间件(`internal/auth`)将 claims 注入 `context.Context`
各 handler 通过统一的 context key 取值:
```go
// 在 handler 中取认证用户 ID
userID, ok := r.Context().Value(codes.CtxKeyUserID).(int64)
if !ok || userID == 0 {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
```
---
## 7. 测试规范
### 7.1 分层
| 类型 | 文件命名 | 依赖 |
|------|---------|------|
| 单元测试 | `*_test.go``package xxx_test`| 纯内存,无外部服务 |
| 集成测试 | `*_integration_test.go` | testcontainersMySQL + Redis|
### 7.2 要求
- **每个导出函数/方法至少有一个单元测试**(happy path + 至少一个 error path)。
- 覆盖率目标:核心包(`apierr``idgen``codes`)≥ 90%。
- 集成测试使用 testcontainers**不依赖外部服务**,可在 CI 无配置跑通。
- 禁止在测试中硬编码随机种子;使用 `crypto/rand` 的函数天然随机,不需要固定 seed。
### 7.3 测试辅助函数命名
```go
// 必须:t.Helper()
// 命名:must<Action>always fatal on error
func mustGenerateCode(t *testing.T) string {
t.Helper()
code, err := idgen.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
return code
}
```
---
## 8. 日志规范
使用 `log/slog`Go 1.21+):
```go
slog.Info("code redeemed", "user_id", userID, "duration_days", result.DurationDays)
slog.Error("internal error", "error", err, "endpoint", r.URL.Path)
```
**绝对禁止**在日志中输出:
- 明文激活码
- 用户密码 / 密钥
- 目的地址 / DNS 查询内容
- 完整 JWT token
---
## 9. CI 检查
`Makefile``make lint``make test` 必须全部通过才能合并。
```
make lint → golangci-lint (errcheck, staticcheck, gosec, goimports)
make test → go test ./...(包含单元测试;集成测试需 -tags integration
```
OpenAPI 同步:`make openapi-check` 验证 `api/openapi.yaml` 与 handler 签名一致(oapi-codegen)。