# 穿山甲 · 后端架构设计(v0.2 — 数据面改为 libbox/sing-box)

> 本文档是给 Claude Code 的实现蓝本:先审阅 → 修订 → 再按模块实现。
> 产品语境见 `../CLAUDE.md`(设计系统)。脱敏规则同样适用于日志、报错文案与对外文档。

---

## 0. 设计原则
1. **控制面 / 数据面彻底分离**:API 服务器只管账户、套餐、节点目录、兑换;用户流量只走加速节点(sing-box libbox，REALITY/Hy2 outbound),绝不经过 API。
2. **App 内无支付**:资金流全部外部化(发卡店/TG/LINE/邮箱)→ 后端只做「激活码」的生成、分发对账与兑换。
3. **节点可秒级灰度**:节点列表带版本,被封节点能即时下架;客户端缓存最后一份可用列表兜底。
4. **最小数据原则(无日志承诺)**:不记录浏览内容/目的地;只保留计费与风控所需的最小元数据(用量字节数、设备数、最后活跃时间),并在文档中公开口径。

---

## 1. 系统拓扑

```
┌────────────────┐  HTTPS   ┌─────────────────┐
│ Flutter 客户端  │ ───────▶ │  API (控制面)    │── Postgres / Redis
│ (libbox 内嵌)  │          │  Go + chi/gin    │
└───────┬────────┘          └───────┬─────────┘
        │ REALITY / Hy2              │ 节点注册/心跳 (mTLS gRPC)
        │ (libbox outbound) ┌────────▼─────────┐  ┌────────────────┐
        └──────────────────▶│ 加速节点 (数据面)  │  │  发卡店 Webhook  │
                            │ singbox + agent   │  │  (激活码入库)    │
                            └──────────────────┘  └────────────────┘
```

- **API**:Go(推荐，chi/gin 生态成熟)。单体起步,按模块分包,未来可拆。
- **节点 agent**:每台加速节点跑一个轻量 agent：注册、心跳、上报负载、向控制面同步节点凭证（REALITY 公钥/Hy2 口令）。与 API 间用 mTLS gRPC。
- **存储**:Postgres(主数据)+ Redis(验证码、限流、节点实时负载)。

---

## 2. 数据模型(核心表)

```sql
users         (id, email UNIQUE, pw_hash, created_at, status)
devices       (id, user_id FK, name, platform, last_seen, created_at)
plans         (id, code['free'|'pro'|'team'], max_devices, max_routes,
               daily_minutes NULL=无限, ad_gate bool)  -- free: 1 节点/10 分钟/须看广告
subscriptions (id, user_id FK, plan_id FK, expires_at, source['trial'|'code'], created_at)
codes         (id, code UNIQUE, plan_id, duration_days, batch_id, channel,
               status['unused'|'redeemed'|'void'], redeemed_by NULL, redeemed_at NULL)
code_batches  (id, channel['store'|'tg'|'line'|'manual'], created_by, note, created_at)
nodes         (id, region, name_zh, name_en, endpoint, protocol['reality'|'hy2'|'both'],
               reality_public_key, reality_short_id, reality_uuid, hy2_password,
               tags[], tier['free'|'pro'], status['up'|'down'|'draining'], weight)
               -- 凭证字段由节点 agent 注册时上报，控制面渲染 connect 响应时使用
usage_daily   (user_id, date, bytes_up, bytes_down, minutes_used, ad_unlocked_at NULL)
               -- 仅字节数/分钟数,无目的地;ad_unlocked_at = 当日激励视频解锁时刻(免费版)
audit_log     (id, actor, action, target, meta jsonb, at)  -- 兑换/封禁/节点操作必记
```

要点:
- 试用 = 注册时自动插入 `subscriptions(plan=pro, expires_at=now()+7d, source='trial')`,一邮箱仅一次(套餐口径见 `../CLAUDE.md` §7)。
- 体验期后回落 free:仅 1 个基础节点(nodes.tier='free')、每日 10 分钟;每日首次连接前需激励视频解锁(客户端上报 → `POST /v1/ads/unlock` 记 `ad_unlocked_at`,connect 接口校验)。
- `codes.code` 用 Crockford Base32,16 位,带校验位;明文只出现一次(生成响应/发卡店),库里可存 hash。

---

## 3. API 契约(v1 摘要,正式版用 OpenAPI 定义)

