# ShortMax 2.0 改动方案

> 本仓库 module `github.com/shortmax/shortmax`，依赖基座 `github.com/toolbelts/forge`

### 本文引用的仓库

全文用下列**短名**指代兄弟仓库，不写绝对路径——每个人的 checkout 位置不同。
文中形如 `models/user.go:1586` 的无前缀路径一律指 **$SPLAY**（1.0 业务源）。

| 短名 | 用途 | 参考 checkout 位置 |
|---|---|---|
| `$SPLAY` | 1.0 业务源，本次重写的迁移对象 | `$WORKSPACE/icenet/splay` |
| `$SP` | 协议布局与 buf 编译风格的模板 | `$WORKSPACE/icenet/sp` |
| `$FORGE` | 基座库 `github.com/toolbelts/forge` | `$WORKSPACE/zwczou/forge` |
| `$STOCK` | 仓库纪律（AGENTS.md / 目录结构规范）参考 | `$WORKSPACE/bit365/stock` |
| 本仓库 | `shortmax`，`$WORKSPACE/icenet/shortmax` | 本文档在 `docs/` 下 |

`$WORKSPACE` 是各自放代码的根目录（作者机器上是 `$HOME/projects`）。

**配套文档**：[参数表与团队收益体系](./参数表与团队收益体系.md) —— `tips_parameters` / `wealth_parameters` 两张配置表的逐字段去向、团队活跃值与合伙人活跃值的区别、月度支持统计表方案。

---

## 一、背景与目标

1.0（splay）业务能跑，但结构已经压不住了：

- `users` 是 57 列宽表——身份、凭据、9 个余额/活跃值列、11 个等级列、关系链数组全挤在一行。加余额在写整行，`services/man/point.go` 还有不带 `Column()` 的整行 `UPDATE`，并发下静默覆盖等级字段。
- 团队等级判定 `checkSupportLevel`（`models/user.go:1586-2204`，618 行）**同步跑在 RPC 里**，一次调用重算自己 + 5 个祖先、每人 4 条重聚合；判定逻辑和 7 个活动副作用缠在一起，活动插入失败会 `return`，**导致等级压根没写**（`models/user.go:2059`）。
- `inviter_fans varchar[]` 物化路径 + 4 个 GIN 表达式索引，注释里写明 5000 个 fans_code 的聚合要 7 秒+。
- 配置散在 `tips_param` / `wealth_param` 两张单行表 + 一堆 Go 常量（`models/user.go:29-68`，生产值旁边躺着注释掉的测试值）。
- 机器人日收益一天一行存在 `robot_daily_income_rates`（365 天的机器人后台要维护 365 行），而且**结算时实时读**这张表，改配置会追溯性改掉存量持仓收益。

2.0 要做的事：**保住用户资产与身份，重构承载它们的结构**，同时上线全新的「1.0 资产解锁 + 兑换」体系。

### 已定决策

| # | 决策 | 影响 |
|---|---|---|
| **D1** | **全量预迁移 + 用户在 2.0 内选方案**。割接前把 1.0 全量数据搬进 2.0 库；1.0 资产落成冻结金库（`scheme=0`，不倒计时）；用户在 2.0 里选方案才开始解锁。1.0 割接后只读。 | 迁移工具是一次性批量任务，不需要长期跨库读 |
| **D2** | 金库口径 = **仅 `users.available_point`**。`frozen_point`（提现在途/机器人冻结收益）与 `consume_point`（体验币）不计入。 | 割接前须先强平所有 EFFECTIVE 持仓、结清在途提现，把钱落回 available |
| **D3** | 三进程：**api（多副本）/ man 后台（多副本）/ worker（含 cron，单副本）** | worker 单副本 ⇒ cron 不必抢锁；但幂等仍靠 DB 唯一键兜底 |
| **D4** | **主键 `users.id` 直接就是业务 ID**（取消 1.0 的 `id`/`user_id` 双 ID），**所有表的外键一律引用 `users.id`**；`fans_code` 不落列，用 `utils` 从 id 正反推导 | 见 §4.1 |
| **D4a** | **`users.inviter_ids bigint[]` = 完整祖先链，不截断层级、不区分类型**（含代理商 / 业务员 / 合伙人 / 管理员上级） | **数据权限**：代理商/业务员只能查自己名下用户的数据（如 `asset_ledgers`），必须靠这条完整链判断归属 |
| **D4b** | **`referral_edges` 派生边表 = 只收 NORMAL 用户、只到 5 层、一行一条边** | 团队等级 / 返佣 / 团队成员列表等业务聚合。完全由 `inviter_ids` 派生，可随时重建 |
| **D5** | **基座直接依赖 `forge`**，不再照搬 splay 的 `pkg/*` | 见 §3.2。splay `pkg/` 里能搬的东西 forge 基本都有更好的版本 |
| **D6** | 团队等级字段名用 **`team_level`**（= 1.0 的 `tips_level`，语义完全一致） | 2.0 已经没有"打赏短剧"这件事，`tips` 前缀不再对应任何东西；且与新增的 `vip_level`（个人等级）对仗清楚。文档 / 后台文案统一标注「团队等级 = 1.0 的 tips_level」 |
| **D7** | **个人 VIP 等级随个人活跃值实时升降（会降级）**，档位与周兑换额度走配置、后续可调 | 但**已授予的当周兑换额度永不回收**（`GREATEST` upsert 保证） |
| **D8** | **充值只保留 futurepay**，jaya / unispay / haipay / Apple / Google / Stripe / InTheBag / MonetaPay 全部不搬 | 提现同样收敛到 futurepay（1.0 已有 `buildFuturePayPayoutRequest`，见 `models/withdraw.go:1746`） |
| **D9** | **协议布局与 buf 编译风格照搬 `$SP`**（module `github.com/shortplay/sp`）：`api/{base,error}/v1` + `api/{public,admin}/<module>/v1`，buf v2 远程插件，生成物**不入库** | 只取协议层与构建链路；`services/` 等领域结构不跟着改。校验用 protovalidate（`buf.validate` 注解），不能沿用 1.0 的 `protoc-gen-validate` |
| **D10** | 团队等级保底取 **`users.tips_level`**（割接时用户实际持有、APP 上显示的等级），不取 `highest_tips_level` | 迁移 S4 阶段 |
| **D11** | **`users.id` 保持随机不连续**（DB 侧随机步长序列 `next_user_id()`，起点 1 亿），不用连续自增 | 对外 ID：连续自增会泄露注册量、且整个用户空间可被枚举。随机步长仍单调递增，可当排序键 / 游标键 |
| **D13** | **删除 `internal/lang`，后端不做 i18n**。错误体系照 `$SP`：`api/error/v1.Error{code, message, metadata}` + `forge/errkit`，message 是英文开发者摘要，客户端按 `code` 分支、自己渲染文案 | **零行为变化**：1.0 的 `contrib/i18n/active.en.yaml` 是 **0 字节**，1504 处 `Errorg` 今天返回的 message 全是空串，每次还多打一行 error 日志 |
| **D14** | **短信只补 `kirim` 一家**接进 `forge/message`；**WhatsApp 不进 forge/message**（它没这个抽象），单独定 `WhatsAppSender` 接口 + Mekari 实现 | 见 §5.7 |
| **D15** | **客户端封装分两类**：裸 HTTP 的自己封 `Client struct`（`internal/client/subtitle`、`internal/pay/future`）；已有官方 SDK 的**不再包一层**，直接在装配层初始化并注入（阿里云 VOD / OSS） | `internal/client/{ali,third}` 删除；`third`（三方登录）整块下线 |
| **D16** | **团队等级枚举重新编号为顺序值**：`TEAM_LEVEL_UNSPECIFIED=0`、`TEAM_LEVEL_FREE=1`、`TEAM_LEVEL_1..7 = 2..8`，DB 直接存枚举值 | 1.0 的 `basepb.User_TipsLevel` 序号乱（FREE=6/VIP=5/ONE=1），每次比较都得过 `models/user.go:745` 的 rank map。重排后**顺序即大小**，rank map 彻底删除 |
| **D12** | **多数据库实例**：`database.default`（主，写 + 强一致读）、`database.slave`（只读从库，承接时效不敏感的查询）。**多 Redis 实例**：`redis.default`（锁 / 队列 / 缓存）、`redis.token`（登录令牌，与业务缓存隔离） | forge 的 `RedisProvider` / `DatabaseProvider` 本来就是按名字注册的多实例 map，配置即启用 |
| **D18** | **取消 `robot_type`**：8 种类型的行为全部降级成列（`settle_mode` / `buy_team_level` / `exclusive_scope` / `on_sale_at`），`type` 退化成纯展示用的 `category`。等级机器人**只保留 V2 行为**，V1 迁移过去 | 生产快照：108 个机器人只有 24 在线、只用 4 种类型；冻结收益类（到期返 / 智投）20 个**全部下架**。见 §5.8 |
| **D17** | **不用 snowflake**。内部表主键一律 `bigint GENERATED ALWAYS AS IDENTITY`；只有 `users.id` 是随机的（D11，因为它对外） | 见 §5.9。少一个依赖、少一处 `snowflake.node_id` 配置、少一整类时钟回拨与 node_id 撞号故障 |

### 仍需产品确认（文档内先按推荐值实现）

**2026-01-30 祖父条款** —— 1.0 的 LV2 判定按 `TipsLevelAt` 分叉成新旧两套规则（`models/user.go:1678`）。2.0 **只实现新规则**（`支持份额>0 && 个人活跃值>=50`）。理由：旧分支只在「降级」时起作用，而迁移用户已被保底锁住不会降到 LV2 以下，这个永久性的日期 `if` 没有存在价值。**这是本方案唯一一处明知故犯的口径偏离。**

---

## 二、总览：照搬 / 改造 / 新增

| 模块 | 处置 | 一句话 |
|---|---|---|
| 基础设施（DI / 配置 / 日志 / redis / db / 锁 / 队列 / 缓存 / 令牌 / 限流 / 通知 / 短信邮件 / cron / 迁移 / 拦截器 / gateway） | **换成 forge** | 见 §3.2 对照表 |
| `pkg/pay/futurepay` → `internal/pay/future` | **重写** | 保留签名 / 双 key / 终态判定，改成带 ctx、typed error、恒定时间验签，见 §5.10 |
| `internal/client/subtitle`（GhostCut 字幕翻译 + 去字幕） | **重构成 Client struct** | 现在是裸函数 + 每次 `&http.Client{}` 无超时 + `map[string]any` 进 `gjson` 出，见 §5.10 |
| `internal/client/ali` | **删除包，能力保留** | 阿里云 VOD 是官方 SDK，不该再包一层；改在装配层初始化注入，见 §5.10 |
| `internal/client/third`（三方登录） | **整块删除** | 需求：第三方登录不要了。连带 `third_logins` 表、4 个 RPC、相关枚举与配置 |
| `internal/lang` | **删除** | 后端不做 i18n（D13），错误改走 `api/error/v1` + `forge/errkit` |
| `pkg/sender/*`（6 家） | **只补 `kirim` 进 forge/message** | WhatsApp 另立接口，见 §5.7 |
| `internal/utils`（除 `GenUserId`） | **原样照搬** | `GetFansCodeForUserId` / `ParseUserIdFromFansCode` 必须逐字保留；ID 生成改走 DB 侧 `next_user_id()` |
| 时间边界（1.0 手写 `Truncate` / `Weekday`） | **换 `jinzhu/now`** | `now.With(t).BeginOfDay()/BeginOfWeek()`，配 `time.Local = Asia/Jakarta` + `now.WeekStartDay = Monday`，不自建包，见 §5.2 |
| 短剧全套（`models/playlet.go` `models/chat.go` + 用户端/后台服务） | **照搬 + 轻改** | 只删 tips 相关列，其余原样；见 §4.7 |
| 团队等级判定算法 | **逻辑照搬，实现重构** | 判定段抽纯函数逐条对齐；执行改队列驱动；见 §5.4 |
| `users` 宽表 | **拆分** | 身份 / 凭据 / 登录方式 / KYC / 资产 / 等级 六张表 |
| `tips_ledgers`（支持记录） | **重构** | 删短剧判别器与全部短剧列，只留机器人；加收益模型快照 |
| `robots` + `robot_daily_income_rates` | **重构** | 三种收益模型（固定 / 线性 / 数组），行表删除 |
| `tips_param` `wealth_param` + Go 常量阈值 | **合并进 configs** | 类型化配置注册表 + forge/dbcache 两级缓存 |
| 1.0 金库解锁 / 兑换 / 周额度 / 个人 VIP 等级 | **全新** | §5.3 |
| 等级变更审计、资产幂等键、增量团队聚合 | **全新** | 1.0 完全没有 |
| Sumsub / Zoloz / 服务注册发现 / CPS 分销体系 | **删除** | KYC 改人工审核；不要服务发现 |

---

## 三、仓库骨架

### 3.1 目录树

```
shortmax/
├── AGENTS.md                 规范唯一真源（只写约束和红线）
├── CLAUDE.md -> AGENTS.md    符号链接
├── Makefile                  init/generate/check/{api,man,worker}/{_linux}/all/test/vet/help
├── go.mod                    module github.com/shortmax/shortmax
│
├── api/                      ★ 布局与 buf 风格照搬 ../sp
│   ├── buf.yaml              v2；deps: protovalidate + googleapis；lint STANDARD
│   ├── buf.gen.yaml          v2 远程插件：go / grpc / gateway / openapiv2，paths=source_relative
│   ├── base/v1/              两个接口面共享的协议类型（原 basepb）
│   ├── error/v1/             ★ 统一错误协议，实现 forge/errkit.Error
│   ├── public/user/v1/       apid 接口：登录 / 资料 / KYC
│   ├── public/asset/v1/      余额 / 流水 / 充值 / 提现
│   ├── public/legacy/v1/     ★ 1.0 金库：选方案 / 解锁 / 兑换
│   ├── public/holding/v1/    机器人 / 持仓 / 收益
│   ├── public/drama/v1/      短剧
│   └── admin/<module>/v1/    mand 接口，与 public 独立 service 与权限
│
├── cmd/{apid,mand,workerd}/main.go  每个 main ≤40 行：App.Use(...) + App.Run
├── cmd/migrate/                    ★ 迁移 CLI：migrate <db> <action>
├── cmd/migrate1to2/                 ★ 1.0→2.0 一次性迁移工具
├── apid/  mand/  workerd/           进程装配：provider 编排清单 + 业务 provider 注册
│
├── services/                 全部业务逻辑（领域结构不跟 sp 变）
│   ├── user/       登录 / 资料 / KYC
│   ├── asset/      资产 / 流水 / 充值提现
│   ├── legacy/     ★ 1.0 金库解锁 + 兑换
│   ├── level/      team_level + VIP 等级 + 重算 worker
│   ├── holding/    机器人 + 持仓 + 结算
│   ├── drama/      短剧
│   ├── activity/   消费等级变更事件的活动 / 补贴
│   └── man/        后台
│
├── internal/
│   ├── rediskey/   ★ 跨进程 Redis 契约（谁写/谁读/TTL/丢失后果 + key_test.go 锁字面量）
│   ├── utils/      从 splay 搬（GenUserId 除外）
│   ├── client/subtitle/    ★ GhostCut 字幕翻译 / 去字幕，重构为 Client struct
│   ├── pay/future/         ★ FuturePay，重写（原 pkg/pay/futurepay）
│   └── notify/wa/          ★ WhatsAppSender 接口 + Mekari 实现
│
├── models/         一表一文件；不 import pb、不持有 *bun.DB
│   └── config/     ★ 类型化配置注册表
├── migrations/     ★ forge/migration：embed.FS + 按 database.<name> 分目录的 *.sql
│   └── default/    0001_extension / 0002_identity / 0003_asset / 0004_level
│                    / 0005_legacy / 0006_robot / 0007_ops 的 *.up.sql + *.down.sql
│                    ★ 2.0 全部表结构在这里，见 §3.4
├── configs/        common.yaml + message.yaml + {apid,mand,workerd}.yaml
├── shell/          三个进程启停脚本
├── scripts/sql/    索引 / 运维 SQL
└── docs/
```

**协议约定（照 sp）**：
- 路径 public `/v1/<module>/...`、admin `/v1/admin/<module>/...`，模块名与 `services/` 目录一致，单数。
- **★ 全部业务接口一律 `POST` + `body: "*"`**，照搬 1.0 的做法，**查询类接口也用 POST**，不用 GET+query。**禁止路径参数**（`{id}` 之类）。这条与 sp 的 REST 风格不同，是本项目的例外，理由见下方「签名」。
- proto 字段 `lower_snake_case`；金额 / 数量 / 费率一律 `string` 传 decimal；时间戳 `int64` 秒。
- **生成物不入库**（`*.pb.go` / `*.pb.gw.go` / `*_grpc.pb.go` / `*.swagger.json`），`make generate` 本地产出，`make check` 验无漂移。这点与 sp 一致——sp 已经验证过这套工作流，不另起炉灶。
- 分页：public 用游标（`cursor + page_size` → `next_cursor`，不 COUNT）；admin 用偏移（`offset + limit` + 精确 `total`）。同一接口不得两套并存。

#### 请求签名：照搬 1.0（客户端不改）

APP 端接口沿用 1.0 的签名，**算法一字不改**（`playd/http.go:118-190`），因为客户端不动：

```
info = path[?query] + timestamp + rawBody
key  = deviceId[11:21] + deviceId[21:26] + deviceId[1:6]
sign = hex(HMAC-SHA256(key, info))
```

三个请求头：`X-Meta-Sign` / `X-Meta-Device-Id`（长度 ≥28）/ `X-Meta-Timestamp`（秒）。时间窗 **±90 秒**，超出或签名不符返回 `CODE_INVALID_SIGN`。

放行规则也照搬：`sign.whitelists` 里的路径跳过；支付回调（`/callback` 且路径含 `future`）跳过；`admin` 前缀不校验签名（后台走管理员令牌）。CN 地区拦截（`Cf-Ipcountry` / `CloudFront-Viewer-Country`）同样保留。

**全 POST 与签名是配套的**：签名串是 `path + timestamp + body`，一旦有接口把参数放 query string，就要处理 URL 编码差异——1.0 为此专门写了 `url.QueryUnescape` 兼容那一段，仍然是客户端和服务端各拼一次容易对不上的地方。**全部走 POST body，签名串的构造就无歧义**，那段兼容代码也不用搬。

⚠️ **必须清楚这个签名能防什么、不能防什么**：密钥是 `deviceId` 的三个切片拼出来的，而 `deviceId` 本身就在请求头里明文传。**任何拿到一次请求的人都能推出 key**，所以它挡的是"随手改个金额重放"，**不构成认证边界**。真正的身份认证只有 `Authorization` 令牌。所以：

1. **任何涉及资金或权限的判断，一律以令牌里的 `user_id` 为准**，绝不信请求体里传来的 `user_id`。
2. 不要因为"有签名"就放松任何一处鉴权或幂等。
3. 想真正加固要动客户端（例如密钥改成服务端下发、一机一密），不在本次范围。

### 3.2 forge 对照表（D5 的落地）

