2.0改造方案.md
旧文件/2.0改造方案.md · 167.1 KB · 2026-09-22 15:07:00
原始文件 下载

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

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


一、背景与目标

1.0(splay)业务能跑,但结构已经压不住了:

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

已定决策

# 决策 影响
D1 全量预迁移 + 用户在 2.0 内选方案。割接前把 1.0 全量数据搬进 2.0 库;1.0 资产落成冻结金库(scheme=0,不倒计时);用户在 2.0 里选方案才开始解锁。1.0 割接后只读。 迁移工具是一次性批量任务,不需要长期跨库读
D2 金库口径 = users.available_pointfrozen_point(提现在途/机器人冻结收益)与 consume_point(体验币)不计入。 割接前须先强平所有 EFFECTIVE 持仓、结清在途提现,把钱落回 available
D3 三进程:api(多副本)/ man 后台(多副本)/ worker(含 cron,单副本) worker 单副本 ⇒ cron 不必抢锁;但幂等仍靠 DB 唯一键兜底
D4 主键 users.id 直接就是业务 ID(取消 1.0 的 id/user_id 双 ID),所有表的外键一律引用 users.idfans_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。错误体系照 $SPapi/error/v1.Error{code, message, metadata} + forge/errkit,message 是英文开发者摘要,客户端按 code 分支、自己渲染文案 零行为变化:1.0 的 contrib/i18n/active.en.yaml0 字节,1504 处 Errorg 今天返回的 message 全是空串,每次还多打一行 error 日志
D14 短信只补 kirim 一家接进 forge/messageWhatsApp 不进 forge/message(它没这个抽象),单独定 WhatsAppSender 接口 + Mekari 实现 见 §5.7
D15 客户端封装分两类:裸 HTTP 的自己封 Client structinternal/client/subtitleinternal/pay/future);已有官方 SDK 的不再包一层,直接在装配层初始化并注入(阿里云 VOD / OSS) internal/client/{ali,third} 删除;third(三方登录)整块下线
D16 团队等级枚举重新编号为顺序值TEAM_LEVEL_UNSPECIFIED=0TEAM_LEVEL_FREE=1TEAM_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/futurepayinternal/pay/future 重写 保留签名 / 双 key / 终态判定,改成带 ctx、typed error、恒定时间验签,见 §5.10
internal/client/subtitle(GhostCut 字幕翻译 + 去字幕) 重构成 Client struct 现在是裸函数 + 每次 &http.Client{} 无超时 + map[string]anygjson 出,见 §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_sizenext_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/redlockDEL 释放,会删别人的锁;错误还常被丢弃) forge/lock fence token + TTL 自动续租,加/续/解锁全走原子 LuaUnlock 校验 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 大致长这样:

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,加 CronProviderApp.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)

# 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: tokenlock/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 名:

// 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 不配对也报错。

CLIcmd/migrate,形态就是你说的那样——

migrate <db> <action>

action 由 migration.IsAction 白名单校验,共 6 个(migrateuprollbackdown 是别名,前者是 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 的一个子命令的原因:

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

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

k8s 里就是一个 Job(或 initContainer),镜像与服务同一个,command: ["migrate", "default", "up"]

3.5 其他骨架决定


四、数据模型

图例:[照搬] / [改造] / [新增]

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

-- [改造] 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 层边表管业务

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

-- [新增] 派生边表:只收 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 级,放开就是多发钱。这也正是为什么一个用数组、一个用边表。

-- 代理商 $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,禁止各处手拼:

// 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 即可。

-- [新增] 凭据类,一行一用户
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_TIKTOKservices/user/user.go:1042),所以后台按 Google 查永远是空的。既然功能下线,这批数据不迁移。

4.2 KYC —— [改造],去 Sumsub

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):

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 的两步(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 资产 —— [改造],全项目唯一的变动入口

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);
// 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 在入口就不存在:

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 恒为正):

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 >= $aMutationExpenseavailable >= $aIncome / 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)

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:1821oldHighestRank),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


-- [新增] 等级变更审计——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:745tipsLevelMap 换算成 rank:

// 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 直接把枚举编成有序的,顺序即大小

// 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] + 1EvalTeamLevel 内部仍按 0..7 的 rank 运算(判定表照抄 1.0 更直观),只在读写边界 ±1。写一个显式的双向映射测试锁住这张对照表。

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

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 里开行。金库这一行就是余额:

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

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

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_limit0 表示不限,而 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 NULLtips.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:3192robot.Type == 判断)。D18 取消 robot_type 后这个 gate 没了依据——2.0 改成只要 max_orders_per_user IS NOT NULL 就生效,对所有机器人一致。这与"行为由列决定、不由类型决定"是同一条原则。