```
POST /v1/auth/code        {email}                  → 发 6 位验证码(Redis, 10min, 限 1/min)
POST /v1/auth/register    {email, code, password}  → 创建账户 + 7 天试用 + JWT
POST /v1/auth/login       {email, password}        → JWT (access 15min + refresh 30d)
POST /v1/auth/refresh     {refresh_token}

GET  /v1/me                                        → 账户 + 订阅 + 用量摘要
GET  /v1/me/devices                                → 设备列表
DELETE /v1/me/devices/:id                          → 移除设备（强制下线，下次 connect 校验时拒绝）

POST /v1/redeem           {code}                   → 兑换激活码(幂等,审计)
POST /v1/ads/unlock       {device_id, ad_token}    → 免费版激励视频解锁当日时长(验广告 SDK 回执)
GET  /v1/plans                                     → 套餐目录(给客户端展示)

GET  /v1/nodes            ?if_version=N            → 节点目录(按套餐过滤, 304 支持)
POST /v1/nodes/:id/connect {device_id}             → 下发完整 sing-box config JSON（见 §3.1）
POST /v1/nodes/:id/disconnect

GET  /v1/usage            ?days=7                  → 用量曲线(统计页)
```

约定:错误体 `{code, message_zh, message_en}`;全部接口 JWT(除 auth);限流用 Redis 滑窗。

### 3.1 connect 契约 —— sing-box config JSON（数据面，v0.2 定稿）

> **输入来源**：#5/#6（控制面 connect 接口实现）以本节为准；11H（M6 联调）强依赖本契约。  
> **决策依据**：见 `doc/plans/11-libbox-bridge.md`（数据面协议选型记录）。

**请求体**

```json
{ "device_id": "<设备 ID>" }
```

**响应体** —— 完整 sing-box config JSON，包含以下四块：

| 块 | 职责 |
|----|------|
| `inbounds` | TUN 接管设备全流量；`strict_route: true` 实现 Kill-switch（VPN 断线时阻断非隧道流量） |
| `outbounds` | REALITY outbound（TCP，抗主动探测）+ Hy2 outbound（UDP，高吞吐）+ `type:urltest` 自动测速选优组 + `block` |
| `route` | 私有 IP 直连；其余默认走 urltest 组（即走代理）；`auto_detect_interface: true` |
| `dns` | 全部 DNS 走远端解析（防泄露）；本地 DNS 仅用于 CN 直连域名（可选） |

**客户端职责（不可越界）**

```
收到 config JSON → libbox.start(configJson)  // 原样透传，Dart 层禁止拼装/修改 config
```

Dart 层不组装 outbound，不修改任何字段，不缓存凭证——全部服务端生成、客户端透传。

**字段与 deploy/ 模板对照**

| 字段 | 客户端 config | deploy/ 对应 |
|------|-------------|-------------|
| VLESS `server_port` | `11443` | `xray/config.tmpl.json` `inbounds[0].port` |
| VLESS `uuid` | 服务端动态生成 | `xray/config.tmpl.json` `clients[0].id`（`__UUID__`）|
| VLESS `flow` | `"xtls-rprx-vision"` | `xray/config.tmpl.json` `clients[0].flow` |
| REALITY `server_name` | `"www.apple.com"` | `xray/config.tmpl.json` `serverNames[0]` |
| REALITY `public_key` | 服务端动态生成 | 由 `__REALITY_PRIVATE_KEY__` 派生公钥 |
| REALITY `short_id` | 服务端动态生成 | `xray/config.tmpl.json` `shortIds[0]`（`__REALITY_SHORT_ID__`）|
| Hy2 `server_port` | `443` | `singbox/config.tmpl.json` `inbounds[0].listen_port` |
| Hy2 `password` | 服务端动态生成 | `singbox/config.tmpl.json` `users[0].password`（`__HY2_PASSWORD__`）|
| Hy2 `masquerade` | TLS `alpn: ["h3"]` | `singbox/config.tmpl.json` `tls.alpn` |
| TUN `strict_route` | `true`（Kill-switch）| — |

**最小示例 config**（可直连 deploy/ 测试节点，占位符须替换）

完整 JSON 见 `doc/plans/client-connect-config.example.json`。占位符替换说明：

| 占位符 | 实际值来源 |
|--------|-----------|
| `"ffffffff-ffff-ffff-ffff-ffffffffffff"` | xray `__UUID__`（VLESS 用户 UUID）|
| `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"` | `__REALITY_PRIVATE_KEY__` 对应的 base64url 公钥 |
| `"deadbeef"` | xray `__REALITY_SHORT_ID__`（十六进制，1–8 字节）|
| `"__HY2_PASSWORD__"` | singbox `__HY2_PASSWORD__`（Hysteria2 用户口令）|
| `"__NODE_HOST__"` | 节点公网 IP 或域名 |

> 校验命令：`sing-box check -c doc/plans/client-connect-config.example.json`

---

## 4. 关键流程

### 4.1 注册(对齐客户端 UI:邮箱 → 验证码 → 设密码)
1. `POST /auth/code`:风控(IP+邮箱限频、一次性邮箱域黑名单)→ 发码。
2. `POST /auth/register`:验码 → 建号 → **自动开 7 天 PRO 试用** → 返回 JWT。