| splay 的做法 | 2.0 用 forge 的什么 | 差异 / 注意 |
|---|---|---|
| `pkg/container` 反射 DI + `playd/init.go` 手写 boot | `forge/ioc` + `forge/provider` | Register/Setup/Serve/Shutdown 四阶段，Shutdown LIFO。`apid/` 从「几百行装配」缩成一个 `App.Use(...)` 清单 |
| `pkg/redlock`（**裸 `DEL` 释放，会删别人的锁**；错误还常被丢弃） | **`forge/lock`** | fence token + TTL 自动续租，**加/续/解锁全走原子 Lua**，`Unlock` 校验 token 后才 DEL。`Manager.Run(ctx,key,fn)` 持锁执行且续租失败会取消 fn 的 ctx |
| `pkg/alert`（只有 Telegram，自己攒 Manager） | **`forge/notify`** | Telegram + Lark 双通道，`Send(ctx,title,content)`，fire-and-forget。`RecoveryProvider` 已经会把 panic 推给它 |
| `pkg/token`（单个不透明 token，redis hash） | `forge/token` | access/refresh 双令牌 + 用户索引 + replay 防护，关键路径 Lua。**割接后 1.0 老 token 全部失效，用户需重新登录一次**——这点要写进上线公告 |
| `pkg/meta` | `forge/meta` | gRPC metadata ↔ HTTP 字段桥接，跨服务透传 |
| `pkg/ratelimit`（go-cache 令牌桶） | `forge/ratelimit` | Redis 滑动窗口 + 规则热重载 + `fail_open`/`fail_closed` |
| `pkg/sender/*`（alisms / i51sms / buka / kirimsms / mekari / mail） | `forge/message` + 自建 WhatsApp | 邮件 SMTP/SendGrid 直接可用；短信**只补 `kirim` 一家**（6 步契约见 §5.7）。alisms / i51sms / buka 不搬。**mekari 是 WhatsApp 不是短信**，forge 完全没有 WhatsApp 抽象，另立 §5.7 |
| `pkg/pubsub`（进程内广播，重启即丢） | `forge/jobqueue` | Redis LIST + BRPOP，at-most-once，反射推断 handler 入参。业务级可靠性靠 DB 状态兜底 |
| `services/playlet/flash_sale_worker.go` 手写队列 | `forge/jobqueue` / `forge/reliablequeue` | 需要至少一次 + DLQ 时用 `reliablequeue`（Redis Streams + 消费组 PEL + `XAUTOCLAIM`） |
| `pkg/scache` + `pkg/mcache` | `forge/dbcache` | cache-aside，memory/redis/**tiered** 三种 store，singleflight 防击穿 + 负缓存防穿透。配置两级缓存直接用 tiered |
| 每个 service 自己 `cron.New(...)` | `forge/cron` | 默认 `SkipIfStillRunning + Recover`，6 字段秒级 |
| `migrations/*.go` + bun `init()` 自注册 | `forge/migration` | **改成 `embed.FS` + 按 db 名分目录的 `*.sql`**。好处：迁移不再引用 `models.X` 结构体，历史迁移不会随模型漂移（1.0 的 `CreateTable().Model()` 反射的是**当前**模型） |
| `basepb.Error` + `loc.Errorg` + `playd/http.go` 信封 + `internal/lang` | `forge/errkit` + `api/error/v1.Error` | 照搬 `$SP/api/error/v1/mapping.go` 的 default-then-override 传输映射。**`internal/lang` 整个删掉**（D13），详见 §5.11 |
| `protoc-gen-validate` 的 `(validate.rules)` | **protovalidate 的 `(buf.validate.field)`** | D9。proto 注解写法要换，生成链路改 buf |
| `playd/grpc.go` + `playd/http.go` 手写 gateway/拦截器 | `GrpcProvider` / `GatewayProvider` / `HttpProvider` / `PprofProvider` + `InterceptorChain` / `MiddlewareChain` | 拦截器链固定 `Recovery → AccessLog → Error → RateLimit → Validate → Token → 业务` |
| `pkg/redislb` | **不用** | 明确不要服务注册发现，`RegistryProvider` 不 Use |

**仍需自建的两块**：`internal/rediskey`（跨进程 key 契约）、`models/config`（类型化配置注册表，见 §5.5）。时间口径不自建包，见 §5.2。

### 3.3 进程

| 中文 | cmd | 装配包 | 二进制 | 部署 | http/grpc/pprof |
|---|---|---|---|---|---|
| 对外 API | `cmd/apid` | `apid/` | `apid` | 多副本 | 8811/8812/8813 |
| 后台 API | `cmd/mand` | `mand/` | `mand` | 多副本 | 8821/8822/8823 |
| 后台任务 + cron | `cmd/workerd` | `workerd/` | `workerd` | **单副本** | -/-/8833 |
| 迁移工具 | `cmd/migrate` | — | `migrate` | 一次性 Job | — |
| 1.0→2.0 数据迁移 | `cmd/migrate1to2` | — | `migrate1to2` | 一次性 | — |

**`d` 后缀 = 常驻守护进程**，看名字就知道这东西会一直跑。反过来，**不带 `d` 的就是一次性工具**，跑完即退——`migrate` / `migrate1to2` 都不带。这条约定是自解释的，`$SP` 把迁移工具叫 `migrated` 反而破坏了 `d` 的含义，这里不跟。

装配层只列 provider + 注册业务 service，**不得出现业务判断**——有业务就没法三进程共用 `services/`。

`apid/apid.go` 大致长这样：

```go
app := ioc.New()
_ = app.Use(
    &provider.BuildProvider{}, provider.NewConfigProvider("apid"), &provider.LoggerProvider{},
    &provider.MetricsProvider{}, &provider.TraceProvider{}, &provider.NotifyProvider{},
    &provider.RedisProvider{}, &provider.DatabaseProvider{},
    &provider.LockProvider{}, &provider.JobQueueProvider{}, &provider.DbcacheProvider{},
    &provider.MessageProvider{},
    // 拦截器：Use 顺序 = 外→内，全部必须在 GrpcProvider 之前
    &provider.RecoveryProvider{}, &provider.AccessLogProvider{}, &provider.ErrorProvider{},
    &provider.RateLimitProvider{}, &provider.ValidateProvider{}, &provider.TokenProvider{},
    &provider.GrpcProvider{}, &provider.HttpProvider{}, &provider.PprofProvider{},
    &provider.GatewayProvider{},
    // 业务
    user.Provider, asset.Provider, legacy.Provider, level.Provider,
    holding.Provider, drama.Provider,
)
_ = app.Run(ctx, nil)
```

`mand/` 去掉 JobQueue、换 `api/admin/**` 注册；`workerd/` 去掉 Grpc/Http/Gateway，加 `CronProvider`，`App.Run(ctx, nil)` 里只有 cron + 队列消费。

**man 独立进程**是 1.0 最该补的一刀：1.0 后台和 APP 同进程同端口，靠 `strings.Contains(info.FullMethod, "manpb")` 区分（`playd/grpc.go`），后台一个慢查询直接拖垮 APP。

worker 单副本是**部署约定不是代码保证**。所有 cron 与队列消费仍必须幂等（唯一键兜底），否则哪天扩成两副本就是双倍发放。

### 3.4 数据库与 Redis 实例（D12）

```yaml
# configs/common.yaml
database:
  # default 是主库：全部写入 + 需要读己之写的查询
  default:
    dsn: "postgres://.../shortmax?sslmode=disable&timezone=Asia/Jakarta"
    slow: "200ms"
  # slave 是只读从库：时效不敏感的查询走这里，减轻主库压力
  slave:
    dsn: "postgres://.../shortmax?sslmode=disable&timezone=Asia/Jakarta"
    slow: "500ms"

redis:
  # default：分布式锁、jobqueue、dbcache、业务缓存
  default: { addr: "127.0.0.1:6379", db: 0 }
  # token：登录令牌独占，与业务缓存隔离
  token:   { addr: "127.0.0.1:6379", db: 1 }

business:
  # ★ 业务时区，全站自然日 / 自然周口径的唯一来源
  timezone: "Asia/Jakarta"
```

**读从库的纪律**——写死三条，避免"随手切 slave"埋出难查的 bug：

1. **任何事务内、任何"读己之写"的路径，一律走 `default`**。资金变动、金库解锁、兑换、等级重算的读取全部主库。
2. **只有这几类允许走 `slave`**：后台列表与导出、报表 / 统计 / rollup、对账 cron 的全量扫描、短剧内容与观看历史查询。共同点是"晚几秒无所谓"。
3. **走 slave 的仓储方法名字必须带 `FromSlave` 后缀**（如 `ListAssetLedgersFromSlave`），代码里一眼可见。禁止在 service 层用 if 决定连哪个库——那样复制延迟引发的问题永远定位不到。

`redis.token` 独立的理由：令牌是登录的生命线，业务缓存是可丢的。混在一个实例里，一次缓存击穿把内存打满就会顺带把所有人踢下线。分开后 `token.redis: token`、`lock/jobqueue/dbcache.redis: default`。

`migrations/<db名>/` 与 `database.<name>` 一一对应（forge/migration 的约定）；`slave` 是同一份 schema 的副本，不建自己的迁移目录。

#### `migrations/default/` 的内容与切分

**2.0 的全部表结构就在这里**，不再有 `models` 反射建表那条路（1.0 的 `CreateTable().Model(new(models.X))` 反射的是当前模型，历史迁移会随模型漂移）。**不搬 1.0 的 76 个迁移文件**——那是 1.0 演化的历史，2.0 是全新 schema，从 baseline 开始。

按领域切成多个编号文件而不是一个大文件：每个文件可独立 review，边界与 `internal/module/*` 对齐，且顺序天然满足外键依赖。

```
migrations/default/
  0001_extension.up.sql   扩展 + next_user_id() 序列与函数 + 全局 domain/枚举约定
  0002_identity.up.sql    users, user_credentials, user_identities, user_kyc, referral_edges
  0003_asset.up.sql       user_assets, asset_ledgers
  0004_level.up.sql       user_levels, user_level_changes, active_value_ledgers
  0005_legacy.up.sql      legacy_vaults, legacy_unlock_ledgers, legacy_unlock_adjustments,
                          legacy_exchanges, legacy_exchange_quotas
  0006_robot.up.sql       robots, holdings, holding_incomes
  0007_ops.up.sql         configs, admins, roles, admin_operation_logs, message_deliveries
  （每个 .up.sql 配一个 .down.sql，按逆序 DROP）
```

上面 7 个文件 = **本文 §4 给出完整 DDL 的 20 张表**，是 P0 的交付物，P0 跑完 `make migrate` 应该能对空库建出完整骨架。

**后续阶段各自追加编号迁移**，不回头改 baseline：

| 阶段 | 追加 | 表 |
|---|---|---|
| P4 | `0008_payment.up.sql` | 充值 / 提现 / 提现渠道 / 银行卡（照搬 1.0，只留 futurepay，D8） |
| P5 | `0009_activity.up.sql` | 活动 / 通知（**任务系统不建**，见配套文档 §6） |
| P6 | `0010_drama.up.sql` | `dramas` / `drama_episodes` / `drama_groups` / `user_drama_ledgers` / `user_drama_collects` / `user_drama_episodes` / `ad_records` / `subtitles` / `drama_materials` / 弹幕 / 敏感词（§4.7 的直接映射） |

这几张的 DDL 本文只给了映射关系没给全列——它们是「照搬 1.0」的部分，到对应阶段对着 1.0 的模型逐列落地即可，现在写出来只是抄一遍且容易过期。

三条硬约定：

1. **迁移文件里不出现任何 Go 类型名**。纯 SQL，不 import model。
2. **每个 `.up.sql` 必须有对应的 `.down.sql`**，且本地验证过 `up → down → up` 可重入。
3. **baseline 一旦进过任何环境就不再改**，schema 变更一律新增编号文件。改 baseline 会让已迁移环境和新环境的 schema 悄悄分叉。

#### 迁移怎么聚合、怎么跑

**聚合**：`migrations/migrations.go` 一个 `embed.FS` 把所有 SQL 打进二进制，`forge/migration.New(FS)` 扫顶层目录、目录名即 db 名：

```go
// migrations/migrations.go
package migrations

import "embed"

//go:embed all:default
var FS embed.FS
```

`migration.New` 的行为（`$FORGE/migration/set.go`）：扫顶层每个**目录**当 db 名，各自走 bun 的 `migrate.Discover`；顶层的非目录条目（README、.gitkeep）忽略；**一个子目录都没有直接返回 `ErrEmpty`**——这条很重要，它让「embed 路径写错」或「SQL 忘了 commit」在启动时就炸，而不是静默地什么都不迁移。

⚠️ **文件名必须匹配 `^(\d{1,14})_([0-9a-z_\-]+)\.(up|down)\.sql$`**：名字部分**只允许小写字母、数字、下划线、连字符**，大写字母会让 `Discover` 直接报错；up/down 不配对也报错。

**CLI**：`cmd/migrate`，形态就是你说的那样——

```bash
migrate <db> <action>
```

action 由 `migration.IsAction` 白名单校验，共 6 个（`migrate`↔`up`、`rollback`↔`down` 是别名，前者是 bun 原生用词、后者更常见）：

| action | 作用 |
|---|---|
| `init` | 建迁移记账表（`bun_migrations` / `bun_migration_locks`）。底层是 `CREATE TABLE IF NOT EXISTS`，**幂等，可以每次部署都跑** |
| `up` / `migrate` | 应用所有未执行的迁移，输出本次的 group |
| `down` / `rollback` | 回滚最后一个 group |
| `status` | 打印 applied / unapplied / last group |

main 照 `$SP/cmd/migrated/main.go` 写，40 行以内，三个要点：

1. **参数校验前置**：`len(os.Args) != 3 || !migration.IsAction(os.Args[2])` → 打 usage、`exit 2`。未知 action 不 fallback，避免 `migrate default upp` 静默什么都不干。
2. **db 名要对着配置校验**：`provider.MustGetViper(ctx).GetStringMap("database")` 里没有就报错退出，而不是等 `Set.For()` 返回 not found。这样 `migrate slave up` 这种误操作会被挡在前面（`slave` 是只读副本，不建自己的迁移目录）。
3. **专用装配入口** `app.NewMigrate(migrations.FS)`：只 Use 配置 + 数据库 + migration set 三个 provider，不起 gRPC / HTTP / 队列 / cron。

**部署流程**——这也是它必须是独立二进制、而不是 `apid` 的一个子命令的原因：

```bash
migrate default init     # 幂等
migrate default up       # 跑完退出，非 0 即失败
# ↑ 上面成功了，才滚动 apid / mand / workerd
```

`apid` 和 `mand` 是**多副本**（§3.3）。把迁移挂在服务启动里，一次滚动发布就是 N 个副本并发跑迁移——bun 有 `bun_migration_locks` 兜底不会写坏，但会变成「一个副本在迁移、其余副本卡在锁上超时重启」的假故障。而且发布和迁移一旦耦合，回滚发布就等于回滚 schema，那是两件不该绑在一起的事。

k8s 里就是一个 `Job`（或 initContainer），镜像与服务同一个，`command: ["migrate", "default", "up"]`。

### 3.5 其他骨架决定

- **ORM/DB**：继续 bun + PostgreSQL（迁移源是 PG，换 ORM 没收益；forge 的 database/migration/dbcache 也都是 bun）。
- **配置里不放真实密钥**，一律环境变量注入（`DATABASE_DEFAULT_DSN`、`REDIS_TOKEN_ADDR` 之类，forge 的 `AutomaticEnv` + `.` → `_` 替换直接支持）。1.0 的 `contrib/playd.yaml.example` 是入库的且带着真实阿里云 AK/SK、OAuth secret、DB 密码、Stripe key（`.gitignore` 只挡 `*.yaml`，`.example` 漏网），`contrib/google/*.json` 也是入库的服务账号密钥。加 `make secret-scan`。这些凭据本身建议独立于本项目轮换。

---

## 四、数据模型

图例：**[照搬]** / **[改造]** / **[新增]**

### 4.1 身份与关系链 —— D4 的落地

```sql
-- [改造] users：从 1.0 的 57 列瘦到身份 + 状态。资产 / 等级 / 凭据 / 认证全部移出。
CREATE TABLE users (
  id              bigint PRIMARY KEY,        -- ★ 直接就是业务 ID，随机 9 位，见 §5.1
  type            smallint NOT NULL,         -- NORMAL / PARTNER / SALESMAN / AGENT / FOAM（ADMIN 已拆走）
  status          smallint NOT NULL,
  nickname        varchar(64),
  avatar          varchar(255),
  -- ★ 完整祖先链，直属在前：[父, 祖父, 曾祖, ...]。
  -- 不截断层级、不区分上级类型（普通用户 / 代理商 / 业务员 / 合伙人 / 管理员一律入链）。
  -- 它是【数据权限】的唯一依据：代理商只能看 inviter_ids 里含自己的那些用户。
  inviter_ids     bigint[] NOT NULL DEFAULT '{}',
  parent_id       bigint,                    -- = inviter_ids[1]，冗余给简单查询用
  kyc_status      smallint NOT NULL DEFAULT 0,     -- 提现前置判断，高频只读，允许冗余
  meta            bigint NOT NULL DEFAULT 0,       -- 禁邀请/禁充值/禁提现位掩码（照搬 1.0 语义）
  is_migrated     boolean NOT NULL DEFAULT false,  -- ★ true = 1.0 迁移用户
  migrated_at     timestamptz,
  comment         text,
  terminated_at   timestamptz,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX users_parent_id_idx   ON users (parent_id);
-- ★ 整列 GIN（不是 [1:5] 切片表达式索引），支撑数据权限的 @> 查询
CREATE INDEX users_inviter_ids_gin ON users USING gin (inviter_ids);
```

**`fans_code` 不落列**。1.0 的 `fans_code = "F" + (user_id*2+1)` 是纯函数（`internal/utils/utils.go:202,211`），可正反推导：按邀请码查用户 = `ParseUserIdFromFansCode(code)` 得 id 后走主键。省一列 + 省一个唯一索引 + 已发出去的邀请链接不失效。1.0 的 `FansMap` 硬编码特例（`"123"→"34436271"`，`models/user.go:71`）挪进 configs。

**所有表的用户外键**：列名仍叫 `user_id`（沿用习惯、少改代码），但**引用的是 `users.id`**。1.0 那种 `bun:"rel:belongs-to,join:user_id=user_id"` 的双 ID 写法在 2.0 不存在。

#### 关系链：一条完整链管权限，一张 5 层边表管业务

两个结构，**职责完全不同，不是同一件事的两种存法**：

```sql
-- [新增] 派生边表：只收 NORMAL 用户、只到 5 层、一行一条边。
-- 完全由 users.inviter_ids 派生，任何时候可 TRUNCATE 后无损重建。
CREATE TABLE referral_edges (
  ancestor_id   bigint   NOT NULL,
  descendant_id bigint   NOT NULL,
  depth         smallint NOT NULL,      -- 1..5
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (ancestor_id, depth, descendant_id)
);
CREATE INDEX referral_edges_desc_idx ON referral_edges (descendant_id, depth);
```

| | `users.inviter_ids bigint[]` | `referral_edges` |
|---|---|---|
| 内容 | **完整祖先链**，不截断层级 | **只到 5 层** |
| 收谁 | **所有类型上级**：普通用户 / 代理商 / 业务员 / 合伙人 / 管理员 | **只收 `type = NORMAL`** 的上下级 |
| 定位 | **真源** | **派生索引**，可随时从数组重建 |
| 干什么 | ① **数据权限**：代理商/业务员/管理员只能看名下用户 ② 往上取直属祖先（读自己一行，O(1)） | 团队等级 / 返佣 / 团队成员列表 / 对账等**业务聚合** |
| 体量 | 每人 1 行 | 每人 ≤5 行，100 万用户 = 500 万行 |

**为什么权限链不能截断到 5 层**：代理商可能在用户上方第 8 层。截断了他就看不到自己名下的用户，或者更糟——**看得到不该看的**。权限判断必须走完整链。

**为什么权限链要混入非普通用户，而边表不能**：链上出现代理商/业务员，恰恰是"这个用户归他管"的表达；但团队等级判定要数"5 层内 LV2+ 有几人"，把代理商数进去就错了。1.0 是靠 `strings.HasPrefix(fan, "F")` 在字符串里区分这两类（`models/user.go:978`），既脆又慢。2.0 把区分下沉成两个结构：**数组全收，边表只收 NORMAL**（生成边表时 `JOIN users ON type = NORMAL` 过滤）。

##### 三层可见范围

| 操作者 | 能看到谁 | 判定依据 |
|---|---|---|
| **超级管理员** | **所有人**，可操作所有人 | 不加任何范围条件 |
| **代理商 / 业务员** | **`inviter_ids` 里含自己的全部用户**，不限层数 | `users.inviter_ids @> ARRAY[$me]` |
| **普通用户（自己看自己的团队）** | **只有 1–5 级** | `referral_edges WHERE ancestor_id=$me AND depth<=5` |

第 2 行和第 3 行是**两套不同的口径，不能互相套用**：代理商可能在用户上方第 8 层，截断到 5 层他就看不到自己名下的人；反过来，普通用户的团队权益（等级 / 返佣 / 团队列表）**必须**卡在 5 级，放开就是多发钱。这也正是为什么一个用数组、一个用边表。

```sql
-- 代理商 $agent 看名下用户的财务日志（不限层数）
SELECT l.* FROM asset_ledgers l
 WHERE l.user_id IN (SELECT id FROM users WHERE inviter_ids @> ARRAY[$agent]::bigint[])
   AND l.created_at >= $from
 ORDER BY l.id DESC LIMIT 50;

-- 普通用户看自己的 1..5 级粉丝（卡 5 层）
SELECT u.id, u.nickname, e.depth FROM referral_edges e
  JOIN users u ON u.id = e.descendant_id
 WHERE e.ancestor_id = $me AND e.depth <= 5
 ORDER BY u.id DESC LIMIT 20;
```

`gin(inviter_ids)` 是**整列 GIN**，不是 1.0 那种 `gin((inviter_fans[1:5]))` 表达式索引——这是关键区别：表达式索引的值不在索引里，PG 每次都要**回表重算表达式**再 recheck；整列 GIN 直接对列建索引，recheck 便宜得多。后台查询低频，且可走 `slave`（§3.4）。

统一封装成一个 scope，禁止各处手拼：

```go
// models/scope.go

// VisibleUserScope 把"当前操作者能看到哪些用户"收敛成一个 scope：
// 超级管理员不加条件；代理商 / 业务员限定 inviter_ids 含自己（不限层数）。
//
// 后台任何按用户维度查询的接口都必须 Apply 它——1.0 是每个 handler 自己判
// (services/man/user.go:32 的 Level>0 分支)，漏一个就是越权，新接口作者极易忘。
func VisibleUserScope(operator *Admin) func(*bun.SelectQuery) *bun.SelectQuery
```

⚠️ 头部代理商的后代可能到十万～百万级，而 `asset_ledgers` 按"用户 × 人均流水"膨胀是全库最大表，**范围过滤后的精确 `COUNT` 是秒级半连接**。所以代理商 / 业务员视角下的资产流水列表 `total` 用**封顶计数**（≤10000 时精确，超出固定返回 10001 表示"10000+"）并限制 `offset+limit ≤ 10000`；超级管理员保持精确 `total`。这个例外只针对资产流水，其它列表照常。

##### 业务聚合怎么查

**热路径谁都不扫子树**——团队活跃值、直属 LV2+ 人数、5 层内 LV2+ 人数全部增量维护在 `user_levels`：

| 用途 | 1.0 做法 | 2.0 做法 |
|---|---|---|
| 取我的 ≤5 级祖先 | `inviter_fans[1:5]` + 字符串解析 + 前缀判类型 | `inviter_ids[1:5]`，读自己一行，O(1) |
| 我的团队活跃值 | `SUM(active_value) WHERE inviter_fans[1:5] @> ARRAY[code]` ← **7 秒** | 读 `user_levels.team_active_value`（增量维护） |
| 我的直属 LV2+ 人数 | 实时 `GROUP BY tips_level WHERE inviter_fans[1]=code` | 读 `user_levels.fans_level_counts`（增量维护） |
| 5 层内 LV2+ 人数 | 实时 `COUNT(*) WHERE tips_level IN(...) AND inviter_fans[1:5] @> ...` | 读 `user_levels.team_qualified_count`（增量维护） |
| 后台看团队成员（分页/排序/按等级筛） | 同上，GIN 扫 | `referral_edges` 主键 range scan + JOIN |
| 夜间对账全量重算 | — | `referral_edges` GROUP BY |

边表的强项就在最后两行：`WHERE ancestor_id=$me AND depth<=5` 是**主键前缀 range scan**，天然有序可分页，还能跟 `user_levels` JOIN 后按等级筛——nested loop + 主键查，代价可预测。这正是 GIN 做不到的：**GIN 无法与其它列的过滤条件组合成一个索引**，只能给候选集再逐行回表判断。1.0 那条 7 秒查询就是这么来的，陆续加了 4 个索引都没救回来（`migrations/20260423120000_*`、`20260512120000_*`、`20260512180000_*`、`20260512190000_*`）。

增量传播只需 ≤5 个 ID：从自己那行取 `inviter_ids[1:5]`，`WHERE id = ANY($1)` 走主键批量更新。

**一致性**：数组和边表在**同一事务**里写；对账 cron 从 `inviter_ids` 重建一份边表做全量 diff，不一致就告警。因为边表是**派生**而非第二真源，出问题永远可以 `TRUNCATE + 从数组重建`，不存在"两个真源打架谁对"。

**关系变更代价**：改绑一个人的上级要重写他整棵子树的 `inviter_ids` 和边——两个结构代价相同，都是子树规模。**建议 2.0 明确把归属关系设为不可变**（迁移后自动归属原家族、之后不允许改绑），改绑只走后台工单 + 全量重建脚本，这个代价就是零。

**管理员拆表**：1.0 把管理员塞在 `users` 里用 `Type=ADMIN` + `RoleIds int32[]` 区分，导致每个用户查询都得记得过滤。2.0 独立 `admins` + `roles` 表。

⚠️ 但**代理商 / 业务员 / 合伙人仍留在 `users`**（他们是有资产、有归属关系的业务角色，只是 `type` 不同），所以 `users.inviter_ids` 里会有他们——这正是数据权限需要的。1.0 是把这类上级以**裸 user_id 字符串**混进 `inviter_fans`、靠 `strings.HasPrefix(fan, "F")` 区分（`models/user.go:978`）；2.0 全部统一成 int64，类型判断改为 `JOIN users ON type`，生成边表时过滤掉非 NORMAL 即可。

```sql
-- [新增] 凭据类，一行一用户
CREATE TABLE user_credentials (
  user_id      bigint PRIMARY KEY REFERENCES users(id),
  password     varchar(128),   -- bcrypt，从 1.0 原样搬（旧密码继续可用）
  otp_secret   bytea,          -- ★ 加密存储（1.0 的 users.otp 是明文 TOTP 密钥）；★ 不迁移，2.0 全员重绑
  otp_bound_at timestamptz,    -- NULL = 未绑定 ⇒ 不能提现
  updated_at   timestamptz NOT NULL DEFAULT now()
);

-- [新增] 一行一登录方式，取代 1.0 的 email/mobile 唯一列 + meta/verified_meta 位掩码
CREATE TABLE user_identities (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id     bigint NOT NULL,
  provider    smallint NOT NULL,      -- 1=EMAIL 2=MOBILE 3=GOOGLE 4=TIKTOK 5=DISCORD 6=APPLE
  identifier  varchar(191) NOT NULL,  -- 邮箱 / E.164 手机号 / openid
  verified_at timestamptz,
  created_at  timestamptz NOT NULL DEFAULT now(),
  UNIQUE (provider, identifier)
);
CREATE INDEX ON user_identities (user_id);
```

为什么干掉位掩码：`users.verified_meta` 是个 int64，靠 `(*User).UpdateVerifiedMeta()`（`models/user.go:1053`）手动同步，**SQL 里查不出"谁验证了邮箱"**，任何忘调同步方法的路径都会静默写坏。行式可查、可索引、可增。

`provider` 枚举里**不含第三方登录**（D15：功能整块下线）。顺带说明：1.0 的三方登录数据本来也不可信——Google 登录被写成 `THIRD_LOGIN_TYPE_TIKTOK`（`services/user/user.go:1042`），所以后台按 Google 查永远是空的。既然功能下线，这批数据不迁移。

### 4.2 KYC —— [改造]，去 Sumsub

```sql
CREATE TABLE user_kyc (
  id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  no             varchar(32) NOT NULL UNIQUE,      -- 保留 utils.GenTimeNo("CERT")
  user_id        bigint NOT NULL,
  status         smallint NOT NULL,                -- 1=CREATED 2=PENDING 3=PASSED 4=REJECTED
  source         smallint NOT NULL DEFAULT 1,      -- ★ 1=MANUAL 2=LEGACY_SUMSUB（迁移来的历史通过）
  country        varchar(2),
  first_name     varchar(64),
  last_name      varchar(64),
  id_type        smallint,                         -- 1=KTP 2=PASSPORT 3=KITAS 4=SIM
  id_number_enc  bytea,                            -- ★ 加密；1.0 明文存 verifications.number
  id_number_hash bytea,                            -- sha256(number||pepper)，查重用
  front_url      varchar(255),
  back_url       varchar(255),
  selfie_url     varchar(255),                     -- ★ 手持自拍，人工审核必需
  reviewer_id    bigint,
  reviewed_at    timestamptz,
  reject_code    smallint,                         -- ★ 结构化拒因；1.0 只有自由文本
  comment        text,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON user_kyc (id_number_hash) WHERE status = 3;  -- 1.0 靠一次 SELECT 查重，有竞态
CREATE INDEX ON user_kyc (status, created_at);                      -- 后台待审队列
```

#### 提现门槛：绑定 OTP，取代 1.0 的支付密码

**1.0 用支付密码（`PayPassword`）做提现的二次校验，而这个校验可以被完全跳过**（`services/point/pay.go:61`）：

```go
if user.PayPassword != "" {          // ← 没设支付密码的用户，这个 if 整个跳过
    if !user.CheckPayPassword(req.GetPassword()) { ... }
}
```

**没设支付密码 = 提现不做任何二次校验**。而支付密码本来就是可选的，所以这不是边缘情况。

2.0 改成**硬门槛**：

1. **提现前必须绑定 OTP**（TOTP，Google Authenticator 那种）。`otp_bound_at IS NULL` ⇒ 提现接口直接返回 `CODE_OTP_NOT_BOUND`，客户端据此跳绑定页。**没有「未设置就跳过」这个分支。**
2. **提现请求必须带 OTP 验证码**，服务端 `totp.Validate(code, otp_secret)` 通过才继续。
3. **2.0 没有「支付密码」这个概念**——不是"保留字段但不读"，是 `user_credentials` 里根本不建 `pay_password` 列，迁移也不复制。资金操作的二次确认只有一种：OTP。

   为什么连字段都不留：一个没有任何代码读的 bcrypt 凭据留在库里，不是资产是负债——它仍然可被拖库、仍然要进安全审计范围，却不提供任何保护。而且支付密码是用户高度复用的那类口令，少存一份就少一份撞库面。真要加回双因子，那时再加列比留着一列死数据干净。

**OTP 不迁移**——1.0 已绑定的用户在 2.0 要重新绑一次。三个理由：

- 1.0 的 `users.otp` 是**明文 TOTP 密钥**入库（坑位清单里单列了一条）。明文密钥搬过来等于把已泄露风险的凭据继续用下去；重新绑定等于强制轮换一次。
- 2.0 的 `otp_secret` 是**加密存储**（`bytea`），迁移要做一次明文→密文转换，而这批数据本来就该作废，转它没有价值。
- 迁移用户在 2.0 首次提现前本来就要走一遍流程（选解锁方案、KYC），顺带绑 OTP 不增加额外触点。

绑定流程照搬 1.0 的两步（`services/user/otp.go`）：`GenerateBindOtp` 返回密钥 + `otpauth://` URL（客户端渲染二维码），`BindOtp(code)` 校验通过才落库并置 `otp_bound_at`。后台保留 `ClearUserOtp`（用户换手机丢失 2FA 时人工解绑），**该操作必须进管理员操作日志**。

#### KYC 流程

流程 = `SubmitKyc`（图片走 OSS 上传口，照搬 `playd/upload.go`）→ PENDING（同事务写 `users.kyc_status`）→ 后台 `ReviewKyc(no, pass|reject, reject_code, comment)`。**本质是把 1.0 已有的人工路径**（`services/user/verification.go:21` + `services/man/verification.go:64`）**保留、把 Sumsub/Zoloz 全砍**。顺带消掉 1.0 的一个安全洞：Sumsub webhook 完全不验签（`services/user/sumsub.go`），知道 URL 的人可以把任意用户标成已实名。

### 4.3 资产 —— [改造]，全项目唯一的变动入口

```sql
CREATE TABLE user_assets (
  user_id    bigint   NOT NULL,
  currency   smallint NOT NULL,          -- 1=COIN(2.0通用币) 2=CONSUME(体验币)
  available  numeric(30,8) NOT NULL DEFAULT 0,
  frozen     numeric(30,8) NOT NULL DEFAULT 0,
  total_in   numeric(30,8) NOT NULL DEFAULT 0,
  total_out  numeric(30,8) NOT NULL DEFAULT 0,
  version    bigint NOT NULL DEFAULT 0,
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, currency),
  CHECK (available >= 0 AND frozen >= 0)   -- ★ 1.0 显式允许负余额，2.0 用约束兜底
);

CREATE TABLE asset_ledgers (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id    bigint NOT NULL,
  currency   smallint NOT NULL,
  biz_type   int NOT NULL,
  -- ★ 两个桶各记增量与前后值。1.0 只有 available 的 after，既对不上账，
  --   也表达不了「可用转冻结 / 冻结扣除 / 解冻」这三类只动 frozen 或同时动两桶的变动。
  available_delta  numeric(30,8) NOT NULL DEFAULT 0,   -- 有符号
  available_before numeric(30,8) NOT NULL,
  available_after  numeric(30,8) NOT NULL,
  frozen_delta     numeric(30,8) NOT NULL DEFAULT 0,   -- 有符号
  frozen_before    numeric(30,8) NOT NULL,
  frozen_after     numeric(30,8) NOT NULL,
  ref_type   smallint, ref_id bigint,
  idem_key   varchar(128) NOT NULL UNIQUE,-- ★ 幂等键，1.0 完全没有
  extras     jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  CHECK (available_after = available_before + available_delta),
  CHECK (frozen_after    = frozen_before    + frozen_delta),
  CHECK (available_delta <> 0 OR frozen_delta <> 0)     -- 不允许空流水
);
CREATE INDEX ON asset_ledgers (user_id, id DESC);
```

```go
// models/asset.go

// ApplyMutations 全项目【唯一】的资金变动入口。services/ 里禁止直接 UPDATE user_assets。
//
// 为什么必须收敛：1.0 有两套并存写法——结构体上读改写
// （user.AddAvailablePoint(x) 后 tx.NewUpdate().Model(user)，services/point/point.go:263）
// 与 SQL 内自增（Set("available_point = available_point + ?")，services/playlet/cron.go:1140）。
// 前者会把并发事务对 users 行其他列的修改一起覆盖掉，而两种写法有时出现在同一个文件里。
//
// 多用户变动时内部按 user_id 排序后再执行，固定加锁顺序防死锁——
// 1.0 的转账按请求顺序锁两个用户（services/point/point.go:241），A→B 与 B→A 并发即死锁。
func ApplyMutations(ctx context.Context, tx bun.IDB, ms ...Mutation) ([]AssetLedger, error)
```

#### 五种变动，方向由 Kind 决定

调用方**只说要干什么、给一个正数金额**，两个桶怎么动由 `Kind` 决定。这样"冻结时把 available 减错成加"这一类 bug 在入口就不存在：

```go
type MutationKind int8

const (
    MutationIncome       MutationKind = iota + 1 // available +A            入账：收益 / 充值 / 返佣
    MutationExpense                              // available -A            出账：购买 / 兑换
    MutationFreeze                               // available -A, frozen +A 冻结：提现申请
    MutationUnfreeze                             // frozen -A, available +A 解冻：提现驳回 / 撤单
    MutationSettleFrozen                         // frozen -A               冻结扣除：提现成功出款
)

type Mutation struct {
    UserId   int64
    Currency Currency
    Kind     MutationKind
    Amount   decimal.Decimal // ★ 恒为正数，方向由 Kind 决定，调用方不自己算符号
    BizType  int
    RefType  int8
    RefId    int64
    IdemKey  string
    Extras   map[string]any
}
```

核心 SQL——**余额校验写成 WHERE 条件，永不"先查再改"**。以 `MutationFreeze` 为例（`$a` 恒为正）：

```sql
UPDATE user_assets
   SET available = available - $a,
       frozen    = frozen    + $a,
       version   = version + 1, updated_at = now()
 WHERE user_id = $uid AND currency = $cur AND available >= $a
RETURNING available + $a AS available_before, available AS available_after,
          frozen    - $a AS frozen_before,    frozen    AS frozen_after;
-- 0 行 => ErrInsufficientFunds
```

`MutationSettleFrozen` 的守卫是 `frozen >= $a`，`MutationExpense` 是 `available >= $a`，`Income` / `Unfreeze` 不需要守卫（只增不减的那个桶）。`total_in` / `total_out` 只在**真正的出入账**上累加（`Income` / `Expense` / `SettleFrozen`），冻结与解冻是**桶间转移，不是出入账**，两个累计口径都不动——1.0 没有区分这一点，`total_out` 把提现申请也算进去了，一旦驳回就永远偏高。

前后值全部从 `RETURNING` 取，不从内存结构体算——1.0 的 `PointLedger.After` 是用读出来的旧结构体算的（`models/point.go:255`），只要有并发就是错的。

三条 `CHECK` 约束让「流水与余额对不上」在写入那一刻就失败，而不是等对账 cron 第二天发现。

### 4.4 等级 —— [新增]（团队等级改名 team_level，D6）

```sql
CREATE TABLE user_levels (
  user_id               bigint PRIMARY KEY,
  -- 团队等级 = 1.0 的 tips_level，语义完全一致，仅改名
  team_level            smallint NOT NULL DEFAULT 1,  -- ★ 顺序枚举值 0..8，见下方 D16
  team_level_at         timestamptz,
  team_level_floor      smallint NOT NULL DEFAULT 0,  -- ★ 迁移保底，只升不降
  highest_team_level    smallint NOT NULL DEFAULT 0,
  highest_team_level_at timestamptz,
  -- 个人 VIP 等级（2.0 新增，随活跃值升降，D7）
  vip_level             smallint NOT NULL DEFAULT 1,  -- LV1..LV7，纯数字不做枚举，档位由配置定
  vip_level_at          timestamptz,
  highest_vip_level     smallint NOT NULL DEFAULT 1,  -- ★ 历史最高，只升不降
  highest_vip_level_at  timestamptz,
  -- 度量
  personal_active_value numeric(30,8) NOT NULL DEFAULT 0,  -- ★ 恒等于生效中持仓本金合计，见下
  team_active_value     numeric(30,8) NOT NULL DEFAULT 0,  -- ★ 1..5 级下级，【不含自己】，增量维护
  fans_level_counts     int[] NOT NULL DEFAULT '{0,0,0,0,0,0,0,0}',  -- ★ 直属粉丝的等级分布，下标=team_level 枚举值
  team_qualified_count  int NOT NULL DEFAULT 0,            -- ★ 团队中达标人数（门槛见 level.team.qualified_level），增量维护
  active_holding_count  int NOT NULL DEFAULT 0,            -- ★ 生效中的机器人持仓数，判定只看 >0
  first_holding_at      timestamptz,
  dirty_at              timestamptz,                       -- ★ 持久化脏标记，队列丢了靠它兜底
  updated_at            timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON user_levels (dirty_at) WHERE dirty_at IS NOT NULL;
CREATE INDEX ON user_levels (team_level);
CREATE INDEX ON user_levels (vip_level);
```

#### 三个等级字段的语义分工

`current` / `floor` / `highest` 是三件不同的事，缺一个就会在某个场景上算错：

| 字段 | 含义 | 谁在用 |
|---|---|---|
| `team_level` / `vip_level` | **当前等级**，实时算出来的 | 返佣比例、周兑换额度、机器人购买门槛、APP 展示 |
| `team_level_floor` | **保底下界**，迁移带来的（D10） | `EvalTeamLevel` 结果的下界钳位 |
| `highest_team_level` / `highest_vip_level` | **历史最高**，只升不降 | **一次性奖励的"首次达标"判定** |

三者会同时分叉：一个迁移过来的 LV5 用户，凭本事升到 LV6 后又掉回来，此刻 `floor=LV5, highest=LV6, current=LV5`——三个值互不相等，各自都对。

**为什么 VIP 也要 `highest`**：D7 定了 VIP 随个人活跃值**会降级**（机器人到期返本时活跃值等额扣减），所以"当前"和"历史最高"必然分叉。而 PRD 明写「各等级专属福利将于后续公布」——只要出现任何一个按 VIP 等级发的**一次性**奖励，就必须拿"变更前的历史最高"做判定，否则用户降一级再升回来可以反复领。1.0 在团队等级上已经踩过这个坑并用 `highest_tips_level` 补住（`models/user.go:1821` 的 `oldHighestRank`），VIP 没有理由重蹈。

加一列的成本是零；事后补要从 `user_level_changes` 回溯扫全表，还得赌审计表从第一天就完整。

**为什么 VIP 不要 `floor`**：保底是迁移专属的历史包袱——1.0 的团队等级要带过来且只升不降。VIP 是 2.0 全新的，所有人（含迁移用户）一律从 LV1 起算（S4 阶段），没有任何需要托底的存量。

**为什么 `team_level` 是枚举而 `vip_level` 是纯数字**：团队等级的判定规则写死在 `EvalTeamLevel` 里，档位不会随便加，做成枚举能拿到类型安全和自解释的名字（D16）。VIP 档位完全由 `level.vip` 配置驱动、门槛和额度随时可调，将来加 LV8 只是配置改一行——做成 proto 枚举反而每次加档都要改协议、重新生成、发版本。所以 proto 里 `vip_level` 就是 `int32`。

```sql

-- [新增] 等级变更审计——1.0 完全没有，客服回答不了"我为什么掉级了"
CREATE TABLE user_level_changes (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id    bigint NOT NULL,
  kind       smallint NOT NULL,   -- 1=TEAM(团队) 2=VIP(个人)
  from_level smallint NOT NULL,
  to_level   smallint NOT NULL,
  reason     smallint NOT NULL,   -- 1=持仓变更 2=定时考核 3=迁移保底 4=后台调整 5=到期降级
  trigger_id bigint,
  snapshot   jsonb NOT NULL,      -- 判定当时的全部输入，可复现
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON user_level_changes (user_id, id DESC);
```

#### 团队等级枚举重排（D16）

1.0 的 `basepb.User_TipsLevel` 序号是乱的——`FREE=6, VIP=5, ONE=1, TWO=2, THREE=3, FOUR=4, FIVE=7, SIX=8`，**枚举值和等级高低毫无关系**。所以每一次比较都得先过 `models/user.go:745` 的 `tipsLevelMap` 换算成 rank：

```go
// 1.0：枚举值不能直接比，必须查表
var tipsLevelMap = map[basepb.User_TipsLevel]int{
    FREE: 0, VIP: 1, ONE: 2, TWO: 3, THREE: 4, FOUR: 5, FIVE: 6, SIX: 7,
}
if GetRankByTipsLevel(a) >= GetRankByTipsLevel(b) { ... }   // 漏一次就是错判
```

2.0 直接把枚举编成有序的，**顺序即大小**：

```proto
// api/base/v1/enum.proto
enum TeamLevel {
  TEAM_LEVEL_UNSPECIFIED = 0;  // 未指定：还没算过 / 无保底
  TEAM_LEVEL_FREE        = 1;  // 默认值，新用户初始档（= 1.0 的 FREE，rank 0）
  TEAM_LEVEL_1           = 2;  // = 1.0 VIP    (rank 1) —— APP 上显示的 LV1
  TEAM_LEVEL_2           = 3;  // = 1.0 ONE    (rank 2)
  TEAM_LEVEL_3           = 4;  // = 1.0 TWO    (rank 3)
  TEAM_LEVEL_4           = 5;  // = 1.0 THREE  (rank 4)
  TEAM_LEVEL_5           = 6;  // = 1.0 FOUR   (rank 5)
  TEAM_LEVEL_6           = 7;  // = 1.0 FIVE   (rank 6)
  TEAM_LEVEL_7           = 8;  // = 1.0 SIX    (rank 7)
}
```

三个连带好处：

1. **`tipsLevelMap` / `GetRankByTipsLevel` / `GetTipsLevelByRank` / `GetLevelsAboveEqual` 这一整套换算函数全部删除**，比较就是 `a >= b`。
2. **枚举名直接对上 APP 文案**：`TEAM_LEVEL_3` 就是界面上的 LV3，不用再在脑子里做「ONE 其实是 LV2」这种翻译——这个错位是 1.0 里最容易读错代码的地方。
3. **DB 存枚举值本身**（`smallint`，0..8），Go / proto / DB 三处同一个数，零转换。`UNSPECIFIED=0` 天然表示「没算过」，`team_level_floor` 默认 0 表示「2.0 原生用户，无保底」。

迁移换算（S4 阶段）：`team_level = tipsLevelMap[1.0 的 tips_level] + 1`。`EvalTeamLevel` 内部仍按 0..7 的 rank 运算（判定表照抄 1.0 更直观），只在读写边界 ±1。写一个显式的双向映射测试锁住这张对照表。

### 4.5 1.0 金库 —— [新增]，本次核心

```sql
CREATE TABLE legacy_vaults (
  user_id           bigint PRIMARY KEY,
  frozen_total      numeric(30,8) NOT NULL,          -- ★ 迁移快照，不可变，所有百分比的分母
  scheme            smallint NOT NULL DEFAULT 0,     -- 0=未选【永不解锁的稳定终态】 1=REGULAR(360天) 2=ACCELERATED(30天/期)
                                                   -- ★ 用户主动选；1→2 可升级（需重组资格），反向与重选一律拒绝
  scheme_chosen_at  timestamptz,
  restructure_right boolean NOT NULL DEFAULT false,  -- 重组资格：买入任意 2.0 产品后获得
  restructure_at    timestamptz,
  next_unlock_at    timestamptz,                     -- ★ 每人独立，可被加速事件调整
  unlock_count      int NOT NULL DEFAULT 0,          -- ★ 已解锁期数
  unlocked_total    numeric(30,8) NOT NULL DEFAULT 0,
  exchanged_total   numeric(30,8) NOT NULL DEFAULT 0,
  accelerated_days  int NOT NULL DEFAULT 0,          -- 累计加速天数（审计）
  completed_at      timestamptz,
  version           bigint NOT NULL DEFAULT 0,
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  CHECK (unlocked_total  <= frozen_total),
  CHECK (exchanged_total <= unlocked_total)
);
-- 部分索引：cron 只扫到期的，已完成 / 未选方案的行完全不进索引
CREATE INDEX ON legacy_vaults (next_unlock_at)
  WHERE completed_at IS NULL AND scheme <> 0 AND next_unlock_at IS NOT NULL;

-- (user_id, period_no) 唯一是整个引擎的幂等锚点
CREATE TABLE legacy_unlock_ledgers (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL,
  period_no   int NOT NULL,
  scheme      smallint NOT NULL,
  ratio       numeric(10,6) NOT NULL,
  amount      numeric(30,8) NOT NULL,
  due_at      timestamptz NOT NULL,      -- 原计划时间（审计：实际 vs 计划）
  unlocked_at timestamptz NOT NULL,
  source      smallint NOT NULL,         -- 1=CRON 2=EVENT_FULL 3=ADMIN
  UNIQUE (user_id, period_no)
);

-- 解锁时间调整（加速 / 延后），只追加不改历史
CREATE TABLE legacy_unlock_adjustments (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL,
  days       int NOT NULL,               -- 负=提前 正=延后
  reason     smallint NOT NULL,          -- 1=买入理财机器人 2=活动 3=后台
  ref_type   smallint, ref_id bigint,
  idem_key   varchar(128) NOT NULL UNIQUE,   -- 例："holding:<holding_id>"
  applied_at timestamptz,                -- NULL = 用户尚未选方案，挂起待兑现
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON legacy_unlock_adjustments (user_id) WHERE applied_at IS NULL;

CREATE TABLE legacy_exchanges (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL,
  amount          numeric(30,8) NOT NULL,
  week_start      date NOT NULL,
  vip_level       smallint NOT NULL,
  asset_ledger_id bigint NOT NULL,
  idem_key        varchar(128) NOT NULL UNIQUE,
  created_at      timestamptz NOT NULL DEFAULT now()
);

-- 周兑换额度：用计数表而不是对流水求和——"周中升 VIP 立即补足额度"
-- 需要记录【已授予】额度，纯 SUM 表达不了。
CREATE TABLE legacy_exchange_quotas (
  user_id    bigint NOT NULL,
  week_start date   NOT NULL,
  granted    numeric(30,8) NOT NULL,
  used       numeric(30,8) NOT NULL DEFAULT 0,
  vip_level  smallint NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, week_start),
  CHECK (used <= granted)
);
```

**唯一真源**：1.0 币**不在** `user_assets` 里开行。金库这一行就是余额：

- 仍锁定 = `frozen_total - unlocked_total`
- 已解锁未兑换 = `unlocked_total - exchanged_total`
- 1.0 总余额 = `frozen_total - exchanged_total`

两套表示必然漂移，一行不会。

### 4.6 机器人与持仓 —— [改造]

```sql
CREATE TABLE robots (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title           varchar(128) NOT NULL,
  avatar          varchar(255) NOT NULL DEFAULT '',  -- 唯一在用的展示图（在线 24/24 有值）
  fold_text       text,                              -- 折叠说明；1.0 唯一在用的富文本（12/108）
  category        varchar(32) NOT NULL DEFAULT '',   -- ★ 纯展示分区，运营自定义，代码不拿它分支（D18）
  status          smallint NOT NULL,
  is_trial        boolean NOT NULL DEFAULT false,    -- 体验版：不计入"买过几个机器人"这类统计（1.0 version_type=BETA）
  recommend_weight int NOT NULL DEFAULT 0,
  -- ★ 价格与期限（命名对齐 holdings，单位进名字）
  unit_price            numeric(20,8) NOT NULL,      -- 单份原价（1.0 support_amount）
  unit_price_discounted numeric(20,8),               -- 单份折后价；NULL = 不打折（1.0 price，0 当哨兵）
  duration_days         int NOT NULL,                -- 收益天数（1.0 income_duration）
  -- ★ 收益模型（需求 11）
  yield_mode      smallint NOT NULL DEFAULT 1,       -- 1=FIXED 2=LINEAR 3=SCHEDULE
  default_rate    numeric(10,6) NOT NULL DEFAULT 0,  -- FIXED / 越界兜底
  min_rate        numeric(10,6) NOT NULL DEFAULT 0,  -- LINEAR 起始日利率
  max_rate        numeric(10,6) NOT NULL DEFAULT 0,  -- LINEAR 封顶
  daily_increment numeric(10,6) NOT NULL DEFAULT 0,  -- LINEAR 每日递增
  rate_schedule   jsonb,                             -- SCHEDULE：["0.5","0.6",...] 下标=天-1
  -- ★ 行为开关：取代 1.0 的 8 种 robot_type（D18）
  settle_mode     smallint NOT NULL DEFAULT 1,       -- 1=CLAIMABLE 用户领取  2=FROZEN 到期解冻
  exclusive_scope smallint NOT NULL DEFAULT 0,       -- 0=不互斥 1=同机器人只能持一单 2=同 category 只能持一单
  buy_team_level  smallint,                          -- 非空 = 等级专属，且领取时校验等级仍匹配
  min_team_level  smallint,                          -- 购买门槛（所有机器人通用）
  -- ★ 三个上限：度量与作用域都写进名字，NULL 一律表示"不限"
  max_shares_per_user int,                           -- 每用户累计【份数】上限（1.0 buy_limit）
  max_orders_per_user int,                           -- 每用户累计【笔数】上限（1.0 run_limit）
  max_total_amount    numeric(30,8),                 -- 【全平台】累计支持金额上限（1.0 total_amount_limit）
  grants_restructure_right boolean NOT NULL DEFAULT true,  -- ★ 买入后授予重组资格
  unlock_accelerate_days   int     NOT NULL DEFAULT 0,     -- ★ 买入后解锁提前 N 天
  -- ★ 售卖窗口，成对命名
  sale_start_at   timestamptz,                       -- 非空 = 预售，到点才可买（1.0 on_sale_at）
  sale_end_at     timestamptz,                       -- 停售时间，到点即下架（1.0 last_buy_at，★名字纠错）
  created_at, updated_at timestamptz NOT NULL DEFAULT now()
);

-- [改造] 从 tips_ledgers；删掉 tips_method 判别器和全部短剧列
CREATE TABLE holdings (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  no              varchar(32) NOT NULL UNIQUE,
  user_id         bigint NOT NULL,
  robot_id        bigint NOT NULL,
  settle_mode     smallint NOT NULL,   -- ★ 购买时快照：1=CLAIMABLE 2=FROZEN。robot_type 已取消（D18）
  share           int NOT NULL,
  unit_price      numeric(20,8) NOT NULL,
  total_amount    numeric(30,8) NOT NULL,
  status          smallint NOT NULL,   -- 1=WAIT 2=EFFECTIVE 3=ENDED 4=REFUNDED
  -- ★ 收益模型快照：后台改机器人收益率不影响存量持仓
  yield_mode      smallint NOT NULL,
  default_rate, min_rate, max_rate, daily_increment numeric(10,6) NOT NULL,
  rate_schedule   jsonb,
  duration_days   int NOT NULL,        -- 与 robots.duration_days 同名
  income_start_at timestamptz NOT NULL,
  income_end_at   timestamptz NOT NULL,
  settled_days    int NOT NULL DEFAULT 0,   -- ★ 幂等推进游标
  total_income    numeric(30,8) NOT NULL DEFAULT 0,
  claimed_income  numeric(30,8) NOT NULL DEFAULT 0,
  ended_at        timestamptz,
  created_at, updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON holdings (status, income_end_at);
CREATE INDEX ON holdings (user_id, id DESC);

-- [改造] tips_income_ledgers + point_frozen_ledgers 合并
CREATE TABLE holding_incomes (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id bigint NOT NULL, holding_id bigint NOT NULL, robot_id bigint NOT NULL,
  day_index   int NOT NULL,
  rate        numeric(10,6) NOT NULL,
  principal   numeric(30,8) NOT NULL,
  amount      numeric(30,8) NOT NULL,
  frozen      boolean NOT NULL DEFAULT false,   -- 到期返 / AI 智投类
  unfreeze_at timestamptz, unfrozen_at timestamptz,
  settled_at  timestamptz NOT NULL DEFAULT now(),
  UNIQUE (holding_id, day_index)   -- ★ 唯一键直接杀掉"cron 跑两次=双倍发放"这一整类 bug
);

-- [改造] 活跃值流水，加幂等键
CREATE TABLE active_value_ledgers (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL, biz_type smallint NOT NULL,
  amount numeric(30,8) NOT NULL, after numeric(30,8) NOT NULL,
  ref_type smallint, ref_id bigint,
  idem_key varchar(128) NOT NULL UNIQUE,
  created_at timestamptz NOT NULL DEFAULT now()
);
```

#### 字段重命名与删减

1.0 的 `robots` 有三组命名问题，2.0 一并解决。

**① 三个上限，三个不同维度，名字一个都看不出来**

| 1.0 | 实际语义 | 代码 | 2.0 |
|---|---|---|---|
| `buy_limit` | **每用户**累计**份数**（`SUM(share)`） | `tips.go:3244` | `max_shares_per_user` |
| `run_limit` | **每用户**累计**笔数**（`COUNT(*)`） | `tips.go:3192` | `max_orders_per_user` |
| `total_amount_limit` | **全平台**累计**金额**（`SUM(total_amount)`） | `tips.go:3303` | `max_total_amount` |

前两个都是"每用户"却毫无共同前缀，第三个是"全平台"却和前两个长得像。**度量（份/笔/金额）与作用域（每用户/全平台）都必须写进名字**——这两组恰恰是 1.0 最容易读错的地方。

**② 「不限」的哨兵值不统一：`0` vs `-1`**

1.0 里 `buy_limit`/`run_limit` 用 `0` 表示不限，而 `total_amount_limit` 用 **`-1`**（DDL 是 `NOT NULL DEFAULT '-1'`）。谁按直觉填 `0`，就变成"上限 0 元、谁都买不了"。2.0 **三个统一用 `NULL` = 不限**，去掉 `NOT NULL`，语义唯一。

**③ `last_buy_at` 的名字是错的**

它是**停售截止时间**不是"最后一次被购买的时间"——查询条件是 `last_buy_at > now() OR IS NULL`（`tips.go:2045/3137/4507/4592`），还没到点才可买。它和 `on_sale_at` 本该成对，现在完全不对仗。2.0 改成 `sale_start_at` / `sale_end_at`。

**其余改名**

| 1.0 | 2.0 | 理由 |
|---|---|---|
| `income_duration` | `duration_days` | 单位进名字 |
| `support_amount` | `unit_price` | 单份原价，且与 `holdings.unit_price` 同名 |
| `price` | `unit_price_discounted` | 单份折后价；1.0 用 `0` 当"不打折"哨兵，2.0 用 `NULL` |

`unit_price` / `unit_price_discounted` 的关系要在字段注释里写死：**活跃值按原价算、扣款按折后算**——这个区别正是 1.0 活跃值 bug 的源头（坑位清单里单列了一条）。

**删掉的字段（生产数据为据，2026-07-28 快照 108 行 / 在线 24 行）**

| 字段 | 全表非空 | 在线非空 | 处置 |
|---|---|---|---|
| `background_image` / `button_image` / `details` / `intro` / `fold_title` | **0/108** | 0/24 | **全删**——从未被填过，搬过去只是让后台表单多五个永远空着的输入框 |
| `avatar` | 105/108 | **24/24** | **保留**，唯一在用的展示图 |
| `fold_text` | 12/108 | 9/24 | 保留 |
| `is_occupy_tips` | 1/16 | — | 删（2.0 无短剧支持，概念不存在） |
| `robot_type` | — | — | 删（D18） |
| `supporting_user_count` / `total_support_user_count` | — | — | 删——1.0 自己在 `models/robot.go:19-22` 标了「废弃(不准确)」 |
| `supporting_coin_amount` / `total_coin_amount` | 82/108 | 14/24 | **删**——1.0 注释写了「**可能包含泡沫用户数据**」（`models/robot.go:22-25`），和上面两个是同一类不可信的增量计数器。要统计就从 `holdings` 聚合，或按 `user_levels` 那套真正做增量维护 + 夜间对账，不要再留一份没人敢信的数 |

**`max_orders_per_user` 的生效条件要重新定义**：1.0 的 `run_limit` 只对 `REPURCHASE` / `AI_INVEST` 两种类型生效（`tips.go:3192` 的 `robot.Type ==` 判断）。D18 取消 `robot_type` 后这个 gate 没了依据——2.0 改成**只要 `max_orders_per_user IS NOT NULL` 就生效**，对所有机器人一致。这与"行为由列决定、不由类型决定"是同一条原则。

**`version_type` → `is_trial boolean`**：1.0 的 `Robot_VersionType` 只有 BETA / STANDARD 两个取值（全表 3 个 BETA、105 个 STANDARD），唯一用途是统计用户买过几个机器人时排除体验版（`tips.go:3406` 的 `WHERE r.version_type != BETA`）。二值的东西不必用枚举，且 `version_type` 这名字看不出是干什么的。2.0 用 `is_trial`，那句查询变成 `WHERE NOT r.is_trial`。

体验版的语义要在字段注释里写死：**体验版机器人不计入任何"买过几个机器人"的统计口径**。1.0 只在一处做了这个排除，2.0 新增同类统计时必须一并排除——否则运营上一个体验版机器人就会把某些门槛判定意外打通。

### 4.7 短剧 —— [照搬]

`models/playlet.go` 直接映射：`Playlet→dramas`、`PlayletEpisode→drama_episodes`、`PlayletGroup→drama_groups`、`UserPlayletLedger→user_drama_ledgers`、`UserPlayletCollect→user_drama_collects`、`UserPlayletEpisode→user_drama_episodes`、`AdRecord`、`Subtitle`、`PlayletMaterial→drama_materials`、`PlayletView→drama_daily_views`，加 `models/chat.go` 的弹幕与敏感词。阿里云 VOD 能力保留但**不再有 `internal/client/ali` 这个包**（§5.10）。

只做三处优化：
1. **删掉全部 tips 列**（`IsTips` `TipsDate` `TipsIncomeStartAt/EndAt` `TipsNum` `TipsTotal` `TipsMaterials` `TipsAmtMaxLimit` `TipsType` `IsTipsTop`）——支持短剧功能取消。连带删除 `playlet_income_ledgers`（短剧收益率设置）、`playlet_tips_shelf_ledgers`（支持上架记录）。
   ⚠️ **`playlet_views` 不在此列**——它是短剧**播放量配置**（`models/tips.go:328`，`playlet_id + date + views` 唯一键，后台 `UpdatePlayletView` / `ListPlayletView` 维护，前台按 `SUM(views) WHERE date <= today` 展示总播放量），和支持功能无关，照搬为 `drama_daily_views`。
2. `user_drama_episodes` 加复合索引 `(user_id, drama_id, episode_no)`。
3. `user_drama_ledgers`（观看历史，最大表）按月做 range 分区。

### 4.8 configs —— [照搬] 表形，[新增] 注册表

```sql
CREATE TABLE configs (
  key        varchar(128) PRIMARY KEY,
  value      text NOT NULL,
  version    bigint NOT NULL DEFAULT 0,   -- ★ 缓存自愈
  updated_by bigint,
  updated_at timestamptz NOT NULL DEFAULT now()
);
```

`tips_param` / `wealth_param` 两张表在 2.0 **不存在**。详见 §5.5。

---

## 五、核心机制

### 5.1 ID 策略

- **迁移用户**：`users.id` = 1.0 的 `user_id` 原值，7–8 位随机数照原样带过来。CS 工具、APP 展示、邀请码全部不变。
- **2.0 新用户：随机 9 位**（D11）。`users.id` 是对外 ID，**不能自增**——自增会把注册量白送出去（今天的 max 减昨天的），也让整个用户空间可被顺序遍历。

实现方式**照搬 `../sp` 的 `next_user_id()`**——数据库侧的**随机步长序列**，比"随机取值 + 撞了重试"更干净：

```sql
-- 起点随机，每次自增一个 1..64 的随机步长。
-- 单调递增但不连续：既拿不到注册量（步长随机，max 差值说明不了什么），
-- 也无法枚举（99% 的相邻整数是空号），同时【不需要任何重试】——
-- 序列天然唯一，不存在 1.0 genUniqueUserId (services/user/user.go:2056) 那种
-- "先 SELECT 5 个候选再 INSERT" 的 TOCTOU。
CREATE SEQUENCE users_id_seq START 100000000;
CREATE FUNCTION next_user_id() RETURNS bigint AS $$
  SELECT nextval('users_id_seq') + (floor(random()*64)::bigint);
$$ LANGUAGE sql;
```

（sp 的实际起点是 17780457；shortmax 取 1 亿以上，理由见上：避开 1.0 的 8 位空间。）

- ID 仍**单调递增**，所以可以直接当注册先后的排序键和游标键用——这是纯随机 ID 拿不到的好处。
- 消耗速度是用户数的约 32 倍，bigint 空间完全够。
- ⚠️ **随机 ID 只是提高门槛，不是权限**。凡是"按 id 查他人数据"的接口仍必须做归属校验（走 §4.1 的 `VisibleUserScope`）。1.0 有多处只靠 ID 不可猜。
- **所有表的用户外键统一引用 `users.id`**（列名仍叫 `user_id`）。1.0 的 `join:user_id=user_id` 双 ID 写法全部消失。

### 5.2 时间口径

**不自建 `timex` 包**，直接用 `github.com/jinzhu/now`（1.0 的 `go.mod` 里本来就有 `v1.1.5`），配两处全局设定即可：

```go
// internal/boot 或各进程装配层最早一步

// 全站默认时区 = 印尼 UTC+7，不是 UTC。
// 设了它之后 time.Now() 就带 +07:00，now.With(...) 的一切边界计算自然落在业务时区，
// 业务代码里不再出现任何 LoadLocation / In(loc)。
time.Local = must(time.LoadLocation(viper.GetString("business.timezone")))  // Asia/Jakarta

// jinzhu/now 默认把周日当周首，与运营口径的周一不一致，必须显式改。
now.WeekStartDay = time.Monday
```

用法就是标准写法，不包一层：

```go
today     := now.With(t).BeginOfDay()     // 业务自然日零点
weekStart := now.With(t).BeginOfWeek()    // 自然周周一零点（WeekStartDay 生效）
monthEnd  := now.With(t).EndOfMonth()
```

⚠️ **DB 连接串的 timezone 也要设成 `Asia/Jakarta`**（见 §3.4），否则 Go 侧是 +07、PG 侧是 UTC，`date` 类型的自然日会差一天。周额度表的 `week_start date` 正是这么被坑的高危字段。

⚠️ **禁止 `time.Truncate(24*time.Hour)` 做日截断**——它按 UTC 绝对秒对齐，在 UTC+7 会截到前一天 17:00。1.0 的 `services/point/point.go:216` 就是这么写的，而且同一行还用 `now.Weekday()` 把周日当周首，两个 bug 叠在一起。

周兑换额度周期 = **自然周，周一 00:00 Asia/Jakarta**（即 `now.With(t).BeginOfWeek()`）。选自然周而非滚动 7 天，是因为「周中升 VIP 立即补足额度」需要一个可存储的「已授予额度」，滚动窗口表达不了。

### 5.3 1.0 金库：解锁 / 兑换引擎 ★

#### 状态机

**解锁必须由用户主动选择，不选就永远不解锁**——`scheme = 0` 是一个稳定终态，不倒计时、不会被任何后台任务推进。这是有意的：1.0 资产的释放是一次需要用户明确知情并确认的动作，不能"默默开始倒计时"。

**能选哪个方案由重组资格决定**：

```
INIT (scheme=0, next_unlock_at=NULL)          迁移落地即此状态；不选 = 永久冻结
 │
 ├── ChooseScheme(REGULAR)                    任何人都能选
 │     next_unlock_at = now + cfg.regular.wait_days - 挂起的加速天数
 │     比例表 = cfg.regular.ratios              ← 默认 [1.00]，到点一次解 100%
 │     ↓
 │   REGULAR ──（之后买了机器人拿到重组资格）──► 可再调一次接口【升级】到 ACCELERATED
 │
 └── ChooseScheme(ACCELERATED)                ★ 前置：restructure_right = true
       next_unlock_at = now + cfg.accelerated.first_wait_days - 挂起的加速天数
       比例 = cfg.accelerated.{first_ratio, step_ratio}   ← 默认首期 5%，之后每期 10%
 │
 ▼
ACTIVE ──(cron: next_unlock_at <= now)──► 推进一期
 │        period_no = unlock_count + 1
 │        amount = min(frozen_total × cfg.ratio(period_no), frozen_total - unlocked_total)
 │        next_unlock_at += cfg.accelerated.period_days
 │                        ← ★ 基于【计划时间】累加，不是 now()+N；配置实时读
 │
 ├──(ApplyUnlockAdjustment: next_unlock_at -= X 天)── 状态不变，下一 tick 自然生效
 ├──(UnlockAllNow 活动)── amount = frozen_total - unlocked_total，source=EVENT_FULL
 │
 ▼
COMPLETED (unlocked_total = frozen_total, completed_at 置位, next_unlock_at=NULL)
```

##### 允许 REGULAR → ACCELERATED 升级，且只升不降

没有持仓的用户只能先选 360 天；等他买了机器人拿到重组资格，**应该允许他再调一次接口切到加速方案**——否则"先选了普通方案"就成了永久惩罚，用户会倾向于不选、观望，与"主动选择"的产品意图相反。

升级时 `next_unlock_at` **取两者较早**：

```sql
UPDATE legacy_vaults
   SET scheme = 2,
       next_unlock_at = LEAST(next_unlock_at, now() + ($2 || ' days')::interval)  -- $2 = cfg.accelerated.first_wait_days
 WHERE user_id = $1 AND scheme = 1 AND restructure_right = true;   -- 0 行 = 无资格 或 状态不对
```

取 `LEAST` 的原因：用户在第 350 天才升级，原本还剩 10 天全额解锁，直接改成 `now + first_wait_days` 反而**推迟 20 天**。升级动作不该让人变慢。

反向（ACCELERATED → REGULAR）和重复选择一律拒绝，全部写成 SQL 的 `WHERE` 守卫而不是 Go 里的 if：

```sql
UPDATE legacy_vaults SET scheme = $s, scheme_chosen_at = now(), next_unlock_at = $t
 WHERE user_id = $1 AND scheme = 0;      -- 首次选择：0 行 = 已选过
```

##### 资格口径：用「重组资格」，不用「当前是否持有」

`restructure_right` 一旦为 true 就**永久有效**（首次买入时置位）。不采用"当前有生效持仓"作为判定，两个原因：

1. 买了 1 天期机器人的用户，持仓到期后就失去选加速的机会——同样付了钱，结果取决于他哪天来点这个按钮，说不通。
2. 判定挂在"当前持有"上会制造时间博弈：用户得掐着持仓没到期的窗口去选方案。资格类的判定应该是单调的。

这也和需求原文一致：**「你支持了 2.0 产品拿到了重组资格，可以选择加速释放方案」**——资格来自"支持过"这个事实，不是"正在支持"这个状态。

##### 首次买入只做两件事，**不自动切方案**

`user_levels.first_holding_at` 由 NULL 首次置位这一刻，与**授予重组资格**是同一个事件、同一个事务：

1. `first_holding_at = now()`（仅当原值为 NULL）
2. `restructure_right = true`

**买入不会自动改 `scheme`**——它只是把"能选加速"这张票发给用户，切不切由用户自己再点一次。这是"方案必须主动选择"的直接推论：系统不替用户做解锁相关的决定。

#### 选择方案接口

```protobuf
// api/public/legacy/v1/vault.proto —— 全部 POST + body:"*"（§3.1）

service VaultService {
  // GetVault 返回金库状态 + 【当前可选哪些方案】，客户端据此渲染按钮
  rpc GetVault(GetVaultRequest) returns (GetVaultResponse);
  // ChooseScheme 选择 / 升级解锁方案。不可逆，服务端用 WHERE 守卫兜底
  rpc ChooseScheme(ChooseSchemeRequest) returns (ChooseSchemeResponse);
}

message GetVaultResponse {
  string frozen_total     = 1;  // 冻结总额
  string unlocked_total   = 2;  // 已解锁累计
  string unlockable_now   = 3;  // 当前可兑换额度（受 VIP 周额度约束）
  Scheme scheme           = 4;  // 0=未选
  int64  next_unlock_at   = 5;  // 未选时为 0
  int32  unlock_count     = 6;  // 已解锁期数
  bool   restructure_right= 7;  // 是否已有重组资格
  repeated Scheme available_schemes = 8;  // ★ 服务端算好，客户端不要自己判断资格
  int32  pending_accelerate_days    = 9;  // 已挂起、待方案生效时兑现的加速天数
}

message ChooseSchemeRequest { Scheme scheme = 1; }
```

`available_schemes` 由服务端计算，客户端**不要自己判断资格**——同一份规则在两端各写一遍，迟早对不上：

| 当前 `scheme` | `restructure_right` | `available_schemes` |
|---|---|---|
| 0 未选 | false | `[REGULAR]` |
| 0 未选 | true | `[REGULAR, ACCELERATED]` |
| REGULAR | false | `[]`（已选，且还没资格升级） |
| REGULAR | true | `[ACCELERATED]` ← 升级路径 |
| ACCELERATED | — | `[]` |

`ChooseScheme` 的错误码：`CODE_SCHEME_ALREADY_CHOSEN`（重复选 / 反向降级）、`CODE_RESTRUCTURE_RIGHT_REQUIRED`（无资格选加速）。两者都要在 `available_schemes` 里就避免掉，服务端的 `WHERE` 守卫只是并发兜底——**判断做两遍是对的，一遍给 UI 用，一遍保证正确性**。

**`next_unlock_at += period_days` 而不是 `now() + period_days`** 是关键：cron 停两天后用 `now()` 推进，会把该用户之后每一期都永久后移两天，同批用户的节奏就散了。

#### 推进方式：cron 为主，读接口只入队不计算

`services/legacy/cron.go`，注册在 worker 进程，`*/5 * * * *`：

```sql
-- 批量取到期的（命中部分索引）
SELECT user_id FROM legacy_vaults
 WHERE completed_at IS NULL AND scheme <> 0 AND next_unlock_at <= now()
 ORDER BY next_unlock_at LIMIT 500;