version_typeis_trial boolean:1.0 的 Robot_VersionType 只有 BETA / STANDARD 两个取值(全表 3 个 BETA、105 个 STANDARD),唯一用途是统计用户买过几个机器人时排除体验版(tips.go:3406WHERE 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→dramasPlayletEpisode→drama_episodesPlayletGroup→drama_groupsUserPlayletLedger→user_drama_ledgersUserPlayletCollect→user_drama_collectsUserPlayletEpisode→user_drama_episodesAdRecordSubtitlePlayletMaterial→drama_materialsPlayletView→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:328playlet_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 —— [照搬] 表形,[新增] 注册表

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 策略

实现方式照搬 ../spnext_user_id()——数据库侧的随机步长序列,比"随机取值 + 撞了重试"更干净:

-- 起点随机,每次自增一个 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 位空间。)

5.2 时间口径

不自建 timex,直接用 github.com/jinzhu/now(1.0 的 go.mod 里本来就有 v1.1.5),配两处全局设定即可:

// 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

用法就是标准写法,不包一层:

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 取两者较早

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:

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——它只是把"能选加速"这张票发给用户,切不切由用户自己再点一次。这是"方案必须主动选择"的直接推论:系统不替用户做解锁相关的决定。

选择方案接口

// 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 * * * *

-- 批量取到期的(命中部分索引)
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

{
  "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_ratiofirst_ratio 都改成 "1.0"
所有人节奏从 30 天缩到 15 天 period_days: 15(已排期的 next_unlock_at 不变,从下一期开始按 15 天走)
只加速某些人 ApplyUnlockAdjustment(按人减天数,留痕)
立刻全额释放某批人 UnlockLegacyAll(按人发任务,留痕)

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

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

加速事件

// 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)。同一事务里还要做首次买入的三件事(见状态机的「首次买入判定口径」):

-- ① 授予重组资格(幂等:只有 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+1amount = frozen_total - unlocked_totalsource=EVENT_FULL 处理,置 completed_at不做整表 UPDATE——那会长时间锁表且一条审计都不留。

兑换(1.0 → 2.0,1:1 单向)

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

一个事务,固定加锁顺序(金库 → 额度 → 资产):

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,用户升级后立刻就能看到新额度。因为是 GREATESTVIP 降级(D7)永远不会回收已授予的当周额度

VIP 档位配置(key level.vip,随时可调):

{"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 的 CheckUpperSupportLevelmodels/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 的个人活跃值变化 Δ 时,同事务内

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:

// 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 的 teamActiveValueSUM(1..5 级) + 自己models/user.go:1179 显式 .Add(u.ActiveValue)),而人数 totalAboveLv2 不含自己——两个 team_* 口径不一致,名字上完全看不出来。2.0 统一成「team_* 一律不含自己」,判定时显式相加:

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):

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

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

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

// 直属中 >= 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」就是数组切片)、增量维护是原地元素更新且原子:

-- 某个直属粉丝从 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 的真实规则,不是设计出来的,照搬。

ActiveHoldingCountPersonalActiveValue 不是同一个量

这两个容易看成一回事,实际差三层:

1.0 playlets → 2.0 ActiveHoldingCount 1.0 users.active_value → 2.0 PersonalActiveValue
量纲 SUM(share) —— 份数 累加 total_amount —— 金额
口径 查询带 status=EFFECTIVE AND payment=INTEGRALmodels/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、到期时等额扣,于是恒有:

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>),并加一条夜间对账:

-- 两条都必须恒等。不等就说明上面三条铁律里有一条被破坏了
-- ① 流水求和 == 物化值
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 >= 2directAtOrAbove(rank) < 3 teamAV < 200rank = 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_* 判定后填入:

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:定义 / 读 / 写 / 缓存失效 / 测试的完整约定与代码骨架。

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

类型化注册表

// 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 只有一个 WithTtlCache.Set 拿它同时写 l1 和 l2($FORGE/dbcache/store_tiered.go:63),分层 TTL 表达不出来。唯一的不对称是 L2 命中回填 L1 时用写死的 tieredRefillTtl = 60s(同文件),不可配——所以最坏本地陈旧窗口是 60s。配置总共几十行、回源是主键单行查询,30s 带来的 DB 压力可以忽略。

后台面

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

5.6 机器人收益

// 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:CheckRobotIncomeSettlementservices/task/common.go:299)结算时实时读 robot_daily_income_rates,后台改费率会追溯性改掉已售持仓的收益。

结算services/holding/cron.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 passUPDATE ... 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,行为不变)。领取本身:

-- ★ 必须用 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 的 checkRobotTipsStatusservices/playlet/cron.go:1099)按 tips_income_end_at = <精确时间戳> 匹配,cron 漏跑一天那批持仓就永远不到期;2.0 改成范围条件 + 追赶。

robot_daily_income_rates 迁移

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

robots.rate_scheduleyield_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 的函数调用。

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 只作兜底

关键性质:

渠道决策可配

// 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,单独定接口:

// 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.gokirimSender 内嵌 regionFilter(白送 Accepts),cfg.build() 实现 smsSpec
  4. 实现 Send/Mode/NameSend 必须在 HTTP 200 时也检查业务码——1.0 的 kirimsms 客户端只看 response_status.code != 0,不看 HTTP 状态,一个返回非 JSON 的 500 会被当成成功且 taskId 为空
  5. provider/message.gosmsProviderYaml 加字段 + buildSmsOptscase "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_limitmax_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)

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,涉及四处跨表引用:

场景 依赖关系 写法
ApplyMutationslegacy_exchanges.asset_ledger_id 先有流水才有兑换记录 流水插入 Returning("id")ApplyMutations 把带 id 的 []AssetLedger 返回给调用方
CreateHoldingApplyUnlockAdjustment(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
FuturePayinternal/pay/future Client struct 同上,裸 HTTP + 自定义签名
Mekari WhatsAppinternal/notify/wa/mekari Client struct 同上,HMAC 签名
三方登录internal/client/third 整块删除 功能下线

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

// 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 加密后下发,由 checkPlayUrlCronGetPlayInfo 批量回填。2.0 沿用这条,VOD 只承担摄入侧(上传凭证 / 转码进度 / 源文件 / 时长封面)和给字幕服务取源 URL。这意味着未来真要切 CDN,动的是回填 cron 这一处,不是 46 个调用点。

subtitle / future / mekari 的统一 Client 约定

参考 $FORGE/message 的后端写法,四条硬约定:

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.goservices/point/init.go 各建了一个,同进程两个 resty 连接池。

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

subtitle 包另外要修的:DownloadFile 挪出去——它是个通用文件下载器,还硬依赖 viper.GetString("static.dir"),不属于 GhostCut 客户端;translateerase 拆成两个具名方法,而不是靠 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

// 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.godefault-then-override

// 每个 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 200playd/http.go:110),而 checkHandler 中间件走的却是 400,同一个 API 两套约定;forge 的 GatewayErrorHandler 统一按 errkit.ToHttpStatus 返回真实状态码。

明确保留的:内容多语言

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

Playlet.Titles/Descriptions/Covers/BannerCoversPlayletMaterial.Titles/DescriptionsPointPack.NamesTipsParameter.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_ledgersstatus=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_idtype/status/nickname/avatar/comment/created_at 直搬;is_migrated=true, migrated_at=now()。 - user_credentialspassword 原样复制(同一 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_atverified_meta 位掩码推导;第三方登录不迁移(D15,功能下线):third_logins 表、ThirdLogin* 枚举、4 个相关 RPC 全部不建。只保留 email / mobile 两种 identity。 - type=ADMIN 的行 → admins 表,不进 users。 - ⚠️ 登录令牌不迁移(forge/token 是 access/refresh 双令牌体系),割接后用户需重新登录一次,写进上线公告。

S2 关系链users.inviter_ids / parent_idreferral_edges

-- ① 数组(真源):完整链、全类型、不截断层级
--    "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 KYCuser_kyc - 搬 verificationsstatus=PASSED 的全部行 + 每人最新的 PENDING;numberid_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_ratesrate_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_edgesinviter_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_deliveriesnext_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_changesEvalTeamLevel 纯函数 + 黄金用例;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_amountNULL=不限各要有用例);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;ApplyUnlockAdjustmentExchangeLegacy;后台全量解锁活动;全部前端接口。迁移 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:283services/point/point.go:241services/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 的 TOCTOUservices/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/Jakartanow.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/*.goCreateTable().Model(new(models.X)))——反射的是当前模型,历史迁移会随模型漂移 → forge/migration 的纯 SQL 文件。
  22. 验证码 3 分钟缓存的判断是反的services/user/job.gosmsCache)——只在 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/alios.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_limit0 表示不限,total_amount_limit 却用 -1NOT 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 == INTEGRALcron.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:404break)。
  5. highest_* 语义:任何「首次达到 LVn」的一次性奖励,判断依据都是变更前的历史最高,而不是当前等级——防止降了再升重复领(models/user.go:1821oldHighestRank)。团队等级与 VIP 等级都适用。
  6. 等级序 FREE < VIP < ONE < TWO < THREE < FOUR < FIVE < SIXmodels/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_atLEAST,加速动作不能让解锁时间变晚。
  14. 解锁的天数与比例一律走 configs 实时读,不硬编码也不快照到用户行——运营要「全员下一期解 100%」只能靠改比例,没有别的手段。代价是改配置即刻影响全部存量用户,所以后台配置页要有二次确认,且改动必须进管理员操作日志。
  15. 上限字段的名字必须同时表达「度量」和「作用域」:份数 / 笔数 / 金额,每用户 / 全平台。robotsmax_*_per_usermax_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:13models.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.availableSUM(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 首批文件