### 4.2 激活码生命周期
```
发卡店售出 → webhook(带签名) → codes 入库(status=unused, channel=store)
            人工渠道(TG/LINE/邮箱) → 管理端批量生成 batch → 导出给客服
客户端 POST /redeem → 校验+事务:code→redeemed, 订阅顺延(叠加而非覆盖) → audit_log
```
- 兑换幂等:同一用户重复提交同一码返回首次结果。
- 风控:单用户兑换失败 5 次/小时锁 1 小时。

### 4.3 连接(数据面)
1. 客户端拉 `/nodes`（带套餐过滤 + 客户端测延迟自选）。
2. `POST /nodes/:id/connect {device_id}`：
   - API 校验订阅有效性与设备数限制（免费版另校验当日 `ad_unlocked_at` 与剩余分钟）。
   - 从 `nodes` 表取节点凭证（`reality_public_key`/`reality_short_id`/`reality_uuid`/`hy2_password`）。
   - 服务端渲染完整 sing-box config JSON（见 §3.1），返回给客户端。
   - 免费版：在 config JSON 的 `outbounds[urltest].outbounds` 中仅保留 `free` tier 节点；TTL 通过客户端侧 token 过期管理，服务端不主动踢连接。
3. 客户端收到 JSON → `libbox.start(configJson)`，原样透传，不修改。
4. 断开：`POST /nodes/:id/disconnect` → 服务端记录设备已断开（用于流量统计），客户端调 `libbox.stop()`。
5. 节点 agent 每 30s 上报负载（在线连接数、带宽） → Redis，用于目录排序与"信号条"。

### 4.4 节点目录灰度
- `nodes` 任何变更 bump 全局 version;客户端带 `if_version` 轮询(或推送)。
- 节点被封:status=down 立即从目录消失;`draining` 用于计划下线(不接新连接)。

---

## 5. 安全与合规
- 密码 argon2id;JWT RS256,密钥轮换;全站 TLS1.3。
- 验证码与兑换接口必须有人机/限流防刷;注册防一次性邮箱。
- **无日志口径**(写进隐私政策并据实执行):不记录目的地址/DNS 查询/流量内容;仅 `usage_daily` 字节数。
- 管理端(内部)单独服务 + IP 白名单 + 双因素;一切敏感操作进 audit_log。
- 节点服务器上不落用户身份明文,peer 只对应 device_id。

---

## 6. 部署与运维(起步规模)
- API：单区域 2 实例 + LB；Postgres 托管（每日备份）；Redis 托管。
- 节点：按地区购 VPS（HK/JP/SG/US…），Ansible/脚本一键装 sing-box + agent；现网已有 `deploy/` 模板可复用；出口 IP 池可轮换。
- 监控：节点心跳缺失告警、兑换失败率、注册转化漏斗。
- CI：GitHub Actions——lint + 单测 + OpenAPI 校验 + docker 镜像。

---

## 7. 给 Claude Code 的实现顺序
1. `openapi.yaml`（把 §3/§3.1 展开成正式契约）+ 数据库 migration
2. **auth 模块**（验证码/注册/登录/JWT）+ 测试
3. **codes 模块**（批量生成/webhook/兑换）+ 审计
4. devices + 订阅校验中间件
5. nodes 目录 + agent gRPC 协议（proto 文件）+ connect/disconnect（响应体按 §3.1 生成 sing-box config JSON）
6. usage 统计 + 管理端最小后台
7. Flutter 端接 API（替换 UI Kit 演示数据；UI 规范见 design/；libbox 桥接见 `doc/plans/11-libbox-bridge.md`）

> 每个模块完成的定义：有单测、有 OpenAPI 同步、错误文案双语且符合脱敏口径。

---

## 附录 A：数据面协议变更说明（v0.1 → v0.2）

v0.1 数据面为 WireGuard（peer 注册模式）。v0.2 改为 **sing-box libbox（REALITY/Hy2 outbound）**。

变更原因及完整决策记录：`doc/plans/11-libbox-bridge.md`（任务 tsk_9uMrd9kUpmVA）。

主要变化汇总：

| 项目 | v0.1 | v0.2 |
|------|------|------|
| 数据面协议 | WireGuard UDP peer | REALITY（TCP）+ Hy2（UDP），libbox 统一 |
| connect 响应 | WireGuard 配置（endpoint/pubkey/IP/DNS）| 完整 sing-box config JSON |
| 客户端行为 | 构建 WireGuard 隧道 | `libbox.start(configJson)` 原样透传 |
| 节点凭证 | WireGuard pubkey | REALITY public_key/short_id/uuid + Hy2 password |
| `devices` 表 | 含 `pubkey`（WG）| 移除 `pubkey`，不存储客户端密钥 |
| `nodes` 表 | 含 `pubkey`（WG）| 改为 `reality_public_key`/`reality_short_id`/`reality_uuid`/`hy2_password` |