-- 每人一个事务，循环追赶（停机后可能欠多期）
BEGIN;
SELECT * FROM legacy_vaults WHERE user_id=$1 FOR UPDATE;
INSERT INTO legacy_unlock_ledgers (...) VALUES (...)
  ON CONFLICT (user_id, period_no) DO NOTHING;      -- 0 行 => 别人已处理，break
UPDATE legacy_vaults
   SET unlock_count   = unlock_count + 1,
       unlocked_total = unlocked_total + $amt,
       next_unlock_at = next_unlock_at + ($period || ' days')::interval,   -- ★ 每 tick 从 configs 读一次，全批共用
       completed_at   = CASE WHEN unlocked_total + $amt >= frozen_total THEN now() END,
       version = version + 1, updated_at = now()
 WHERE user_id = $1;
COMMIT;
```

worker 是单副本，但**幂等仍然靠 `(user_id, period_no)` 唯一键**——部署约定会变，唯一键不会。

**读路径兜底**：`GetLegacyVault` 发现 `next_unlock_at <= now()` 时，只做一次 `jobqueue.Publish("legacy.unlock", uid)` 让 worker 在 1 秒内捡起来。**读接口绝不写金库表。**

#### 配置（key `legacy.unlock`）

```json
{
  "regular":     {"wait_days": 360, "ratios": ["1.0"]},          // 选方案起算，未买入的兜底路径
  "accelerated": {"first_wait_days": 30, "period_days": 30,      // 首次买入后转入，30 天一期
                  "first_ratio": "0.05", "step_ratio": "0.10"},
  "min_unlock_amount": "0.00000001"
}
```

`ratio(1)=5%`、`ratio(n>1)=10%`，累计到第 11 期达 100%，由 `min(..., frozen_total - unlocked_total)` 的**钳位**保证终止——**不是靠配置刚好加起来等于 1**，所以运营填错比例不会把引擎卡死。

##### 配置实时读，改了立刻对全部存量用户生效

引擎**不快照**这份配置：`ChooseScheme` 算首次 `next_unlock_at` 时读一次，cron 每个 tick 读一次（一次读、整批共用），加速调整同理。所以改配置的效果是**立即、全局**的。

这是有意的——运营需要能一把调整所有人的节奏，而这件事用别的手段都做不到：`ApplyUnlockAdjustment` 只能**按人减天数**，`UnlockAllNow` 只能**一次性全解**，两者都改不了「比例」。典型场景：

| 想要的效果 | 怎么做 |
|---|---|
| **所有人下一期直接解 100%** | `step_ratio` 与 `first_ratio` 都改成 `"1.0"` |
| 所有人节奏从 30 天缩到 15 天 | `period_days: 15`（已排期的 `next_unlock_at` 不变，从下一期开始按 15 天走） |
| 只加速某些人 | `ApplyUnlockAdjustment`（按人减天数，留痕） |
| 立刻全额释放某批人 | `UnlockLegacyAll`（按人发任务，留痕） |

「下一期解 100%」能直接用比例表实现，靠的正是上面那个**钳位**：`ratio = 1.0` 时 `frozen_total × 1.0` 被 `min(..., frozen_total - unlocked_total)` 夹成"剩余全部"，已经解过几期的用户也正好收口。**不需要为这个场景单开一个开关。**

代价要认下来并在后台配置页写清楚：

- **改配置会即刻改变所有存量用户的剩余计划**，包括已经领了几期的人。配置页必须有明确警示 + 二次确认，不能像改个普通参数一样随手保存。
- **改动必须留痕**：谁改的、改前改后、生效时间，进管理员操作日志。这是唯一能事后解释"为什么这批用户的解锁节奏变了"的依据——因为用户行里不存快照。
- `period_days` 改小**不会**追溯已排期的 `next_unlock_at`（那是行上的绝对时间），只影响之后的累加。要立刻提前得配合 `ApplyUnlockAdjustment`。

#### 加速事件

```go
// ApplyUnlockAdjustment 提前 / 延后指定用户的下次解锁时间。
// idemKey 必填（例 "holding:<holding_id>"），同来源重复调用只生效一次。
// 用户还没选方案时，调整记录 applied_at=NULL 挂起，在 ChooseScheme 时一次性兑现——
// 否则"先买入机器人再选方案"的用户会静默丢掉加速。
func ApplyUnlockAdjustment(ctx context.Context, tx bun.IDB,
    userId int64, days int, reason AdjustReason, refType RefType, refId int64, idemKey string) error
