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>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
# 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/ 节点 agent(gRPC 服务)
|
||||
├── 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 实体 ID(UUID 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` | testcontainers(MySQL + 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)。
|
||||
Reference in New Issue
Block a user