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

8.6 KiB
Raw Permalink Blame History

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.Middlewarehandler 可以 panic(apierr.ErrXxx) 代替 WriteJSON + return;但不可混用两种风格。

2.2 使用模式

// ❌ 错误示例
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

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,不存明文

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.sqlgolang-migrate 规范)。 每个 migration 必须可回滚(down.sql 不得为空)。


5. HTTP Handler 结构

每个模块的 handler 遵循以下模板:

// 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 取值:

// 在 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.gopackage xxx_test 纯内存,无外部服务
集成测试 *_integration_test.go testcontainersMySQL + Redis

7.2 要求

  • 每个导出函数/方法至少有一个单元测试happy path + 至少一个 error path)。
  • 覆盖率目标:核心包(apierridgencodes)≥ 90%。
  • 集成测试使用 testcontainers不依赖外部服务,可在 CI 无配置跑通。
  • 禁止在测试中硬编码随机种子;使用 crypto/rand 的函数天然随机,不需要固定 seed。

7.3 测试辅助函数命名

// 必须: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/slogGo 1.21+):

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 检查

Makefilemake lintmake 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)。