```

`CreateHolding` 在同一事务里调用它（当 `robots.unlock_accelerate_days > 0`）。同一事务里还要做**首次买入**的三件事（见状态机的「首次买入判定口径」）：

```sql
-- ① 授予重组资格（幂等：只有 false→true 这一次会命中）
UPDATE legacy_vaults
   SET restructure_right = true, restructure_at = COALESCE(restructure_at, now())
 WHERE user_id = $1 AND restructure_right = false;

-- ② 首次买入时间（仅当原值为 NULL）
UPDATE user_levels SET first_holding_at = now()
 WHERE user_id = $1 AND first_holding_at IS NULL;

```

两条都是**幂等的条件更新**，第二次买入全部 0 行，不需要在 Go 里判断"是不是第一次"。

⚠️ **买入到此为止，不碰 `scheme`**——切方案是用户在 `ChooseScheme` 里自己做的决定。

若加速后 `next_unlock_at` 落到过去，下一个 cron tick 自然解锁——**不需要任何特殊分支**。

#### 全量当日解锁活动

后台 RPC `admin/legacy/v1.UnlockLegacyAll{user_ids[] | all, reason}`：把命中的未完成用户逐个 `jobqueue.Publish("legacy.unlock_full", uid)`，worker 按 `period_no = unlock_count+1`、`amount = frozen_total - unlocked_total`、`source=EVENT_FULL` 处理，置 `completed_at`。**不做整表 UPDATE**——那会长时间锁表且一条审计都不留。

#### 兑换（1.0 → 2.0，1:1 单向）

上限 = `min(已解锁未兑换, 本周剩余额度, 1.0 可用余额)`。**三个上限全部写成 UPDATE 的 WHERE 条件，不做"先查再改"**——并发下先查再改必然超发。

一个事务，固定加锁顺序（金库 → 额度 → 资产）：

```sql
BEGIN;
-- 0) 幂等：idem_key = <uid>:<req.request_id>，命中直接返回旧结果

