# 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）。