-- 1) 金库：扣可兑换额，条件即校验
UPDATE legacy_vaults SET exchanged_total = exchanged_total + $amt, version = version+1
 WHERE user_id=$uid AND unlocked_total - exchanged_total >= $amt;
-- 0 行 => CODE_LEGACY_UNLOCKED_NOT_ENOUGH

-- 2) 本周额度：upsert 授予（GREATEST 就是"周中升 VIP 立即补足"），再扣减
INSERT INTO legacy_exchange_quotas (user_id, week_start, granted, used, vip_level)
VALUES ($uid, $week, $quotaOf(vip), 0, $vip)
ON CONFLICT (user_id, week_start) DO UPDATE
   SET granted   = GREATEST(legacy_exchange_quotas.granted,   EXCLUDED.granted),
       vip_level = GREATEST(legacy_exchange_quotas.vip_level, EXCLUDED.vip_level);

UPDATE legacy_exchange_quotas SET used = used + $amt
 WHERE user_id=$uid AND week_start=$week AND granted - used >= $amt;
-- 0 行 => CODE_LEGACY_WEEKLY_QUOTA_EXCEEDED

-- 3) 2.0 币入账：ApplyMutations(tx, Mutation{COIN, +amt, BIZ_LEGACY_EXCHANGE, idem_key})
-- 4) INSERT legacy_exchanges
COMMIT;
```

`GREATEST` 的 upsert **本身就是**中途升级补额度的规则，不需要单独的补额任务；等级 worker 提升 `vip_level` 时也跑同一句 upsert，用户升级后立刻就能看到新额度。因为是 `GREATEST`，**VIP 降级（D7）永远不会回收已授予的当周额度**。

VIP 档位配置（key `level.vip`，随时可调）：

```json
{"tiers":[
 {"level":1,"min_active_value":"0",    "weekly_quota":"5"},
 {"level":2,"min_active_value":"50",   "weekly_quota":"10"},
 {"level":3,"min_active_value":"250",  "weekly_quota":"50"},
 {"level":4,"min_active_value":"500",  "weekly_quota":"125"},
 {"level":5,"min_active_value":"1000", "weekly_quota":"250"},
 {"level":6,"min_active_value":"5000", "weekly_quota":"750"},
 {"level":7,"min_active_value":"10000","weekly_quota":"2500"}]}
```

VIP 判定是纯查表：`vip = max{tier.level | personal_active_value >= tier.min_active_value}`。活跃值降（机器人到期本金返还时活跃值等额扣减）则 VIP 跟着降，与 `team_level` 的保底规则**互不影响**——保底只作用于团队等级。

### 5.4 队列驱动的等级重算

#### 队列选型

用 **`forge/jobqueue`**（Redis LIST + BRPOP，at-most-once）+ **两道自建闸门**：

1. **去重闸门**：生产端 `SET shortmax:lvl:pending:{uid} 1 NX EX <debounce>`，成功才 `Publish`。窗口内后续事件全部被吸收，worker 处理完 `DEL`。这就是收敛扇出风暴的关键——100 个粉丝在窗口内支持同一上级，只产生 **1 次**祖先重算。
2. **持久兜底**：任何改动 `personal_active_value` / `active_holding_count` / 下级等级的事务，都在**同一事务**里给受影响用户置 `user_levels.dirty_at = now()`。worker 只在 `dirty_at <= jobStartedAt` 时清除（按时间 CAS，job 执行期间新到的标记不丢）。一个 60s 的 sweep cron 扫 `WHERE dirty_at IS NOT NULL LIMIT 500` 重新入队。

**Redis 只是加速器，Postgres 才是真源**——这样 jobqueue 的 at-most-once 语义就够用了，不需要上 `reliablequeue` 的 Streams 复杂度。（金库解锁同理：`legacy_vaults.next_unlock_at` 就是持久待办。）

#### Redis 契约（`internal/rediskey/level.go`）

| 用途 | 结构 | key | 谁写 / 谁读 / 丢失后果 |
|---|---|---|---|
| 重算队列 | LIST | `shortmax:jq:level.recalc` | api+man 写、worker 读 / 丢了靠 `dirty_at` sweep 恢复 |
| 去重闸门 | String | `shortmax:lvl:pending:{uid}` | 生产端 SETNX、worker DEL / TTL=防抖窗口 / 丢了只是多算几次 |
| 单用户串行锁 | `forge/lock` | `shortmax:lvl:{uid}` | worker，TTL 15s 自动续租 / 丢了并发重算同一人，结果一致只是浪费 |
| 等级变更事件 | LIST | `shortmax:jq:level.changed` | worker 写、activity 读 / 丢了活动奖励漏发，需告警 |

#### 扇出收敛

1.0 的 `CheckUpperSupportLevel`（`models/user.go:1556`）在 RPC 里同步重算自己 + 5 个祖先 = 6×4 条重聚合，调用点有 7 处。

2.0：

1. RPC 事务提交后，**只入队本人**（payload 带 `fanout=true`）。
2. worker 从本人行取 `inviter_ids[1:5]`（≤5 个 ID，读自己一行），重算自己后把 5 个祖先入队（不带 fanout）。
3. 去重闸门收敛掉重复。

配合增量维护的团队聚合（§4.1），单次重算从 4 条重聚合降到 2 条主键读。

**团队活跃值增量传播**：用户 X 的个人活跃值变化 Δ 时，同事务内

```sql
UPDATE user_levels SET team_active_value = team_active_value + $delta   -- 只加到 1..5 级祖先，不含本人
 WHERE user_id = ANY($ancestors_including_self);   -- ≤6 个 ID，走主键
```

夜间对账 cron 用 GIN 全量重算比对，**发现漂移只告警不静默修复**——静默修复会把造成漂移的 bug 一起藏起来。

#### 算法逐条对齐

把判定段从 `models/user.go:1586-2204` 抽成**纯函数**，DB 读集中在 `collect` 步骤，判定不碰 DB：

```go
// services/level/rule.go

// TeamLevelInput 团队等级评定的全部输入。口径的唯一来源。
type TeamLevelInput struct {
    CurrentRank         int             // 现等级 rank 0..7（= TeamLevel 枚举值 - 1，见 D16）
    LevelFloorRank      int             // ★ 2.0 新增：迁移保底
    PersonalActiveValue decimal.Decimal
    TeamActiveValue     decimal.Decimal // ★ 1..5 级下级，【不含自己】
    ActiveHoldingCount  int             // ★ 生效中的机器人持仓数，只判 >0（对应 1.0 的 playlets）
    FansLevelCounts     []int           // ★ 直属粉丝等级分布，下标=team_level 枚举值（门槛是动态的，见下）
    TeamQualifiedCount  int             // 1..5 级下级中 >= qualified_level 的人数
}

// EvalTeamLevel 纯函数，与 1.0 checkSupportLevel 的判定段逐条对齐。
//
// ★ 判定顺序有副作用：后面的 if 会覆盖前面的结果。照抄时不要"顺手整理"成
// switch 或提前 return——那会改变结果。改本函数必须同步改 rule_test.go 的黄金用例。
func EvalTeamLevel(in TeamLevelInput, cfg TeamLevelConfig) int
```

##### 粉丝 / 团队的范围与「含不含自己」

产品口径：**第 1 级下级 = 粉丝，第 1..5 级下级 = 团队**。2.0 的字段前缀严格照这个分：

| 前缀 | 范围 | 含自己 |
|---|---|---|
| `personal_*` | 自己 | — |
| `fans_*` | 第 1 级下级 | 否 |
| `team_*` | 第 1..5 级下级 | **否** |

⚠️ 这里改了 1.0 的一处**存储**口径（判定结果不变）：1.0 的 `teamActiveValue` 是 `SUM(1..5 级) + 自己`（`models/user.go:1179` 显式 `.Add(u.ActiveValue)`），而人数 `totalAboveLv2` 不含自己——**两个 `team_*` 口径不一致，名字上完全看不出来**。2.0 统一成「`team_*` 一律不含自己」，判定时显式相加：

```go
totalActiveValue := in.PersonalActiveValue.Add(in.TeamActiveValue)   // 等价于 1.0 的 teamActiveValue
```

阈值一个都不动，判定结果逐人一致（P3 的全量 diff 会验证）。好处是读判定代码时能看见「我的贡献 + 团队的贡献」，而不是一个隐含含自己的字段。

⚠️ 另注意 1.0 代码里的「粉丝」指的是 **1-5 层**（`inviter_fans` 是 5 级物化路径，注释「统计粉丝（1-5层）」），第 1 级反而叫「铁粉」——和产品定义正好错位。2.0 已经废掉 `inviter_fans` 数组（换 `inviter_ids` + `referral_edges`），那套用词随数组一起消失。

##### 为什么粉丝存的是「等级分布」而不是一个计数

1.0 的直属人数判定长这样（`models/user.go:1671`）：

```go
if GetRankByTipsLevel(tipsLevel) >= GetRankByTipsLevel(TWO) {
    if sumLevelAbove(tipsLevel) < Lv2InLv3 || teamActiveValue < TwoTeamActiveValue {
```

`sumLevelAbove` 传的是 **`tipsLevel`——我当前的等级**，不是固定的 LV2。所以规则是「直属粉丝中 **≥ 我当前等级** 的人数」，**门槛跟着自己的等级浮动**。

所以一个 `direct_lv2_count` 的 int **算不出这个数**——我 LV3 时要数 ≥LV3 的，LV5 时要数 ≥LV5 的。必须存**按等级的人数分布**，判定时按当前等级求后缀和：

```go
// 直属中 >= level 的人数
func (in TeamLevelInput) fansAtOrAbove(level int) int {
    n := 0
    for lv := level; lv < len(in.FansLevelCounts); lv++ { n += in.FansLevelCounts[lv] }
    return n
}
```

用 `int[]` 而不是 8 个定长列或 jsonb：一列、下标直接是 `team_level` 枚举值（D16 让它有序了，所以「≥X」就是数组切片）、增量维护是原地元素更新且原子：

```sql
-- 某个直属粉丝从 LV2(枚举3) 升到 LV3(枚举4)
UPDATE user_levels
   SET fans_level_counts[3] = fans_level_counts[3] - 1,
       fans_level_counts[4] = fans_level_counts[4] + 1
 WHERE user_id = $直属上级;
```

这一步就挂在已有的 `level.changed` 事件消费里——等级变更本来就要通知上级重算，顺手改一下父节点的桶即可。

**团队那个计数则是固定门槛**：1.0 的 `totalAboveLv2` 硬编码了 ONE..SIX 六个等级（`user.go:1639`），门槛就是 LV2 不随自己变。所以 `team_qualified_count` 存单个 int 够用。门槛值提到配置 `level.team.qualified_level`（默认 `TEAM_LEVEL_2`），列名不带数字——D16 之后 `TEAM_LEVEL_2 = 3`，列名里写 `lv2` 会和枚举值对不上，读代码的人要卡一下。

这个「粉丝动态门槛 / 团队固定门槛」的不对称是 1.0 的真实规则，不是设计出来的，照搬。

##### `ActiveHoldingCount` 与 `PersonalActiveValue` 不是同一个量

这两个容易看成一回事，实际差三层：

| | 1.0 `playlets` → 2.0 `ActiveHoldingCount` | 1.0 `users.active_value` → 2.0 `PersonalActiveValue` |
|---|---|---|
| 量纲 | `SUM(share)` —— **份数** | 累加 `total_amount` —— **金额** |
| 口径 | 查询带 `status=EFFECTIVE AND payment=INTEGRAL`（`models/user.go:1656-1660`） | **增加时不过滤支付方式**（`tips.go:768 / 3555 / 4750` 三处全是无条件 `+`） |
| 用法 | **只与 0 比较**——判定里只有 `playlets == 0` / `playlets > 0`，从不参与数值运算，本质是「有没有生效中的持仓」 | `>= 50` 的数值门槛 |

LV2 的规则 `有生效持仓 && 个人活跃值 >= 50` 里，两个条件考的是不同的事：前者问「现在还有没有持仓」（**条数**），后者问「投入够不够多」（**金额**）。所以 2.0 建成 `int` 计数，名字也从 `SupportingAmount` 改掉——那个名字既暗示金额又暗示总量，两头都不对。

##### `personal_active_value` 就是持仓本金，不再单列一份

按上面三条铁律推，活跃值只在买入时加 `total_amount`、到期时等额扣，于是恒有：

```sql
user_levels.personal_active_value
  == COALESCE(SUM(total_amount) FILTER (WHERE status = 2 /* EFFECTIVE */), 0)   -- 来自 holdings
```

所以**不再单独存一列「生效中本金合计」**——两个列装同一个数，迟早会不一致。要展示"我当前投入了多少"，读 `personal_active_value` 即可。

保留 `active_value` 这个词根而不是叫 principal，是因为它是**产品概念**（等级门槛「个人活跃值 ≥ 50」，PRD 和运营都在用），而且 `team_active_value` 是它的团队汇总——「团队本金合计」听起来像财务口径，实际是等级判定输入。

将来产品若要让活跃值 ≠ 本金（比如某类产品按 1.5 倍计入活跃值），那时活跃值才真正独立于本金，再加列，语义反而更清楚。

⚠️ 注意这里有**三个不同的量**，别混：`personal_active_value`（生效中本金，会随到期减少）、`active_holding_count`（生效中条数）、以及**累计投入**（历史总额，只增不减）。前两个在 `user_levels` 上物化，累计投入没有物化列——需要时从 `holdings` 聚合，或从 `active_value_ledgers` 只累加正数。

##### 顺带挖出 1.0 活跃值的三处不一致（2.0 必须修）

1. **增减不对称 → 活跃值只涨不跌**
   加的时候三处全是无条件 `active_value = active_value + ?`；到期扣的时候却有两个 gate（`services/playlet/cron.go:1135`）：
   ```go
   if user.Type == basepb.User_TYPE_NORMAL && ledger.Payment == basepb.Order_PAY_TYPE_INTEGRAL {
       Set("active_value = active_value - ?", ledger.TotalAmount)
   }
   ```
   于是**非 NORMAL 用户、或非硬币支付的持仓，加了永远不扣**——活跃值永久虚高，等级跟着虚高。

2. **金额基准两套**
   普通机器人加 `totalAmount`（原价，注释还专门写了「活跃值得按原价计算」，`tips.go:3555`）；等级机器人加 `paymentAmount`（折后价，`tips.go:4750`）。同一个活跃值，两种算法。

3. **扣的比加的多**
   到期统一扣 `ledger.TotalAmount`（原价）。所以走等级机器人折扣买入的用户**加折后价、扣原价**——净扣，活跃值可能被扣成负数。

**2.0 的三条铁律**：

1. **2.0 的持仓只接受硬币支付**（`holdings` 没有支付方式列），所以不存在 1.0 那种「加时不过滤、扣时才过滤 `payment=INTEGRAL`」的口子。将来若真要开别的支付方式，**增减两侧必须同时加同一个过滤条件**。
2. **增减完全对称**：按什么条件加、按什么金额加，到期就按同样的条件、同样的金额扣。
3. **基准统一用 `holdings.total_amount`（原价）**，折扣只影响实际扣款（`ApplyMutations` 的金额），不影响活跃值。

三条都落在 `active_value_ledgers` 上，靠 `idem_key` 保证一加一减各恰好一次（购买 `holding:<id>`、到期 `holding_end:<id>`），并加一条夜间对账：

```sql
-- 两条都必须恒等。不等就说明上面三条铁律里有一条被破坏了
-- ① 流水求和 == 物化值
SELECT l.user_id
  FROM (SELECT user_id, SUM(amount) AS v FROM active_value_ledgers GROUP BY user_id) l
  JOIN user_levels u ON u.user_id = l.user_id
 WHERE l.v <> u.personal_active_value;

-- ② 物化值 == 生效中持仓本金（这条同时验证了 active_holding_count）
SELECT u.user_id
  FROM user_levels u
  LEFT JOIN (SELECT user_id, SUM(total_amount) AS principal, COUNT(*) AS cnt
               FROM holdings WHERE status = 2 GROUP BY user_id) h ON h.user_id = u.user_id
 WHERE u.personal_active_value <> COALESCE(h.principal, 0)
    OR u.active_holding_count  <> COALESCE(h.cnt, 0);
```

顺序严格保留：

| # | 1.0 出处 | 规则 |
|---|---|---|
| 1 | `user.go:1666` | `rank := current`；UNKNOWN 或 FREE → `rank = 1`(VIP/L1) |
| 2 | `user.go:1671` | 若 `rank >= 2`：`directAtOrAbove(rank) < 3` **或** `teamAV < 200` → `rank = 2`。※ 注意它比较的是**用户自己当前等级**而不是 LV2，是个不对称写法，照抄 |
| 3 | `user.go:1688` | LV2：`supporting > 0 && personalAV >= 50` → rank=2，否则 rank=1。**只实现这一支，丢弃 2026-01-30 祖父分支** |
| 4 | `user.go:1706` | LV3：`directAtOrAbove(2) >= 3 && teamAV >= 200` |
| 5 | `user.go:1711` | LV4：`>= 10 && teamLv2Plus >= 60 && teamAV >= 1200` |
| 6 | `user.go:1716` | LV5：`>= 20 && >= 350 && teamAV >= 7000` |
| 7 | `user.go:1721` | LV6：`>= 35 && >= 2000 && teamAV >= 40000` |
| 8 | `user.go:1726` | LV7：`>= 50 && >= 10000 && teamAV >= 300000` |
| 9 | **2.0 新增** | `if rank < in.LevelFloorRank { rank = in.LevelFloorRank }` |

保底是**对结果取下界**，不是跳过计算——所以迁移过来的 LV5 用户凭真本事升到 LV6 照样升，`highest_team_level` 也仍然准确。阈值全部从 Go 常量（`models/user.go:29-68`）搬进配置 key `level.team`。

#### 副作用改成事件

1.0 的「等级变了」分支里塞了约 450 行活动钩子（升级补贴 / VIP 邀请 / 宝藏计划 / AI-X / 周年庆 / 33 挑战 / 33 互助），其中好几处出错就 `return`，**导致等级本身都没写**（`models/user.go:2059`）。这是 1.0 最严重的结构问题。

2.0 的 worker 只做三件事：一个事务内写 `user_levels` + 写 `user_level_changes`；事务外 `jobqueue.Publish("level.changed", payload)`。`services/activity` 消费。**活动坏了不能再阻止等级落库。**

事件 payload 必须自带 `is_new_high`（本次是否刷新了历史最高），由 worker 在**写库的同一个事务里**、拿变更前的 `highest_*` 判定后填入：

```go
type LevelChanged struct {
    UserId    int64
    Kind      LevelKind // TEAM / VIP
    FromLevel int
    ToLevel   int
    IsNewHigh bool      // ★ 变更前 highest 与 ToLevel 比出来的，消费方不要自己再查
    Reason    ChangeReason
    ChangedAt int64
}
```

不让消费方自己去查 `user_levels.highest_*`：消息是异步消费的，等它读到的时候 `highest_*` 早被后续变更覆盖了，一次性奖励就会漏发或重发。判定必须在事实发生的那一刻完成。

### 5.5 配置合并

> 落地手册见 [`docs/models-config-使用说明.md`](models-config-使用说明.md)：定义 / 读 / 写 / 缓存失效 / 测试的完整约定与代码骨架。

#### key 命名空间

```
level.team           团队等级阈值 + qualified_level  ← 替代 models/user.go:29-68 常量
level.vip            个人 VIP 档位 + 周兑换额度
rebate.team          团队返佣 [level][1..5]  ← 原 tips_param.tips_rebate（★迁移映射见配套文档 §2.1）
rebate.consume       消费返佣                ← 原 tips_param.consume_rebate
holding.limits       份额 / 金额 / 占用上限   ← 原 tips_param 标量列
holding.active_value 1 币 = 多少活跃值        ← 原 tips_param
holding.claim        领取截止时间            ← 原硬编码中午 12 点
legacy.unlock        解锁方案（360 天 / 30 天 / 比例）
legacy.exchange      兑换周期 / 最小额
wealth.rate          币↔USD↔IDR / 体验币↔通用币  ← 原 wealth_param
wealth.withdraw      提现上下限 / 免费次数 / 费率 ← 原 wealth_param
robot.default        机器人默认参数
app.page / app.popup / activity.<name> / invite.alias
```

#### 类型化注册表

```go
// models/config/registry.go

// Def 一个配置项的定义。注册即产出：强类型 accessor、默认值、缓存、后台白名单。
type Def[T any] struct {
    Key     string
    Default func() *T
    // Normalize 归一化 + 兜底。配置是人手填的，越界值必须在【读出口】收敛，
    // 不能让业务代码到处 if——照抄 $SPLAY/models/flash_sale.go:100 normalize() 的做法。
    Normalize func(*T)
}

// Register 在 init() 里调用。重复 key 直接 panic：启动即失败，好过线上读到错的那份。
func Register[T any](d Def[T]) *Holder[T]

func (h *Holder[T]) Load(ctx) *T                          // forge/dbcache tiered：L1 内存 → L2 redis → DB
func (h *Holder[T]) Save(ctx, db bun.IDB, v *T) error     // 写 DB + Delete 缓存 + 广播失效
```

调用处没有字符串 key：`cfg := config.TeamLevel.Load(ctx)`。

#### 缓存机制

用 **`forge/dbcache` 的 tiered store**（L1 内存 LRU + L2 Redis），自带 singleflight 防击穿与负缓存防穿透。

**两层共用同一个 TTL，取 30s**——`dbcache` 只有一个 `WithTtl`，`Cache.Set` 拿它同时写 l1 和 l2（`$FORGE/dbcache/store_tiered.go:63`），分层 TTL 表达不出来。唯一的不对称是 L2 命中回填 L1 时用写死的 `tieredRefillTtl = 60s`（同文件），不可配——所以**最坏本地陈旧窗口是 60s**。配置总共几十行、回源是主键单行查询，30s 带来的 DB 压力可以忽略。

- **后台写路径**：`UPDATE configs SET value=..., version=version+1` → **`cache.Delete(key)`**（连带清 in-flight singleflight）→ **`rdb.Publish("shortmax:cfg:invalidate", key)`** 让其它副本清掉自己的 L1。删而不是重写是需求 10 的要求，也是对的：写方重写会和另一个管理员的并发写竞态。
- **广播走 Redis Pub/Sub，不能用 `forge/jobqueue`**：jobqueue 是 Redis LIST + `BRPOP`（`$FORGE/jobqueue/worker.go:39`），**竞争消费**——一条消息只有一个订阅者拿得到，多副本的 api 只会有一个副本清掉 L1；何况按 §3.3 的编排 `apid` / `mand` 根本不订阅 jobqueue，消息会被投到唯一一个不需要它的 worker。这是全方案里少数几处 forge 没有现成抽象的地方，直接用 `RedisProvider` 给出的 `redis.UniversalClient`，三个进程都要起订阅。
- dbcache **不实装跨进程失效广播**，所以那条 invalidate 消息是必需的；但**丢了不影响正确性**，只是其它副本退化成靠 TTL 收敛（≤60s）。Postgres 是真源、Redis 只是加速器，所以 Pub/Sub 的 at-most-once 语义在这里够用——与 §5.4 等级重算队列同一个理由。
- invalidate channel 与 L2 的 key 前缀都要进 `internal/rediskey` 的契约表（谁写 / 谁读 / TTL / 丢失后果）。

#### 后台面

1.0 的 `SetConfig(key, value string)`（`services/man/config.go:52`）往任意 key 写任意字符串、零校验，打错一个字母就静默关掉一个活动。2.0 的 `admin/config/v1.SetConfig` **由注册表驱动**：查 key → 按注册的 Go 类型反序列化（`DiscardUnknown: false`）→ `Normalize` → `Save`。未知 key 或未知字段直接 `INVALID_ARGUMENT`。`ListConfigDefs` 返回 key + schema + 当前值，后台就能渲染真表单而不是一个 textarea。

### 5.6 机器人收益

```go
// models/robot.go

// GetDayRate 取第 day 天（1-based）的日利率(%)。
// 结算、前端预估、后台预览必须全部走这一个函数——三份实现必然对不上账。
func (r *Robot) GetDayRate(day int32) decimal.Decimal {
    switch r.YieldMode {
    case YieldFixed:
        return r.DefaultRate
    case YieldLinear:
        v := r.MinRate.Add(r.DailyIncrement.Mul(decimal.NewFromInt(int64(day - 1))))
        if r.MaxRate.IsPositive() && v.GreaterThan(r.MaxRate) { return r.MaxRate }
        return v
    case YieldSchedule:
        if int(day) >= 1 && int(day) <= len(r.RateSchedule) { return r.RateSchedule[day-1] }
        return r.DefaultRate   // 越界回落默认值，不返 0
    }
    return r.DefaultRate
}
```

`Holding` 带同样五个字段的**购买时快照**并有同名方法。这堵住 1.0 最严重的机器人 bug：`CheckRobotIncomeSettlement`（`services/task/common.go:299`）结算时**实时读** `robot_daily_income_rates`，后台改费率会追溯性改掉已售持仓的收益。

**结算**（`services/holding/cron.go`，每日）改成游标推进：

```go
dueDays := int(today.Sub(l.IncomeStartAt).Hours()/24) + 1
for d := l.SettledDays + 1; d <= min(dueDays, l.IncomeDuration); d++ {
    // INSERT holding_incomes (holding_id, day_index=d, ...) ON CONFLICT DO NOTHING
    // UPDATE holdings SET settled_days=d, total_income=total_income+amt WHERE id=? AND settled_days=d-1
}
```

停机 3 天会补齐；一天跑两次是 no-op。1.0 **两条都不满足**。

**冻结**：不再有 `PointFrozenLedger` 和单独的解冻 cron。`settle_mode = FROZEN` 的机器人写 `holding_incomes.frozen=true, unfreeze_at=income_end_at`，**同一个 cron pass** 里 `UPDATE ... SET unfrozen_at=now() WHERE frozen AND unfrozen_at IS NULL AND unfreeze_at <= now() RETURNING`，再 `ApplyMutations(idem_key="unfreeze:<income_id>")`。一张表一个任务。

**领取**：中午 12 点截止改成 `holding.claim.cutoff_hour`（默认 12，行为不变）。领取本身：

```sql
-- ★ 必须用 CTE 先把旧值捞出来。PostgreSQL 的 RETURNING 看到的是【更新后】的行，
--   直接写 RETURNING total_income - claimed_income 恒为 0（SET 已经把两者拉平了）。
--   PG 18 才有 OLD./NEW. 前缀，生产是 PG 17，只能这么写。
WITH prev AS (
  SELECT id, total_income - claimed_income AS delta
    FROM holdings
   WHERE id = $1 AND claimed_income < total_income
   FOR UPDATE
)
UPDATE holdings h
   SET claimed_income = h.total_income, updated_at = now()
  FROM prev
 WHERE h.id = prev.id
RETURNING prev.delta;
-- 0 行 => 无可领取，直接返回，不调 ApplyMutations
```

→ `ApplyMutations({Kind: Income, Amount: delta, IdemKey: "claim:<holding_id>:<total_income>"})`。连点两下是 no-op：第二次 `claimed_income < total_income` 不成立，0 行。幂等键带上 `total_income` 是为了让「领了一次、又结算出新收益、再领一次」能正常记两笔。

**到期**：一个事务里——本金 1:1 退回、`personal_active_value` 减、`status=ENDED`、团队活跃值增量传播给 ≤5 祖先、置 `dirty_at`、入队。1.0 的 `checkRobotTipsStatus`（`services/playlet/cron.go:1099`）按 `tips_income_end_at = <精确时间戳>` 匹配，**cron 漏跑一天那批持仓就永远不到期**；2.0 改成范围条件 + 追赶。

**`robot_daily_income_rates` 迁移**：

```sql
SELECT robot_id, jsonb_agg(rate::text ORDER BY day_index) AS schedule
  FROM robot_daily_income_rates GROUP BY robot_id;
```

→ `robots.rate_schedule`，`yield_mode=3(SCHEDULE)`。然后跑一个**只出报告**的后处理：若某机器人的数列在容差内是等差的，打印建议的 `(min_rate, max_rate, daily_increment)` 供运营手工确认。**不自动转换**——静默改掉一个在售产品的收益曲线不是迁移，是调价。

### 5.7 验证码投递：WhatsApp 优先 → 短信兜底（D14）

#### 1.0 现状与它的四个洞

全部逻辑在 `services/user/job.go` 一个 95 行的 `sendMessageJob` 里：发 Mekari WhatsApp → **同步 `sleep 1s` × 3 轮询 `/whatsapp/log`** → 状态是 failed/error 或 3 轮没结果 → 落到短信。`62` 前缀走 kirimsms，其余走 buka。

四个必须修掉的问题：

1. **3 分钟缓存的判断是反的**。`smsCache` **只在 WhatsApp 成功时**写入，而守卫是 `if !ok`（没命中才发 WhatsApp）。实际效果是：**上次 WhatsApp 成功过的号码，3 分钟内再次请求会跳过 WhatsApp 直接发短信**。而重发窗口 `message.again` 是 1 分钟——用户 60 秒后点重发，必定烧一条短信。而且它是**进程内 go-cache**，重启即失效、多副本各算各的。
2. **`pubsub.Broadcast` 会静默丢消息**。`teivah/broadcast` 的实现是 `select { case ch <- v: default: }`，listener buffer 5、worker pool 10，而每次 WhatsApp 尝试要占住一个 worker 3 秒以上。**并发超过 15 个验证码请求时，多出来的直接被丢弃**——redis 里的码已经写好了，RPC 返回成功，用户永远收不到，日志里一行都没有。
3. **`Debug()` 无条件开启**（`services/user/init.go:44`），resty 会把完整请求响应打进日志——**手机号、6 位验证码、HMAC `Authorization` 头全部落盘**。
4. **`taskId` 用完即弃**，不落库、不打日志。没有 webhook、没有 cron、没有重试，投递结果无从追溯，也没法回答「这条为什么没收到」。

#### 2.0 形态：投递落库 + 快探 + 异步跟进

**核心是把「投递」变成一条有状态的记录**，而不是一次 fire-and-forget 的函数调用。

```sql
CREATE TABLE message_deliveries (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  scene         smallint NOT NULL,           -- REGISTER / LOGIN / FORGOT / BIND / WITHDRAW ...
  target        varchar(191) NOT NULL,       -- E.164 手机号或邮箱
  channel       smallint NOT NULL,           -- 1=WHATSAPP 2=SMS 3=EMAIL
  provider      varchar(32) NOT NULL,        -- mekari / kirim / smtp
  provider_msg_id varchar(128),              -- ★ 1.0 丢掉的东西：供应商侧消息 id
  status        smallint NOT NULL,           -- 1=PENDING 2=DELIVERED 3=FAILED 4=EXPIRED
  attempt       int  NOT NULL DEFAULT 0,     -- 已探询次数
  next_check_at timestamptz,                 -- ★ 下次探询时间，cron 靠它扫
  fallback_at   timestamptz,                 -- ★ 到点仍未 DELIVERED 就发短信
  fallback_of   bigint,                      -- 指向被兜底的那条 WhatsApp 投递
  idem_key      varchar(128) NOT NULL UNIQUE,-- <scene>:<target>:<code 窗口>
  err_msg       text,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON message_deliveries (next_check_at)
  WHERE status = 1 AND next_check_at IS NOT NULL;   -- 部分索引，终态行不进索引
CREATE INDEX ON message_deliveries (target, id DESC);
```

流程：

```
RPC SendCode
  └─ 写 redis 验证码（沿用 pkg/msgcode 的语义：again 窗口 / daily 上限 / 测试号后门）
  └─ jobqueue.Publish("verification.deliver", deliveryId)      ← forge/jobqueue，不再用会丢消息的 Broadcast
  └─ 立即返回

worker: deliverJob
  ├─ 选渠道（见下）→ WhatsApp
  ├─ wa.Send() → 拿 providerMsgId → UPDATE 落库（PENDING）
  ├─ ★ 快探一次：sleep 1.5s → wa.QueryStatus()
  │     命中 delivered → status=DELIVERED，结束（体验与 1.0 持平）
  │     明确 failed    → 立刻发短信兜底
  │     仍 pending     → next_check_at = now+2s, fallback_at = now+wa_wait(默认 12s)，交给 cron
  └─ 结束（worker 最多占用 ~1.5s，1.0 是 3s+）

cron: followUpDeliveries   每 2 秒
  ├─ SELECT ... WHERE status=1 AND next_check_at<=now() LIMIT 200   （走部分索引）
  ├─ QueryStatus → delivered 收尾 / failed 立即兜底 / pending 则 attempt++、next_check_at 退避
  └─ now() >= fallback_at 仍未送达 → 发短信，新插一条 channel=SMS 的记录，fallback_of 指回来

（可选）webhook: 供应商投递回执直接把记录推成终态，cron 只作兜底
```

关键性质：

- **worker 阻塞从 3s+ 降到 ~1.5s**，且不再有"占满 worker → Broadcast 丢消息"这条路（jobqueue 是 Redis LIST，满了是阻塞/裁剪不是静默丢，且 `max_len` 超限有 `PublishDropped` 指标可告警）。
- **多副本安全**：状态在 DB 不在进程内存。
- **可审计**：客服能查到「这个号码今天发了几条、走的哪个渠道、供应商侧 id 是多少、为什么兜底」。
- **重发去重改成正向语义**：`idem_key` 唯一 + `again` 窗口，而不是 1.0 那个反向的 3 分钟缓存。同一窗口内重发命中已有 PENDING 记录时不重复发，直接返回。

#### 渠道决策可配

```json
// configs key: verification.delivery
{
  "channels": ["whatsapp", "sms"],
  "whatsapp": {
    "enabled": true,
    "include_prefixes": ["62"],        // ★ 1.0 是无差别给所有号码发印尼语模板
    "fast_probe_delay": "1.5s",
    "probe_interval": "2s",
    "max_probe": 5,
    "wait_before_fallback": "12s"
  },
  "sms": { "providers": ["kirim"] }
}
```

⚠️ 1.0 对**所有**非邮箱号码都先试 WhatsApp，包括非印尼号，而 Mekari 模板语言硬编码 `"id"`。2.0 用 `include_prefixes` 收敛，默认只对 `62` 开 WhatsApp。

#### WhatsApp 接口（可插拔，D14）

forge/message 完全没有 WhatsApp 抽象，也没有任何投递状态概念（`Send` 只返回 `error`，Twilio 响应里的 `sid`/`status` 都被丢弃）。所以 WhatsApp 不进 forge，单独定接口：

```go
// internal/notify/wa/wa.go

// Sender 是 WhatsApp 模板消息的最小能力：发出去、并且能回头查这条的投递状态。
// 这两件事必须在同一个接口里 —— 只有 Send 的渠道无法支撑"确认后才不发短信"。
type Sender interface {
    // Send 发送模板消息，返回供应商侧消息 id，用于后续 QueryStatus。
    Send(ctx context.Context, msg Message) (providerMsgId string, err error)
    // QueryStatus 查询投递状态。供应商不支持查询时返回 ErrStatusUnsupported，
    // 上层据此退化为"发完即当作 pending，只等 fallback 计时"。
    QueryStatus(ctx context.Context, providerMsgId string) (Status, error)
    Name() string
}

type Status int8
const (
    StatusPending   Status = iota + 1
    StatusDelivered
    StatusFailed
)
```

`internal/notify/wa/mekari` 是第一个实现。相对 1.0 要修的：`ctx` 贯通、`Debug()` 受配置控制且脱敏、模板语言与模板 id 走配置不写死、状态字符串到 `Status` 的映射集中一处（1.0 是散在 `job.go` 里的 `EqualFold` 判断，且「非 pending 非 failed 就算成功」这种兜底过于宽松）。

#### 短信只补 kirim（D14）

按 forge/message 的 backend 契约补一个 `sms_kirim.go`，6 步：

1. `KirimConfig{Name, ApiKey, ApiSecret, SenderId, Timeout, IncludeRegions, ExcludeRegions}` 加进 `options.go`
2. `WithKirimSms(cfg) Option`，参数校验在这里（`New` 时 fail-fast）
3. `sms_kirim.go`：`kirimSender` 内嵌 `regionFilter`（白送 `Accepts`），`cfg.build()` 实现 `smsSpec`
4. 实现 `Send/Mode/Name`。**`Send` 必须在 HTTP 200 时也检查业务码**——1.0 的 kirimsms 客户端只看 `response_status.code != 0`，不看 HTTP 状态，一个返回非 JSON 的 500 会被当成成功且 `taskId` 为空
5. `provider/message.go` 的 `smsProviderYaml` 加字段 + `buildSmsOpts` 加 `case "kirim":`
6. `sms_kirim_test.go`，用 `httptest.Server` 覆写不导出的 `baseURL`

保留 1.0 kirimsms 里唯一有价值的那段：印尼干线 0 的归一化（`620…` → `62…`）。但 `sender_id` 必须从写死的 `"ShortPro"` 改成配置项。

alisms / i51sms / buka 都不搬。

### 5.8 机器人类型：8 种砍成 0 种（D18）

#### 依据：2026-07-28 生产库快照（`robots` 全表 108 行）

| 类型 | 在线 | 下架 | 累计支持额 | |
|---|---|---|---|---|
| `FIXED_INCOME` 固定收益 (2) | **5** | 47 | 2,596 万 | 主力（AI-X 系列） |
| `REPURCHASE` 复购 (5) | **9** | 0 | 1,225 万 | 主力（AI Solo 1–35 天阶梯） |
| `TIPS_LEVEL` 等级 V1 (4) | **5** | 0 | 0 | 在线但**零成交** |
| `TIPS_LEVEL_V2` 等级 V2 (8) | **5** | 0 | 0 | 在线但**零成交** |
| `NORMAL` 普通 (1) | 0 | 16 | 299 万 | **全部下架** |
| `AI_INVEST` 智投 (7) | 0 | 11 | 268 万 | **全部下架** |
| `EXPIRED_REFUND` 到期返 (3) | 0 | 9 | 30 万 | **全部下架** |
| `SUBSCRIPTION` 申购 (6) | 0 | 1 | 16 万 | **全部下架**，全库只做过 1 个 |

四个事实：

1. **冻结收益链路目前零在用**。`IsFrozenIncomeType() = {EXPIRED_REFUND, AI_INVEST}`——恰好这两类的 20 个机器人全部下架。所以 1.0 最复杂的那条链路（`PointFrozenLedger` 表 + `checkSettlementRobotIncome` cron + `unfreezeRobotTipsIncome` 每日解冻 cron + `existsAiInvestRobotHosting` 互斥 + `GlobalParameter` 开关）**在线一个产品都没在跑**。在线的 24 个全部是用户自领型。
2. **`SUBSCRIPTION` 是全库唯一用 `on_sale_at`（→`sale_start_at`）预售的类型**，只做过 1 个、已下架。预售 + `Status=WAIT` 那套逻辑零在用。
3. **`NORMAL` 下架后它独占的字段也跟着死**：`is_occupy_tips`（16 个里 1 个用过）、`total_amount_limit`→`max_total_amount`（16 个里 6 个）。而 `is_occupy_tips` 本身是"占用打赏位"，2.0 短剧支持已下线，这个概念不存在了。
4. **在线的 4 类真实差异只有两处**：固定收益 vs 复购字段完全同构（价/天/率/限购），只差一个持有互斥；等级 V1 vs V2 档位一模一样（`buy_tips_level` 都是 2/3/4/7/8），只差 V2 领取时校验等级仍匹配。

#### 结论：`robot_type` 不再是行为开关

8 种类型承载的真实行为只有 5 个轴，每个都能降级成列：

| 行为轴 | 1.0 靠什么 | 2.0 |
|---|---|---|
| 收益冻结到期发 vs 随时领 | `type ∈ {3,7}` | `settle_mode` |
| 等级专属 | `type ∈ {4,8}` + `buy_tips_level` | `buy_team_level` 非空即是 |
| 持有互斥 | `type ∈ {5,7}` + 两个 `GlobalParameter` 开关 | `exclusive_scope` |
| 预售 | `type == 6` + `on_sale_at` | `sale_start_at` 非空即是（本来就是列） |
| 领取时校验等级 | `type == 8`（V1 豁免） | **不需要开关**——2.0 只保留 V2 行为，等级专属机器人一律校验 |

`NORMAL` / `FIXED_INCOME` / `REPURCHASE` 三者在代码里**没有任何行为差异**（除复购的互斥），区别全在 price / duration / rate / limit 这些每个机器人都有的列。所以 `type` 退化成 `category varchar` —— **纯展示分区，运营自定义（"AI-X 专区"/"AI Solo"），代码永远不拿它做分支**。

配套的三条硬约束写进 AGENTS.md：

1. **`services/` 里禁止出现 `switch robot.Category`**。要加行为就加列，不要往 category 上挂语义——1.0 就是这么从 1 种类型长到 8 种的。
2. **等级专属只有一种行为**：`buy_team_level` 非空 ⇒ 购买时要求 `user.team_level == buy_team_level`，**领取时再校验一次**（1.0 的 V2 行为）。V1 那种"买时校验、领时豁免"的口子不保留。
3. **`settle_mode` 保留但不建独立冻结表**：`FROZEN` 只是让 `holding_incomes.frozen = true, unfreeze_at = income_end_at`，同一个结算 cron 顺手解冻（§5.6）。1.0 用一张 `PointFrozenLedger` + 一个专门的解冻 cron 表达同一件事，两边状态还会漂。

#### 迁移影响（S7）

- **`type` → `category`**：按 1.0 类型名给个默认分区值，运营上线前自己改。
- **`settle_mode`**：`EXPIRED_REFUND` / `AI_INVEST` → `FROZEN`，其余 → `CLAIMABLE`。
- **`exclusive_scope`**：`REPURCHASE` / `AI_INVEST` → 按 1.0 那两个 `GlobalParameter` 开关当时的值决定是 `BY_ROBOT` 还是 `BY_CATEGORY`，**迁移时把开关当时的值记进报告**，别让它悄悄变。
- **等级机器人 V1 → V2 行为**：5 个 V1 机器人的 `min_buy_tips_level` 现在全是 5，而 V2 的填法是 `min_buy_tips_level = buy_tips_level`。迁移后按 V2 语义，**可购买人群会从"≥LV5"变成"正好是该等级"**。因为 V1 零成交、无存量持仓，不影响任何用户；但在线的 5 个产品口径变了，**迁移报告要单独列出来给运营确认**。
- **`is_occupy_tips` 不迁移**（2.0 无短剧支持，概念不存在）。
- **字段改名映射**见 §4.6「字段重命名与删减」；三个上限的哨兵 `0`/`-1` 统一转成 `NULL`，**转换时要按各自的旧哨兵分别判断**——`buy_limit=0`→NULL、`run_limit=0`→NULL、`total_amount_limit=-1`→NULL，别用同一条规则套。
- `is_trial = (version_type = 1 /* BETA */)`；`version_type` 的 `0`(UNKNOWN) 与 `2`(STANDARD) 都映射为 `false`。

### 5.9 主键策略：去掉 snowflake（D17）

1.0 给高频流水表（`point_ledgers` / `tips_ledgers` / `channel_data` / `coupons`）用 snowflake，其余用 `pk,autoincrement`。2.0 **全部改成 `bigint GENERATED ALWAYS AS IDENTITY`**，只有 `users.id` 例外（D11 的随机步长序列，因为它对外）。

**为什么 snowflake 在这里不划算**

| | snowflake | identity |
|---|---|---|
| 依赖 | `bwmarrin/snowflake` + `snowflake.node_id` 配置 + 装配层初始化 | 无 |
| 故障模式 | **两个实例配了同一个 `node_id` → ID 冲突 → 唯一约束报错**；时钟回拨 → 阻塞或 panic | 无 |
| 1.0 的额外坑 | node 靠 `container.AsyncGet` **异步注入**（`models/service.go`），就绪前插入会拿到零值 node | 无 |
| 索引写入 | 大致单调，但多副本交错，右端页有轻微分裂 | 严格单调，B 树写入集中在右端，页最紧凑 |
| 反推时间 | 可以（ID 内嵌时间戳） | 不行——但每张表都有 `created_at` |
| 分库分表 | 天然不冲突 | 跨库会冲突 |

只有最后一行是 snowflake 的真实优势，而 2.0 是**单主库 + 只读从库**，不分片（§3.4）。等真要分片时，那时的表结构、分片键、迁移窗口都得重新设计，现在为它背一个依赖和一类故障不值得。

PG 的 sequence 是全局非事务对象，多副本并发 `nextval` 没有锁竞争（和 MySQL 的 auto_increment 表锁不是一回事），高频流水表也不构成瓶颈；真需要再给序列设 `CACHE 100`。

**用 `IDENTITY` 而不是 `bigserial`**：语义更严格（`GENERATED ALWAYS` 默认禁止手工插入覆盖，需要显式 `OVERRIDING SYSTEM VALUE`），也没有 `serial` 那种 owned-sequence 的权限与 `DROP` 连带问题。bun 侧 tag 是 `bun:",pk,autoincrement"`。

**要改的写法：先插拿 id，不再预生成**

1.0 是在 `BeforeAppendModel` 钩子里 `s.Id = node.Generate().Int64()`，插入前 ID 就有了。2.0 需要 `RETURNING id`，涉及四处跨表引用：

| 场景 | 依赖关系 | 写法 |
|---|---|---|
| `ApplyMutations` → `legacy_exchanges.asset_ledger_id` | 先有流水才有兑换记录 | 流水插入 `Returning("id")`，`ApplyMutations` 把带 id 的 `[]AssetLedger` 返回给调用方 |
| `CreateHolding` → `ApplyUnlockAdjustment(idem_key="holding:<id>")` | 先有持仓才有加速记录 | 持仓插入 `Returning("id")`，同事务内接着用 |
| `holding_incomes.holding_id` | 持仓早已存在 | 无影响 |
| `message_deliveries.fallback_of` | 先有 WhatsApp 记录才有兜底短信记录 | 原记录插入 `Returning("id")` |

全部在同一事务内，`RETURNING` 只是多带一列回来，没有额外往返。

**两个要接受的性质**

1. **ID 有空洞**。事务回滚不回退序列，所以审计表的 ID 不连续——**不连续不代表丢数据**，这句要写进 AGENTS.md，否则第一次有人拿 ID 差值算流水量就会误报。
2. **ID 泄露表的行数量级**。这些表的 ID 不直接对外，但流水列表的游标会暴露。所以 public 侧游标**用 `(created_at, id)` 复合值 base64 编码后下发**，不裸传 id——这本来就是 `$SP` 的游标约定（游标必须基于与排序一致且唯一的稳定键），顺带把这点信息也遮住。

### 5.10 第三方客户端：两类封装（D15）

分类标准只有一条：**这个依赖有没有官方 SDK。**

| 依赖 | 处置 | 理由 |
|---|---|---|
| **阿里云 VOD / OSS** | **不封包**，在装配层构造 SDK 客户端并注入 | SDK 本身就是封装好的库，再包一层只是转发。1.0 的 `internal/client/ali` 一共 36 行、只有一个构造函数，却**偷偷 `os.Setenv("OSS_ACCESS_KEY_ID"/"..._SECRET")`** 让 `playd/upload.go` 的 OSS 从环境变量读——这是编译器看不见的跨包耦合，也是它该被删掉的真正原因 |
| **GhostCut 字幕**（`internal/client/subtitle`） | **封 `Client struct`** | 裸 HTTP + 自定义 `AppSign = md5(md5(body)+secret)`，没有 SDK |
| **FuturePay**（`internal/pay/future`） | **封 `Client struct`** | 同上，裸 HTTP + 自定义签名 |
| **Mekari WhatsApp**（`internal/notify/wa/mekari`） | **封 `Client struct`** | 同上，HMAC 签名 |
| **三方登录**（`internal/client/third`） | **整块删除** | 功能下线 |

#### 阿里云 VOD / OSS：装配层初始化

```go
// apid/init.go —— 和 forge 的 Provider 并列，业务只拿到 SDK 客户端本身
vodClient := lo.Must(vod.NewClient(&openapi.Config{
    AccessKeyId:     ..., AccessKeySecret: ..., RegionId: ..., Endpoint: ...,
}))
ossClient := lo.Must(oss.New(endpoint, accessKeyId, accessKeySecret))  // ★ 显式传参，不走 os.Setenv
ioc.Provide(vodClient); ioc.Provide(ossClient)
```

顺带清掉 1.0 的三个问题：`initAliVod()`（老账号，`aliyun.*`）与 `ali.NewAliVod()`（新账号，`new_aliyun.*`）**两套客户端并存**、按 `episode.IsOldAli` 分叉——2.0 只留一套；`ali.NewAliVod()` 构造失败只打日志、返回可能为 nil 的客户端，每个调用方都不判空；`playd/upload.go` 的每个错误分支都 `return` 而不写响应体，客户端永远收到空 body 的 200。

**播放路径**：1.0 已经有一条不依赖 VOD 的路在跑——`PlayletEpisode.VideoInfo` jsonb 存各清晰度直连 URL、AES-CBC 加密后下发，由 `checkPlayUrlCron` 从 `GetPlayInfo` 批量回填。2.0 沿用这条，VOD 只承担**摄入侧**（上传凭证 / 转码进度 / 源文件 / 时长封面）和给字幕服务取源 URL。这意味着未来真要切 CDN，动的是回填 cron 这一处，不是 46 个调用点。

#### subtitle / future / mekari 的统一 Client 约定

参考 `$FORGE/message` 的后端写法，四条硬约定：

```go
type Client struct {
    cfg    Config
    baseURL string        // ★ 不导出但可在测试里覆写，httptest 就能测
    client *resty.Client
}
func New(cfg Config, opts ...Option) (*Client, error)   // 校验前置，失败即返回 error
func (c *Client) Do(ctx context.Context, req Request) (*Response, error)
```

1. **每个方法第一个参数是 `ctx`**。1.0 的 futurepay / jayapay / unispay / haipay **全部没有 ctx**，超时只能靠 resty 的全局设置。
2. **请求响应都是具名 struct**，不用 `map[string]interface{}` 进、`gjson.Result` 出。1.0 的 `subtitle.CreateSubtitleWork(reqBody map[string]interface{})` 就是这样，调用方靠 `content.Get("tgtSrtUrl")` 这种裸路径取值。
3. **HTTP 状态和业务码都要检查**，且**业务失败要有 typed error / sentinel**。1.0 的 futurepay 全是 `fmt.Errorf` 字符串，逼得调用方写 `strings.Contains(err.Error(), "empty checkOutUrl")`（`services/point/futurepay.go:32`）来分类。
4. **构造一次、全局共享**。1.0 的 futurepay 客户端在 `playd/init.go` 和 `services/point/init.go` **各建了一个**，同进程两个 resty 连接池。

`future` 包另外要修的：导出 webhook body 类型（1.0 的 `ParseWebhookBody` 返回不导出的 `*webhookBody`，包外根本没法命名）；验签用 `hmac.Equal` 恒定时间比较（1.0 一个用 `!=` 一个用 `EqualFold`，都非恒定时间）；金额统一 `decimal.Decimal`（1.0 里 `AmountInfo.Value` 是 `int64`、`PayoutAmount.Value` 是 `string`）；去掉无条件 `Debug()` 和响应体全文 Info 日志。

`subtitle` 包另外要修的：`DownloadFile` 挪出去——它是个通用文件下载器，还硬依赖 `viper.GetString("static.dir")`，不属于 GhostCut 客户端；`translate` 与 `erase` 拆成两个具名方法，而不是靠 `needChineseOcclude: 1/2/14` 这种魔数区分。

### 5.11 错误体系：删掉 i18n（D13）

#### 为什么可以直接删

`contrib/i18n/active.en.yaml` **是 0 字节的空文件**，而 `Errorg` 的 message-id 是 `strconv.Itoa(int(errCode))` 这种数字串。`Trans` 查不到就吞掉并 `log.Error()` 返回空串。所以**今天线上 1504 处 `Errorg` 返回的 `Message` 全是空字符串**，客户端拿到的本来就只有 code——backend i18n 在 1.0 从来没生效过，只是每报一次错多打一行错误日志。

删除 = 零行为变化。唯一有内容的是 16 处显式传了硬编码英文串的调用，那些照抄即可。

#### 替代方案：照 `$SP/api/error/v1`

```proto
// api/error/v1/error.proto
message Error {
  int32  code = 1;                    // 稳定业务错误码
  string message = 2;                 // 开发者定位用的英文摘要，客户端不得据此分支
  map<string, string> metadata = 3;   // ★ 机器可读参数：remaining / retry_after / min_amount ...
}
```

`metadata` 是相对 1.0 `basepb.Error{code, message}` 的实质升级：**把"还剩 2 次机会"这类参数做成机器可读字段，而不是拼进一句本地化的话**。客户端按 `code` 分支、用 `metadata` 插值、自己渲染文案——这正好是"后端不做多语言"的正确形态。

传输映射照抄 `$SP/api/error/v1/mapping.go` 的 **default-then-override**：

```go
// 每个 code 先兜底成 400 / InvalidArgument，再按组提升
for code := range Code_name {
    errkit.RegisterGrpcCodeMapping(code, codes.InvalidArgument)
    errkit.RegisterHttpStatusMapping(code, http.StatusBadRequest)
}
registerTransport(codes.Internal,          500, Code_UNKNOWN, Code_INTERNAL, Code_DATABASE_ERROR)
registerTransport(codes.ResourceExhausted, 429, Code_CODE_SEND_FREQUENTLY, Code_ACCOUNT_LOCKED)
registerTransport(codes.Unauthenticated,   401, Code_UNAUTHENTICATED, Code_INVALID_CREDENTIALS)
// ...
```

新增一个 code 自动是传输安全的。对比 1.0：gRPC code 在 **1504 个调用点上逐个手传**，写错一个就是错误的 HTTP 状态。

顺带修掉：1.0 的 gateway 对业务错误**强制返回 HTTP 200**（`playd/http.go:110`），而 `checkHandler` 中间件走的却是 400，同一个 API 两套约定；forge 的 `GatewayErrorHandler` 统一按 `errkit.ToHttpStatus` 返回真实状态码。

#### 明确保留的：内容多语言

`internal/lang` 是**后端错误文案 i18n**，和**内容多语言**是两码事，后者原样保留：

`Playlet.Titles/Descriptions/Covers/BannerCovers`、`PlayletMaterial.Titles/Descriptions`、`PointPack.Names`、`TipsParameter.Instructions`、公告 `Text`、弹窗图片——全是 `[]*LanguageValue` jsonb 列，数据直接下发给客户端，客户端自己挑语言。它们不 import `internal/lang`，删除后一行都不用改。

（`$SP` 还有个 DB 驱动的 `content` 模块给前端拉多语言文案快照——2.0 若以后要统一管理 APP 文案可以照它做，本次不含。）

---

## 六、数据迁移（splay → shortmax）

### 6.1 工具

`cmd/migrate1to2/main.go`，同时打开两个 bun DB（`--src-dsn` / `--dst-dsn`）。骨架照 `$SPLAY/cmd/refresh_support_level/main.go`（flags / batch / dry-run / lock 那套已经是对的形状）。

性质：幂等、可续跑、分阶段（`--stage S1 --from-id N --batch 2000`），进度写目标库 `migration_progress(stage, last_id, done_count, updated_at)`，另有 `--verify` 模式。

排除的方案：**dump/restore**（这是重构不是拷贝）；**运行时跨库读 1.0**（让 1.0 变成永久硬依赖，且没有一致性点）。

### 6.2 阶段

**S0 割接前置（在 1.0 侧执行）**
1. 1.0 置只读。
2. **强平所有 `tips_ledgers` 中 `status=EFFECTIVE` 的持仓**：本金 1:1 退回 `available_point`，活跃值同步减。
3. 结清 / 拒付所有在途提现，`frozen_point` 归零。
4. 至此 `available_point` 就是用户的全部 1.0 资产（对应 D2 口径），`frozen_total` 不再是移动靶。

**S1 用户** → `users` / `user_credentials` / `user_identities` / `admins`
- `users.id = 1.0 user_id`；`type/status/nickname/avatar/comment/created_at` 直搬；`is_migrated=true, migrated_at=now()`。
- `user_credentials`：`password` **原样复制**（同一 bcrypt ⇒ 旧密码继续能登录，这是需求「登录信息必须保存」的硬指标）。
- ⚠️ **`pay_password` 不迁移，2.0 也不建这个列**——支付密码这个概念在 2.0 不存在，提现门槛换成 OTP，见 §4.2。
- ⚠️ **OTP 不迁移**：`otp_secret` / `otp_bound_at` 一律留空，1.0 已绑过 2FA 的用户在 2.0 需**重新绑定**。理由与做法见 §4.2 的「提现门槛」。这条和「登录令牌不迁移」一样要写进上线公告。
- `user_identities`：每个非空 `email` / `mobile` 一行；`verified_at` 由 `verified_meta` 位掩码推导；**第三方登录不迁移**（D15，功能下线）：`third_logins` 表、`ThirdLogin*` 枚举、4 个相关 RPC 全部不建。只保留 email / mobile 两种 identity。
- `type=ADMIN` 的行 → `admins` 表，不进 `users`。
- ⚠️ **登录令牌不迁移**（forge/token 是 access/refresh 双令牌体系），割接后用户需重新登录一次，写进上线公告。

**S2 关系链** → `users.inviter_ids` / `parent_id` → `referral_edges`

```sql
-- ① 数组（真源）：完整链、全类型、不截断层级
--    "F<n>" 用 ParseUserIdFromFansCode 解回 id；裸数字（代理商/业务员/管理员上级）直接转 bigint
UPDATE users u SET
  inviter_ids = (SELECT COALESCE(array_agg(
                     CASE WHEN c LIKE 'F%' THEN (substr(c,2)::bigint - 1)/2
                          ELSE c::bigint END
                   ORDER BY ord), '{}')
                   FROM unnest(s.inviter_fans) WITH ORDINALITY AS t(c, ord)),   -- ★ 无 ord<=5 限制
  parent_id   = ...
FROM src_users s WHERE s.user_id = u.id;

-- ② 边表（派生）：只到 5 层、只收 NORMAL 用户之间的边
INSERT INTO referral_edges (ancestor_id, descendant_id, depth)
SELECT a.id, u.id, a.ord
  FROM users u
  JOIN LATERAL unnest(u.inviter_ids) WITH ORDINALITY AS a(id, ord) ON true
  JOIN users anc ON anc.id = a.id AND anc.type = 1   -- ★ 只收 NORMAL 上级
 WHERE u.type = 1 AND a.ord <= 5                     -- ★ 下级也必须是 NORMAL
ON CONFLICT DO NOTHING;
```

**对所有人无条件执行、在「选方案」这件事之前完成**——这就是需求里「无论免费或付费迁移方案都自动归属到原家族，家族成员都恢复」。

⚠️ **两条链的合并**：1.0 有两条并行的上级链——`inviter_fans`（家族，注册码以 `F` 开头时写入）与 `inviter_user_ids`（CPS 分销商，注册码是裸 user_id 时写入）。注册路径是二选一（`services/user/user.go:269`），所以正常情况下同一用户只有一条非空。迁移规则：`inviter_fans` 非空用它，否则回落 `inviter_user_ids`。**必须先跑一次统计：两者同时非空的用户有多少**，若非零需产品裁定优先级，不能默认。

校验（逐人）：
- `array_length(inviter_ids,1)` == 源链完整长度（**不是 5**）
- `count(referral_edges WHERE descendant_id=u.id)` == `min(链中 NORMAL 上级连续前缀长度, 5)`
- 抽样验证若干代理商：`SELECT count(*) FROM users WHERE inviter_ids @> ARRAY[$agent]` 与 1.0 后台显示的名下人数一致

差异一律出报告，**不静默跳过**。

**S3 金库** → `legacy_vaults`
```
frozen_total      = users.available_point       -- ★ D2：仅 available
scheme            = 0                            -- 未选方案，不倒计时
unlock_count = 0, unlocked_total = 0, exchanged_total = 0
next_unlock_at    = NULL
restructure_right = false                        -- ★ 必须在 2.0 买入产品才获得，迁移不预授
```

**S4 等级** → `user_levels`
```
team_level            = rank(users.tips_level)     -- 1.0 的 tips_level → 2.0 的 team_level
team_level_floor      = rank(users.tips_level)     -- ★ D10：迁移等级即保底
highest_team_level    = rank(users.highest_tips_level)
team_level_at         = users.tips_level_at
vip_level             = 1                          -- ★ 个人等级全部从 LV1 起（含迁移用户，无保底）
highest_vip_level     = 1                          -- ★ 历史最高同样从 LV1 起
personal_active_value = 0                          -- ★ 活跃值全部清零
team_active_value = 0, fans_level_counts = '{0,0,0,0,0,0,0,0}', team_qualified_count = 0
active_holding_count = 0, first_holding_at = NULL          -- ★ 2.0 考核从零开始
```
清零是「2.0 的返佣 / 奖金 / 工资必须完成 2.0 考核条件才享受」的自然结果：活跃值 0 + 无支持份额 ⇒ `EvalTeamLevel` 算出来人人 LV1，保底把他们托在迁移等级上（用于展示和返佣资格），而**所有带阈值的奖励都要从零重新挣**。

**S5 KYC** → `user_kyc`
- 搬 `verifications` 中 `status=PASSED` 的全部行 + 每人最新的 PENDING；`number` → `id_number_enc` + `id_number_hash`；图片 URL 原样（同一个 OSS 桶，不重传）。
- **Sumsub 认证过的用户本地没有图片**。需求要求保留 KYC 信息，所以带 `status=PASSED, source=LEGACY_SUMSUB` 搬过来、图片 URL 空，**不让他们重新实名**。
- `users.kyc_status` 从最新一行冗余。

**S6 短剧** → 短剧全套表。列直映射，数据量最大，按 id 可续跑，**提前几天预跑**（基本只增不改，最后的增量 pass 会很小）。`user_drama_ledgers` / `user_drama_episodes` 考虑只搬近 N 个月，其余归档。

**S7 机器人 + 配置种子**
- `robots` 列映射；`robot_daily_income_rates` → `rate_schedule`（§5.6）。
- `tips_param` / `wealth_param` 单行 → `configs`，用手写翻译器（`cmd/migrate1to2/config.go`），**先打印生成的 JSON 供人工确认再写库**——两边形状差得够远，静默转换是坏主意。
- 1.0 `configs` 表：只搬 2.0 注册表认识的 key，其余出报告。

**S8 校验**（`--verify`，割接前必须全绿）

| 检查 | 不通过 |
|---|---|
| 各表行数 src vs dst | 报告 |
| `SUM(legacy_vaults.frozen_total)` == `SUM(1.0 available_point)`，精确到分 | **硬失败** |
| 每个迁移用户至少一行 `user_identities` | **硬失败**（否则登不进来） |
| `inviter_ids` 长度分布 == 1.0 源链完整长度分布（不截断） | **硬失败** |
| 抽样代理商的名下用户数（`inviter_ids @> ARRAY[agent]`）与 1.0 后台一致 | **硬失败**（错了就是越权或漏看） |
| `referral_edges` 从 `inviter_ids` 重建后 diff 为空 | **硬失败** |
| `team_level_floor` 分布 == 1.0 `tips_level` 分布 | **硬失败** |
| 每个 1.0 PASSED 的 verification 都有 `user_kyc` 行 | 报告 |
| 抽样 1000 用户左右对照 dump | 人工过目 |

### 6.3 割接顺序

1.0 置只读 → S0 强平 → S1–S5、S7 增量跑（`updated_at > lastRun`）→ S6 增量 → `--verify` → 切流量。S6 全量与**生产等量的完整演练**在 P7 阶段完成，不放在割接窗口里。

---

## 七、分期

| 期 | 交付物 | 出口标准 |
|---|---|---|
| **P0 骨架**（1w） | 仓库 / module / `go get forge` / `cmd/{apid,mand,workerd}` + 装配包（provider 编排清单）/ `cmd/migrate`/ buf + protovalidate 链路 / `configs/*.example` / Makefile / `AGENTS.md` + `docs/目录结构规范.md` / `api/{base,error}/v1` + 空 service proto / `migrations/default/` 0001–0007（§4 的 20 张表全建完，identity 主键，仅 users 用 `next_user_id()`）/ 健康检查 | `make all` 出三个二进制；`migrate default init && migrate default up` 对空库建出完整骨架，且 `up → down → up` 可重入；`migrate slave up` / 未知 action 被挡下并 exit 2；各自能对空 PG+Redis 启动；`GET /health` 经 gateway 返 200；`error/v1.Error` 与 errkit 打通、一个故意报错的 RPC 返回正确信封与真实 HTTP 状态码（不是恒 200）；`time.Local` 与 DB timezone 均为 Asia/Jakarta 有断言 |
| **P1 用户与鉴权**（1.5w） | `users` / `user_credentials` / `user_identities` / `user_kyc` / `admins` / `roles` / `message_deliveries`；`next_user_id()`；注册登录（密码 + OTP，**无三方登录**）、forge/token 鉴权、KYC 提交 + 后台审核；**验证码投递链路**（forge/message + kirim backend + Mekari WhatsApp + 快探 + 跟进 cron）。迁移 **S1 + S5** | 迁移用户**用旧密码在 2.0 登录成功**并看到 KYC 状态；**未绑 OTP 的用户调提现直接被 `CODE_OTP_NOT_BOUND` 挡下**，绑定后带正确验证码才放行；全部接口均为 POST 且签名中间件对齐 1.0（用 1.0 客户端的真实请求录像回放验证）；后台能审一笔新提交；并发注册 10k 次无唯一冲突、ID 全部 ≥1 亿且不连续；**验证码四条路都验过**：WhatsApp 快探命中 / 快探未命中由 cron 收尾 / WhatsApp 明确失败立即兜底短信 / 超 `wait_before_fallback` 兜底短信，每条都在 `message_deliveries` 留痕。本阶段全站无资金 |
| **P2 资产原语**（1w） | `user_assets` / `asset_ledgers` / `ApplyMutations`；futurepay 充值提现；余额与流水接口；`configs` + 类型化注册表 + dbcache tiered + 后台 SetConfig | 压测：100 用户 × 10k 并发随机变动后，**每个用户** `SUM(ledger.amount) == user_assets.available`，无负余额、无死锁。**这道门不过不许往下走** |
| **P3 关系链与等级**（2w） | `users.inviter_ids` + `referral_edges` + `user_levels` + `user_level_changes`；`EvalTeamLevel` 纯函数 + 黄金用例；VIP 等级规则（含降级 + `highest_vip_level` 首次达标判定）；jobqueue + 去重闸门 + `dirty_at` sweep；团队聚合增量维护 + 夜间对账。迁移 **S2 + S4** | 拿生产快照全量跑 `EvalTeamLevel`，与 1.0 的 `users.tips_level` 逐人 diff，**每一处不一致都要能解释**（祖父条款 / 保底）并列表存档；边表从数组重建后 diff 为空；后台「查某人 5 层内 LV2+ 成员」在最大子树用户上 <200ms；worker 在 staging 稳定 ≥500 次重算/秒 |
| **P4 机器人与持仓**（2w） | `robots` 三种收益模型 + 行为列（`settle_mode` / `buy_team_level` / `exclusive_scope` / `sale_start_at`，D18）+ 三个上限（`max_shares_per_user` / `max_orders_per_user` / `max_total_amount`，`NULL`=不限各要有用例）；`holdings` 带收益快照；`holding_incomes` 每日唯一键；结算 / 冻结 / 领取 / 到期 cron；活跃值流水；支持后入队等级；5 级返佣链。迁移 **S7** | 一笔 30 天机器人在三种扰动下都恰好结算 30 条：① cron 停 3 天 ② cron 一天跑两次 ③ 后台在持仓存续期改收益率（存量不受影响）。到期时本金与活跃值各变动**恰好一次**；且**加减对称**——折扣买入的持仓，到期后 `SUM(active_value_ledgers.amount)` 归零而不是负数 |
| **P5 1.0 解锁与兑换 + 团队收益**（2.5w）★ | `legacy_*` 五张表；团队返佣 / 合伙人奖池 / 升级补贴三套机制（配套文档 §6）；`GetVault` / `ChooseScheme` 接口（含 `available_schemes` 的 5 种组合）；**不选方案永不解锁**要有用例；**解锁天数与比例全部走 `configs` 实时读**（改 `step_ratio=1.0` 后下一 tick 全员收口要有用例）；**REGULAR → ACCELERATED 升级**（含「第 350 天才升级」的取 LEAST 用例）+ 重组资格 + 加速；`advanceUnlocks` cron；`ApplyUnlockAdjustment`；`ExchangeLegacy`；后台全量解锁活动；全部前端接口。迁移 **S3** | 表驱动测试覆盖：常规 360 天 / 加速 5%+10%… / 加速把时间推到过去 / cron 停机后连补多期 / 末期补差 / 中途全量解锁 / 兑换分别撞三个上限 / 周中升 VIP 补足额度 / VIP 降级不回收当周额度 / 并发双兑换被拒 |
| **P6 短剧**（1.5w） | 短剧全套表 + 播放 / 剧集 / 收藏 / 弹幕 + 阿里云 VOD + 后台。迁移 **S6** | staging 上用迁移数据端到端播放；抽样用户观看历史与解锁状态一致 |
| **P7 活动 / 后台补齐 + 迁移演练**（2w） | 等级变更事件消费方（升级补贴等）；统计 rollup；剩余后台；**生产等量全量演练**；verify 报告；上线 runbook + 回滚方案 | 完整演练在割接窗口内跑完且 verify 全绿；回滚方案书面化 |

每期都能独立上线：P1 可作为纯登录壳上线，P2 加钱，P3 加等级，依此类推。**P5 依赖 P2（ApplyMutations）与 P3（VIP 等级供额度），这个顺序不可调。**

---

## 八、坑位清单

### 8.1 绝不要搬过来

1. **redlock 用裸 `DEL` 释放**（`pkg/redlock/lock.go:51`）——A 的锁过期后 B 拿到，A 的 defer 会删掉 **B 的**锁。→ `forge/lock`（fence token + 全 Lua + 自动续租）。
2. **redlock 错误被丢弃**——`t.redLock.Lock(...)` 不判错是常态（`services/task/common.go:283`、`services/point/point.go:241`、`services/playlet/cron.go:1118`）。Redis 一挂，这些路径全部**无锁裸奔**。2.0：拿不到锁就拒绝请求。
3. **两套并存的资金写法**（读改写整行 vs SQL 内自增）——前者静默覆盖并发对宽行其他列的修改。2.0 只允许 `ApplyMutations`。
4. **设计上允许负余额**（`models/user.go:1117`，充足性检查被注释掉）。2.0 用 `CHECK (available >= 0)`，另开后台冲正通道。
5. **`numeric` 不带精度**（大部分金额列），而机器人用 `numeric(20,8)`。统一 `numeric(30,8)`，加一个对裸 `numeric` 失败的 schema lint 测试。
6. **`GenUserId` 的 TOCTOU**：`services/user/user.go:2056` 先 SELECT 5 个空号候选再 INSERT，两个并发注册可能选中同一个，后者唯一约束报错且**不重试**。→ 2.0 保持随机（D11）但换成 sp 那种数据库侧随机步长序列 `next_user_id()`，序列天然唯一、根本不需要重试。
7. **数据权限散落在各 handler**：1.0 是每个后台接口自己判 `Level > 0` 再拼 `inviter_*` 条件（`services/man/user.go:32`），漏一个就是越权，且新接口作者极易忘。→ 收敛成 `VisibleUserScope`，后台按用户维度的查询一律 `Apply` 它。
8. **明文密钥入库**：`users.otp`（TOTP 密钥）、`verifications.number`（身份证号）。两个都加密。
9. **回调不验签**：Sumsub webhook 完全不验（`services/user/sumsub.go`）。Sumsub 删掉了，但这条纪律对 futurepay 的每一个回调都成立。
10. **硬编码阈值旁边躺着注释掉的测试值**（`models/user.go:29-68`）。手滑取消一行注释就是生产事故 → 配置化。
11. **`time.Truncate(24h)` 做日截断**（`services/point/point.go:216`）——按 UTC 对齐，UTC+7 会截到前一天 17:00；同一行还用 `now.Weekday()` 把周日当周首 → 改用 `now.With(t).BeginOfDay()/BeginOfWeek()`，并全局设 `time.Local = Asia/Jakarta` 与 `now.WeekStartDay = time.Monday`（§5.2）。
12. **RPC 内同步 6 次等级重算**（`models/user.go:1556`，7 处调用）→ 队列。
13. **计算与副作用缠绕、错误被吞**（`models/user.go:2059` 在写等级之前 `return`）→ 纯函数 + 事件队列。
14. **真实密钥进了入库文件**：`contrib/playd.yaml.example` 含阿里云 AK/SK、TikTok / Google OAuth secret、DB 密码、Stripe key；`contrib/google/*.json` 是入库的 GCP 服务账号密钥（`.gitignore` 只挡 `*.yaml`）。2.0 的 example 只放占位符 + `make secret-scan`。**这条是对现有仓库内容的观察，凭据轮换建议独立于本项目处理。**
15. **假的增量计数器**：`robots.supporting_user_count` / `total_support_user_count`，1.0 自己标了废弃。不要重建。
16. **结算时实时读费率**（`services/task/common.go:299`）→ 购买时快照。
17. **`inviter_fans[1:5] @> ARRAY[...]` 走热路径**，而且 GIN 表达式索引**没法跟等级之类的列条件组合**、只能给候选集再逐行回表——这才是那条 7 秒查询的真正成因，加了 4 个索引都没救回来 → 热路径改增量聚合，需要遍历子树的场景改走 `referral_edges` 边表。
18. **两个用户加锁不排序**（`services/point/point.go:241`）——A→B 与 B→A 并发死锁 → 按 id 排序。
19. **`FansMap` 硬编码邀请码特例**（`models/user.go:71`）→ 配置或删除。
20. **cron 精确时间戳匹配**（`services/playlet/cron.go:1099`）——漏跑一天那批持仓永远不到期 → 范围条件 + 追赶。
21. **迁移文件引用当前 model 结构体**（`migrations/*.go` 用 `CreateTable().Model(new(models.X))`）——反射的是**当前**模型，历史迁移会随模型漂移 → forge/migration 的纯 SQL 文件。
22. **验证码 3 分钟缓存的判断是反的**（`services/user/job.go` 的 `smsCache`）——只在 WhatsApp **成功**时写入，守卫却是「没命中才发 WhatsApp」，实际效果是上次成功过的号码 3 分钟内直接走短信；而重发窗口 `message.again` 是 1 分钟，用户点重发必烧一条短信。而且是进程内 go-cache，重启失效、多副本各算各的。
23. **`pubsub.Broadcast` 静默丢验证码**——实现是 `select{case ch<-v: default:}`，listener buffer 5 / pool 10，每次 WhatsApp 占住 worker 3 秒。并发 >15 时消息被直接丢弃：redis 里码已写好、RPC 返回成功、日志一行都没有。→ forge/jobqueue。
24. **`whatsappSender.Debug()` 无条件开启**（`services/user/init.go:44`）——resty 全量 dump，**手机号、6 位验证码、HMAC `Authorization` 头全部落进日志**。
25. **`internal/client/ali` 用 `os.Setenv` 传凭据**给 `playd/upload.go` 的 OSS 读——编译器看不见的跨包耦合。→ 装配层显式构造并注入（§5.10）。
26. **三方登录里的硬编码**：`internal/client/third/discord.go` 写死 client secret，还写死 `http://127.0.0.1:7897` 代理（Discord 登录在任何没开代理的机器上都不可用）。功能整块删除，但**那个 secret 已经在 git 历史里，建议独立轮换**。
27. **裸函数式客户端每次调用新建 `&http.Client{}` 且无超时**（`subtitle` / `third` / `monetapay` / `inthebag`）——供应商慢一次就无限挂住。→ §5.10 的 Client 约定。
28. **`SendCode` 不受日发上限约束**——`message.daily` 只在 `SendMessage` 里检查（且用 `>` 而非 `>=`，实际放行 21 条），认证态重发路径完全不限量；计数 hash `messages:code:count:<date>` **从不设 TTL**，永久堆在 redis。
29. **snowflake 的两类运维故障**：`snowflake.node_id` 配重（两个实例同号）会直接产生重复 ID，靠唯一约束报错才发现；而 node 在 1.0 是 `container.AsyncGet` **异步注入**的（`models/service.go`），就绪之前插入会拿到零值 node。→ D17 用 identity，这两类故障都不存在了。
30. **8 种 `robot_type` 里 4 种在线数为 0**（普通 / 到期返 / 申购 / 智投，共 37 个机器人全部下架），却各自带着专属代码路径；在线的 4 种里还有两对行为几乎相同。把类型枚举当行为开关用，正是它从 1 种长到 8 种的根因。→ D18 全部降级成列。
31. **kirimsms 客户端不看 HTTP 状态码**——只判 `response_status.code != 0`，一个返回非 JSON 体的 500 会被当成成功且 `taskId` 为空。`sender_id` 还写死成 `"ShortPro"`。
32. **资金流水只记 available 一个桶**（1.0 的 `PointLedger` 只有 `after`）——「提现申请冻结 / 出款扣冻结 / 驳回解冻」这三类动作在流水里根本表达不出来，客服查「钱去哪了」只能靠猜。→ 双桶 delta + before/after + CHECK 约束。
33. **`total_out` 把提现申请也算进去了**，一旦驳回就永远偏高。冻结是桶间转移，不是出账。
34. **`robots` 三个上限的哨兵值不统一**：`buy_limit`/`run_limit` 用 `0` 表示不限，`total_amount_limit` 却用 `-1`（`NOT NULL DEFAULT '-1'`）。按直觉填 `0` 就变成"上限 0 元、谁都买不了"。→ 2.0 统一 `NULL` = 不限。
35. **`robots.last_buy_at` 的名字是错的**：它是**停售截止时间**（`WHERE last_buy_at > now()`），不是"最后一次被购买的时间"。→ 改名 `sale_end_at`，与 `sale_start_at` 成对。
36. **`robots` 有 5 个从未被填过的展示字段**（`background_image` / `button_image` / `details` / `intro` / `fold_title`，全表 0/108）。照搬只会让后台表单多五个永远空着的框。
37. **提现的二次校验可以被完全跳过**：`if user.PayPassword != ""`（`services/point/pay.go:61`）——没设支付密码的用户提现不做任何校验，而支付密码本来就是可选的。→ 2.0 取消支付密码，改用**强制绑定的 OTP**，没有「未设置就跳过」这个分支。
38. **`UPDATE ... RETURNING` 看到的是更新后的值**：`SET claimed_income = total_income RETURNING total_income - claimed_income` **恒为 0**。PG 18 才有 `OLD.` 前缀，PG 17 必须用 CTE 先捞旧值。
39. **活跃值增减不对称，只涨不跌**：加的时候三处全是无条件 `active_value + ?`（`tips.go:768 / 3555 / 4750`），到期扣的时候却卡 `user.Type == NORMAL && payment == INTEGRAL`（`cron.go:1135`）——非普通用户、或非硬币支付的持仓**加了永远不扣**，活跃值永久虚高，等级跟着虚高。
40. **活跃值的金额基准有两套**：普通机器人按原价 `totalAmount` 加（注释还专门写了「活跃值得按原价计算」），等级机器人按折后价 `paymentAmount` 加；而到期一律按原价 `ledger.TotalAmount` 扣 → 走折扣买入的用户**加折后、扣原价**，被净扣，活跃值可能变负。

### 8.2 必须保持一致

1. **`fans_code = F<id*2+1>` 及其逆函数**（`internal/utils/utils.go:202,211`）——这些码印在已经发出去的邀请链接上。
2. **5 级上限**出现在每一个聚合里。
3. **`EvalTeamLevel` 的判定顺序与阈值**：团队活跃值 50/200/1200/7000/40000/300000；直属 LV2+ 人数 3/10/20/35/50；团队 LV2+ 人数 60/350/2000/10000。顺序有意义——后面的分支会覆盖前面的结果，且第 2 步比较的是用户**自己当前等级**而不是 LV2。
4. **上级等级不得低于下级，否则返佣链断**（`services/task/common.go:404` 的 `break`）。
5. **`highest_*` 语义**：任何「首次达到 LVn」的一次性奖励，判断依据都是**变更前**的历史最高，而不是当前等级——防止降了再升重复领（`models/user.go:1821` 的 `oldHighestRank`）。团队等级与 VIP 等级都适用。
6. **等级序 `FREE < VIP < ONE < TWO < THREE < FOUR < FIVE < SIX`**（`models/user.go:745`）必须在枚举重编号后存活——写显式映射测试。`tips_level → team_level` 只是改名，rank 语义一字不动。
7. **`fans_*` = 第 1 级下级、`team_*` = 第 1..5 级下级，两者都不含自己**；自己的量一律在 `personal_*`。判定里需要 1.0 那个「含自己的团队活跃值」时显式 `personal + team`，不要把自己混进存储。
8. **直属粉丝的人数门槛是动态的**（≥ 自己当前等级），团队的是固定的（≥ `qualified_level`）。这个不对称是 1.0 的真实规则——不要"顺手统一"成同一个门槛。
9. **`Mutation.Amount` 恒为正数**，方向由 `Kind` 决定。任何"调用方自己传负数"的写法都要拒掉——1.0 就是靠传有符号金额，冻结相关的符号错误查了很久。
10. **签名不是认证边界**：密钥是 `deviceId` 的切片，而 `deviceId` 明文在请求头里。涉及资金或权限的判断一律以令牌里的 `user_id` 为准，绝不信请求体传来的 `user_id`。
11. **不选方案 = 永不解锁**。`scheme = 0` 是稳定终态，任何 cron / 后台任务都不得推进它。系统不替用户做解锁决定。
12. **加速方案的资格是 `restructure_right`（永久），不是「当前有持仓」**。资格类判定必须单调，否则用户得掐着持仓未到期的窗口去点按钮。
13. **方案只升不降**：`REGULAR → ACCELERATED` 允许（需资格），反向与重选一律拒绝；升级时 `next_unlock_at` 取 `LEAST`，加速动作不能让解锁时间变晚。
14. **解锁的天数与比例一律走 `configs` 实时读**，不硬编码也不快照到用户行——运营要「全员下一期解 100%」只能靠改比例，没有别的手段。代价是改配置即刻影响全部存量用户，所以后台配置页要有二次确认，且改动必须进管理员操作日志。
15. **上限字段的名字必须同时表达「度量」和「作用域」**：份数 / 笔数 / 金额，每用户 / 全平台。`robots` 的 `max_*_per_user` 与 `max_total_amount` 是这条的落地，新增上限字段照此命名。
16. **`NULL` = 不限**，禁止再用 `0` 或 `-1` 当哨兵。
17. **`available_schemes` 由服务端算**，客户端不得自行判断资格——同一份规则两端各写一遍迟早对不上。
18. **活跃值恒等于生效中持仓本金**（`personal_active_value == SUM(holdings.total_amount WHERE EFFECTIVE)`）——持仓到期时本金退回可用余额、活跃值同刻等额扣减。
19. **业务时区 Asia/Jakarta (UTC+7)** 是日 / 周边界（`models/service.go:13` 的 `models.Local`）。
20. **bcrypt 登录密码原样复制**，老用户登录不能断。（支付密码不在此列——2.0 没有这个概念）
21. **代理商 / 业务员的可见范围**：只能看自己名下用户（完整链，不限层数）。这条在 1.0 是分散实现的，2.0 收敛后**行为必须一致**——迁移校验里要抽样比对名下人数。
22. **等级高低的顺序** `FREE < LV1 < … < LV7`。D16 只改枚举编号让顺序自然成立，**判定阈值与 rank 语义一字不动**；写一张 1.0 枚举 ↔ 2.0 枚举的双向映射测试锁住对照关系。
23. **验证码的重发窗口与日上限语义**（`again` / `daily`），以及测试号后门（`message.tests` 正则 → 固定码、跳过真实发送）。这几条是运营和 QA 在用的，改了会有人来问。
24. **kirimsms 的印尼干线 0 归一化**（`620…` → `62…`）——这是 1.0 六个短信客户端里唯一有价值的号码处理，必须带走。
25. **机器人的行为必须由列决定，不能由 `category` 决定**。`services/` 里出现 `switch robot.Category` 就是在重走 1.0 那条从 1 种类型长到 8 种的路。
26. **LV2 判定的两个条件考的是不同的事**：`有生效持仓`（存在性）+ `个人活跃值 >= 50`（数量）。不要因为"看起来重复"就合并成一个——合并会改变判定结果。
27. **审计 / 流水表的 ID 单调但不连续**（D17：事务回滚不回退序列）。**ID 空洞不代表丢数据**——任何按 ID 差值估算业务量的脚本或告警都是错的。

---

## 九、验证方式

| 层次 | 做法 |
|---|---|
| 单元 | `EvalTeamLevel` 黄金用例表（含 1.0 生产快照抽样的期望值）；`GetDayRate` 三种模式边界；自然日 / 自然周边界跨夏令时与跨年（含 DB `date` 与 Go 侧时区一致性） |
| 事务 | P2 出口的并发压测脚本：N goroutine 随机增减 → 收尾对账 `SUM(ledger) == balance` |
| 状态机 | P5 出口的表驱动用例（10 个场景，见分期表）；解锁引擎用可注入时钟，不 sleep |
| 集成 | staging 上跑迁移数据，串一条完整链路：迁移用户旧密码登录 → 支持机器人（得重组资格 + 加速 X 天）→ 选加速方案 → 时钟推 30 天 → 首期解锁 5% → LV1 兑 5 币 → 额度锁 → 升 LV2 补兑 → 下周重置 → 下期解锁 10% |
| 对账 | 夜间 cron：`SUM(asset_ledgers.available_delta) == user_assets.available` 且 `SUM(frozen_delta) == frozen`（两个桶各自恒等）；`SUM(active_value_ledgers.amount) == personal_active_value == SUM(holdings.total_amount WHERE EFFECTIVE)` 三者必须恒等；团队活跃值 GIN 全量重算 vs 增量值比对，漂移只告警；`legacy_vaults` 总额 vs `legacy_unlock_ledgers` + `legacy_exchanges` 总额 |
| 投递 | `message_deliveries` 的四条路各一个用例（快探命中 / cron 收尾 / 明确失败立即兜底 / 超时兜底），WhatsApp 与短信客户端均用 `httptest.Server` 打桩，不打真实供应商 |
| 错误 | 每个 `error/v1.Code` 都有传输映射（默认 400/InvalidArgument + 分组提升），加一个「新增 code 自动安全」的表驱动测试 |
| 迁移 | `migrate1to2 --verify` 的 7 项检查，割接前全绿 |

---

## 十、P0 首批文件

- `AGENTS.md` — 照 `$STOCK/AGENTS.md`：进程表 / 端口表 / 职责边界 / 红线，只写约束不写教程
- `docs/目录结构规范.md` — 照 `stock/docs/目录结构规范.md`
- `apid/apid.go` / `mand/mand.go` / `workerd/workerd.go` — forge provider 编排清单（§3.3）
- `api/buf.yaml` + `api/buf.gen.yaml` + `api/{base,error}/v1` — 照搬 `$SP/api/` 的布局与生成配置；`api/error/v1/mapping.go` 抄 default-then-override 传输映射；`api/base/v1/enum.proto` 定顺序化的 `TeamLevel`（D16）
- `internal/rediskey/` — 每个 key 注释「谁写 / 谁读 / TTL / 丢失后果」，`key_test.go` 锁字面量
- `models/config/registry.go` — 类型化配置注册表 + forge/dbcache tiered
- `migrations/migrations.go`（`//go:embed all:default`）+ `migrations/default/0001..0007_*.{up,down}.sql` — §4 的 20 张表全量建完，按领域切分（§3.4）；不搬 1.0 的 76 个迁移文件
- `cmd/migrate/main.go` — 照 `$SP/cmd/migrated/main.go`，≤40 行
- `internal/notify/wa/` — `Sender` 接口（Send 返回 providerMsgId + QueryStatus），Mekari 为第一个实现
- `internal/pay/future/`、`internal/client/subtitle/` — 按 §5.10 的 Client 约定重写（ctx / 具名 struct / typed error / 单例）
- `Makefile` — `generate` / `check`（生成物不入库，check 验无漂移）
- `configs/common.yaml` — `database.{default,slave}` + `redis.{default,token}` + `business.timezone: Asia/Jakarta`
