zizi 1b3e375a68 docs(arch): 历史回收判定落档——约 60 条捡回项写进 21 份现行设计档
按 _recall/创始人判定.md(创始人 2026-06-22 逐条判定),6 域并行 additive 落档,
每处标来源、人读散文、涉契约者给 contract-first 待落地清单:
- 产品:商业定位补竞品现任者/IP 关键人风险/增长冷启动/验收北极星;护城河话术补
  腰尾部 IP/真资产 reframe/自有吉祥物红线;Doc A 就地改格(P-PUB-01→P1、
  P-OPS-01→P0-lite,ACC 进 P0↔PUB 出 P0 保住 55 锚)+ P-CRT-02 注记每类 workflow
  生成/rig 留 premium 远期/补 P-id
- 前端:README §9 前端域承载(admin 审核台四页+观测三入口/C 端收益页溯源条 AIGC
  标识/43 口径引用/移动端适配/Debug 插件)
- 后端:数据模型 §7 待落地缺口(026 game_id/029 留存/030 锁风 JSON/031 信用台账/
  绘豆)、鉴权与权限 032 封号级联+018 短信防护、契约总览 Pact/Registry 远期
- 生成引擎:029 硬门 A-E 回填验收门+OpenGame对照/031 Phase 路线/033 创作期回灌/
  035 反锁死/036 harness 护城河/037 整图串行/001 对话式/002 透传/007 绘豆预算
- 运维:观测体系 §3.7 网关健康巡检、README §5/6/7 job 调度面/flag 纪律/数据可靠性
- 运营:渠道发行广告运行时红线/审核台事中召回/变现端到端资金切换护栏/变现与单位经济
  月增速+eCPM 口径统一+成本漏算+成本假设待测

contract-first 待落地项均标"勿当已建"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:08:05 +00:00

26 KiB
Raw Blame History

变现端到端

这是什么:绘境AI 三条变现线的工程钱流地图——钱从玩家的一次广告、一笔订阅、一单定制出发,经过哪些表、哪些后台 job、哪些跨模块接缝、哪些回调,最终落到创作者钱包或平台营收。 给谁看:变现链路的后端工程师、运营,以及要接真实支付与广告渠道的对接人。 怎么读:先看 §1 那张全景图认清三条线和两套钱包,再按线深入——广告分成线最长(§2),订阅(§3)、B 端(§4)各一段;§5 是控制平面(谁在什么时候推动钱流),§6§7 是改钱流代码时要对照的不变量与对账锚点,§8 是现状边界与接线点。 变现与单位经济的分工:那份算账——分账公式、eCPM 敏感性、回本 DAU、这门生意成不成立;本份讲钱具体怎么流——经哪些表、哪些服务、哪些 job 与回调。引用代码处都带 file:line 锚点,以 dev/2.0.0 分支为准。


1. 一张图看懂:三线钱流与两套钱包

广告分成、订阅会员、B 端定制三条线,钱的进法、经过的表与服务、终点各不相同,成熟度也不同。

flowchart LR
  subgraph AD线["广告分成线 · 已建"]
    A1["玩家看广告<br/>ad 计费"] --> A2["T+1 结算<br/>SettlementJob"]
    A2 --> A3["入账<br/>game_trade_income"]
  end
  subgraph SUB线["订阅会员线 · 入账已建/收单未建"]
    S1["admin 赋订阅<br/>(自助购买未接 pay)"] --> S2["幂等账本<br/>subscription_grant"]
  end
  subgraph BIZ线["B 端定制线 · 仅状态机"]
    Z1["询单 → 报价<br/>→ 进度 → 验收"] --> Z2["签署<br/>(收款/分账留 M4)"]
  end
  A3 --> WALLET["创作者收益钱包<br/>game_trade_account"]
  S1 --> WALLET
  WALLET --> WD["提现 → 异步打款<br/>(真渠道 mock)"]
  A3 --> REPORT["平台营收报表<br/>trade/report/revenue"]
  classDef done fill:#e6ffe6,stroke:#3a3;
  classDef stub fill:#fff7e6,stroke:#d90;
  classDef none fill:#fee,stroke:#c33;
  class AD线 done;
  class SUB线,BIZ线 stub;
  class WD stub;

三条线在创作者收益钱包 game_trade_account 上汇合,平台那一侧另有一张营收报表。当前真实终点的成熟度要分清:广告计费、T+1 分账、入账、订阅赋权这几段都已落地并真跑;提现走到打款时,真实微信/支付宝渠道还没接,终点是 mock;B 端的收款与分账整段留在 M4,现在只走到状态机加线下签署兜底标记。

模块分工是这样切的:广告分成线跨两个模块,ad 管"造钱 + 台账"、trade 管"分账 + 钱包 + 提现";订阅会员线由 trade 独管(V21 三表);B 端定制线由 biz 独管(V13 四表)。

一处必须先纠正的事实:没有 game-module-pay 这个业务模块

读旧文档容易踩一个坑——把 pay 当成一个已经接好的"game 业务模块"。代码里不是这样:

  • 现有的是框架自带的 huijing-module-pay(命名空间 com.wanxiang.huijing.module.pay),它在后端聚合 pom 里挂载(game-cloud/pom.xml:20),提供两个能力 API:PayTransferApi(企业付款转账)和 PayWalletApi(消费钱包)。
  • trade 模块并不依赖它——grep 过 trade-servertrade-api 的 pom,零 module-pay 依赖。trade 自己定义了打款的抽象接口,并没有走 huijing-module-pay。
  • 真实的微信/支付宝打款渠道(wxpay / alipay)还没接线。当前钱流走到打款这一步,终点是 mock。

所以本档凡提到"打款",真实终点都是 mock;真渠道的接线点在哪、零改业务码怎么切到真实渠道,在 §8 讲清。


2. 广告分成线:从一次曝光到创作者提现

这是最长的一条线,跨 ad 和 trade 两个模块,中间隔着一道 Feign 接缝和一个 T+1 的结算 job。

2.1 广告造钱(ad 模块)

ad 模块管的是"把一次广告事件变成一条创作者收入台账"。错误码段 1-111,建表脚本 V6.0.0__create_game_ad.sql

SDK 先拉广告位再上报事件。拉取走 GET /ad/slot/list-enabled(AdController.java:65),上报走三个 @PermitAll 的计费端点:POST /app-api/ad/report/impression/report/reward/reward/callback(AdController.java:74/84/94)。这三个端点对匿名玩家放行,是 2026-06-10 鉴权波的明确裁决——曝光不能因为玩家没登录就丢掉。

计费的核心在 AdRevenueServiceImpl.bill()(AdRevenueServiceImpl.java:139-210),五步走:

sequenceDiagram
  participant SDK as Game SDK(玩家端)
  participant C as AdController
  participant S as AdRevenueServiceImpl.bill()
  participant CK as AdComplianceChecker
  participant P as ProjectApi
  participant DB as game_ad_revenue
  SDK->>C: report/impression · report/reward
  C->>S: bill(traceId, eventType, gameId, ...)
  S->>S: ① 查广告位 enabled
  S->>CK: ② 合规校验(未成年 blockMinor / 单会话 maxPerSession)
  CK-->>S: 超限即不计费
  S->>DB: ③ 幂等查 uk_trace(traceId, eventType)
  S->>P: ④ getCreatorUserId(gameId) 归因到游戏作者
  S->>S: ⑤ mock 算收入 calcRevenue(ecpm)
  S->>DB: ⑥ 落 game_ad_revenue(settle_status=0)

几个要点钉清楚:合规校验(AdRevenueServiceImpl.java:147-153)在未成年 blockMinor 命中或单会话超 maxPerSession 时直接不计费;收入归因靠 ProjectApi.getCreatorUserId(gameId) 把这笔收入挂到游戏作者头上(:163);激励视频(rewarded)类型的曝光不计收入,只有 reward 事件计(:172);最终落库时 settle_status 置 0(待结算),审计列硬兜底成字符串 "0",防匿名玩家撞 NOT NULL(:196)。

台账表 game_ad_revenue(V6.0.0__create_game_ad.sql:54-77)有几处设计值得记住:金额单位是"分",BIGINT;计费幂等键是 uk_trace(trace_id, event_type, tenant),所以同一个 trace 的 impression 与 reward 各落一条、互不覆盖(:73);settle_status 由 0 翻到 1 是 trade 结算后回标的;idx_settle_date 供 trade 扫未结算的行。

广告渠道是可插拔的 SPI:AdProvider + AdProviderFactory,现有 MockAdProviderCallbackAdProvider 两个实现。这条线的降级口径是宽松的——没注册真实渠道时降级到 mock 可以接受,代价只是少算些收入,不影响主链路。(打款那条线的降级口径正相反,见 §2.4。)

admin 侧有两个控制器:AdSlotController 管广告位 CRUD(RBAC ad:slot:*),AdRevenueController 查台账(/ad/revenue/page,ad:revenue:query)。前端 game-admin/src/views/wanxiang/adSlot/ 有广告位的 CRUD 视图。

2.2 ad→trade 分账接缝(Feign)

ad 算完台账,钱还在 ad 的库里;要变成创作者钱包余额,得跨到 trade。这道接缝的契约是 AdRevenueApi(在 ad-api 包,AdRevenueApi.java:46/61),两个方法:getUnsettledRevenue(statDate) 拉某天未结算的台账,markSettled(revenueIds) 把入账成功的行回标。

MVP 是单体,这道 Feign 接缝由 AdRevenueApiImpl(@RestController @Primary)在同进程内就地解析——跨模块只在编译期依赖对方的 -api 包,运行期不走真实网络。@Primary 让单体优先用本地实现而非 Feign 代理。

2.3 T+1 结算

把昨天的广告台账结算成创作者收入,由 XXL-Job 触发。job 名 tradeSettlementJob,带 @TenantJob(按租户跑),入口 SettlementJob.java:39。默认每日凌晨结算昨天(T+1);它接受 yyyy-MM-dd 参数,传一个历史日期就能补结算或重跑某天,靠下游的 uk_source 幂等保证不会重复发钱。

编排逻辑在 SettlementServiceImpl.settle()(SettlementServiceImpl.java:64-115):

flowchart TB
  J["tradeSettlementJob<br/>param=昨天 或 指定日"] --> F["Feign getUnsettledRevenue(statDate)<br/>拉未结算台账"]
  F --> L{逐条}
  L --> R["recordIncome<br/>source=AD · source_ref=ad_revenue.id<br/>gross · creatorShare · traceId"]
  R --> N["首次入账才发 community 通知<br/>notifyIncomeChanged(失败不中断)"]
  N --> L
  L -->|本批入账成功的 id| M["markSettled(ids) 回标 0→1"]
  M -.->|回标失败| NEXT["下一轮 job 补偿"]

逐条调 recordIncome,source=ADsource_ref 记成 ad_revenue.id、带上 gross/creatorShare/traceId。分账比例走 Nacos 的 trade.creator-share,默认 0.80(@Value,:43)。这里有一条关键的补偿语义——先入账,后回标:只有入账成功的那些 id 才回标(:110),回标本身要是失败了,下一轮 job 会把它们当成"仍未结算"再拉一次,靠 uk_source 挡住重复入账。只有首次入账才发 community 的收益变动通知 notifyIncomeChanged(:99),通知失败不中断整个循环。

入账的原语是 IncomeServiceImpl.recordIncome()(IncomeServiceImpl.java:43-78):@Transactional 把"写流水 + 账户余额原子增"放进同一个事务;幂等靠 uk_source 先查后插,再用 DuplicateKeyException 兜底并发;calcNetBigDecimal.setScale(0, DOWN) 向下取整,防浮点(:89)。

2.4 提现 + 打款异步化

创作者点提现到钱真正打出去,是一个跨"申请 / 审核 / 发起 / 终态"四个阶段的状态机。提现表 game_trade_withdraw(V7.0.0__create_game_trade.sql:93-116,后续 V14.0.0__m4_trade_withdraw_transfer_ref.sql 加列 transfer_ref),状态机 5 态,幂等键 uk_biz_no,金额单位"分"。

stateDiagram-v2
  [*] --> 待审_0: applyWithdraw<br/>freeze(balance→frozen)
  待审_0 --> 打款中_1: 审核通过<br/>approveToPaying CAS 0→1
  待审_0 --> 驳回_3: 审核驳回<br/>refundFrozen
  打款中_1 --> 已打款_2: 回调成功<br/>settlePaid
  打款中_1 --> 打款失败_4: 回调失败<br/>refundFrozen
  打款失败_4 --> [*]
  已打款_2 --> [*]
  驳回_3 --> [*]

申请走 app:POST /app-api/trade/withdraw/apply(TradeController.java:88)。WithdrawServiceImpl.applyWithdraw()(:90-133)先校门槛—— Nacos trade.withdraw-min,默认 500 分(满 5 元,:68),再用 bizNo 幂等先查,落单,然后 freezebalance 原子挪进 frozen(带充足校验,余额不够就整事务回滚、不留脏单)。

审核走 admin:POST /admin-api/trade/withdraw/audit(TradeAdminController:86,RBAC trade:withdraw:audit),真实渠道回调入口是 /withdraw/payout-notify(:95)。前端 game-admin/src/views/wanxiang/withdraw/index.vue 有"提现审核"Tab,带 approve/reject 操作。

这一段最硬的红线是"发起 ≠ 终态"的异步化(WithdrawServiceImpl.java:44-50,135-274):审核事务内只做 CAS 0→1(approveToPaying),绝不在审核事务里直接置成"已打款";事务提交之后,才发起真正的转账 initiatePayout(这一步不进事务,:213);资金的终态由 handlePayoutNotify 回调驱动——成功就 CAS 1→2 并 settlePaid,失败就 CAS 1→4 并 refundFrozen(:241-274)。mock 渠道是同步直通的,发起后本进程直接把回调驱动一遍,但仍然走 CAS,不抄近路直接置 2。

打款渠道也是 SPI:PayoutClient + PayoutClientFactory(PayoutClientFactory.java)。它的降级口径和广告渠道正好相反——配置了某个渠道却找不到实现时,fail-fastTRADE_PAYOUT_CHANNEL_UNAVAILABLE,严禁静默降级到 mock 发假钱(:78-87)。原因很直白:广告渠道缺失只是少算收入,打款渠道缺失若降级 mock 就是真把假钱当成功发出去。当前只有 MockPayoutClient 一个实现(grep 证),没有 wxpay/alipay 实现;PayoutClient.java:14/52 的注释写明真渠道应委托 huijing-module-pay 的 PayTransferApi.create/getTransfer,但还没接线。

打款发起后可能卡在"打款中"——网关超时、回调丢失都会让单子停在状态 1。兜底是对账补偿 job WithdrawPayoutCompensateJob(XXL-Job tradeWithdrawPayoutCompensateJob,默认卡单阈值 15 分钟、单批 100):扫 status=1 的超时单,按单子自己记的 withdraw.channel(不是当前 Nacos 配的渠道)取对应 client 查终态、补驱动(compensateStuckPayouts,WithdrawServiceImpl.java:277-312)。按单子血统的渠道而非当前配置取 client,是为了让换了渠道配置之后,历史卡单仍能找回原渠道对账。

2.5 打赏现金线

打赏和广告共用 trade 的入账原语,但分账比例不同。RewardPayoutServiceImpl.payoutPendingCashRewards()(RewardPayoutServiceImpl.java:51-91)从 community 拉待发的现金奖励,调 recordIncome 入账,source=TIPshareRate=1.0——打赏全额给创作者、平台不分成,然后 markRewardGranted 把 community 那边 CAS 0→1 回写。同样是"先入账后回写"的补偿语义。触发器是 RewardPayoutJob


3. 订阅会员线:admin 赋订阅到 C 端查态

订阅完全在 trade 内部,建表 V21.0.0__create_game_trade_subscription.sql,错误码 1-106-006。它现在只接通了"admin 手动赋订阅"这一半,自助购买那一半要等真实支付。

三张表各管一段:

  • game_trade_grant——admin 赋余额的流水,幂等键 uk_biz_no
  • game_trade_subscription——一个用户一行(uk_user),plan 取 1 月/2 季/3 年,有效性以 expire_time 为权威。
  • game_trade_subscription_grant——每笔赋订阅落一行的幂等账本(uk_biz_no)。这张表是 P0 修复加进来的(V21.0.0__create_game_trade_subscription.sql:82-114):订阅主行里只有一个 last_grant_biz_no 字段,它只能挡住"紧挨着的上一次"重放,挡不住任意历史 bizNo 的重放;独立账本才能挡住任意历史 bizNo 重复赋权。

赋订阅的钱流入口在 admin:POST /admin-api/trade/subscription/grant(TradeAdminController:184,RBAC trade:subscription:grant),赋余额另有 /account/grant(:173)。SubscriptionServiceImpl.grantSubscription()(:54-140)先写幂等账本,再 upsert 续期:

flowchart LR
  G["admin grant<br/>bizNo · plan"] --> L["写幂等账本<br/>subscription_grant(uk_biz_no)"]
  L --> U["upsert 续期<br/>newExpire = max(now, 旧expire) + 时长"]
  U --> C["renewByCas 防并发 lost-update"]
  C --> Q["C 端查态<br/>effectiveStatus = expire_time > now"]

续期的算法是 newExpire = max(now, 旧expire) + 时长(:177-209),所以续期会把没用完的时长叠加上去、不丢;renewByCas 防并发的 lost-update。赋余额走 AccountServiceImpl.grant()(:84-133):写 grant 流水 + balance/total_income 原子增,在同一事务里。

C 端只读查态:GET /app-api/trade/subscription/mine(TradeController:103)。没有 cron 去物化"过期"这件事,effectiveStatusexpire_time > now 实时回算(SubscriptionServiceImpl.java:34-36)——少维护一个定时任务,代价是查的时候算一下。

边界要说清:真实支付收单、自助购买订阅是日历闸门约束的事,还没做;当前订阅只能 admin 手动赋,sourceadmin_grant


4. B 端定制线:询单到交付(钱在 M4)

B 端由 biz 独管,建表 V13.0.0__create_game_biz.sql,错误码 1-110。这一波只走业务状态机,完全不碰钱——收款、在线签章、分账整段留在 M4。

四张表:game_biz_lead(询单主单,单线性五态)、game_biz_quote(报价,只展示不收款)、game_biz_progress(进度工单时间线)、game_biz_acceptance(试玩→反馈→确认三态,带 signed_offline 线下签兜底)。主单的状态机是一条单线:

stateDiagram-v2
  [*] --> 待跟进_0
  待跟进_0 --> 已报价_1: 报价
  已报价_1 --> 制作中_2: 接单
  制作中_2 --> 待验收_3: 提交验收
  待验收_3 --> 已交付_4: 确认
  已交付_4 --> [*]

admin 侧 AdminBizLeadController(/admin-api/biz/*)管 leads 队列、指派 assign、报价 quotes、推进 advance、线下签 sign-offline;app 侧 AppBizLeadController(/app-api/biz/*)给客户提单、查我的订单 leads/my、看进度/模板、反馈 feedback、确认 confirm。状态机的合法迁移由 BizLeadServiceImpl 在写前校验。

资金流边界写在建表脚本和配置里(V13.0.0__create_game_biz.sql:6biz.yaml:17):本波只写 biz 自有的四张表,不写 pay、不写 trade;报价不等于可支付的订单,签署(在线电子签章)、收款(对应工作项 T-BIZ-08)、分账全留 M4。现在唯一能标的是 signed_offline——线下签了合同就打个兜底标记。前端 game-admin 还没有 biz/lead 的视图(find 证零)。


5. 控制平面:谁推动钱流

前面几节是"钱从哪流到哪"的数据视角。但钱不会自己流——广告台账躺在库里不会自动变成钱包余额,打款发起了不等于钱到账。推动这些状态前进的是三个后台 job、一道单体内就地解析的 Feign 接缝、和打款那条"发起 ≠ 终态"的双路驱动。

flowchart TB
  subgraph JOBS["三个 XXL-Job(@TenantJob)"]
    J1["tradeSettlementJob<br/>每日凌晨 · param=昨天/指定日<br/>T+1 分账"]
    J2["tradeWithdrawPayoutCompensateJob<br/>卡单阈值 15min · 单批 100<br/>对账补偿"]
    J3["RewardPayoutJob<br/>打赏发放"]
  end
  J1 -->|Feign getUnsettled/markSettled| SEAM["AdRevenueApi 接缝<br/>单体 @Primary 就地解析"]
  J1 --> INC["recordIncome 入账"]
  J3 --> INC
  PAYOUT["打款发起 initiatePayout<br/>(发起 ≠ 终态)"]
  CB["回调 handlePayoutNotify<br/>成功 1→2 / 失败 1→4"]
  PAYOUT -.->|回调到| CB
  J2 -.->|回调丢失时补驱动| CB

三个 job 的调度与 param 约定:tradeSettlementJob 每日凌晨跑、默认结算昨天,传 yyyy-MM-dd 可补结算/重跑;tradeWithdrawPayoutCompensateJob 周期扫 status=1 超过 15 分钟的卡单、单批最多 100;RewardPayoutJob 驱动打赏现金发放。三个都带 @TenantJob,按租户维度跑。

**Feign 接缝的单体解析:**ad→trade 的 getUnsettledRevenue/markSettled 在单体里由 @Primary 的本地实现就地解析,不走真实网络;拆成微服务后换成真 Feign 调用,调用方代码不用改。

**打款的双路驱动终态:**打款只负责"发起",钱的终态由两条路推动——正常路是渠道回调 handlePayoutNotify,异常路(回调丢失、网关超时)是补偿 job 兜底重新查终态。两条路都通过 CAS 改状态,所以重复驱动是安全的:已经从 1 翻走的单子,CAS 会失败、不会二次发钱。


6. 资金一致性不变量(代码锚点版)

变现与单位经济§5 把资金一致性红线讲成了文字;改钱流代码时要对照的是它们钉在哪个方法、哪个 CAS、哪条事务边界上。

账户恒等式。game_trade_account(V7.0.0__create_game_trade.sql:32-47)是物化汇总账户,恒等式 balance + frozen + total_withdraw = total_income 必须永远成立;uk_user 保证一个创作者一个账户;余额的增减是行级原子操作,带充足校验防超扣。维持这条恒等式的是 AccountServiceImpl 的四个原子动作 addIncome/freeze/settlePaid/refundFrozen——它们有一条红线注释(AccountServiceImpl.java:148/160):资金动作更新 0 行就抛错,让外层事务回滚,绝不允许"状态已置、资金未动"的半态 commit

四条红线钉到代码:

  • 发起 ≠ 终态——审核事务内只 CAS 0→1(approveToPaying,WithdrawServiceImpl.java),终态留给回调/补偿,不在审核里置 2。
  • 打款 fail-fast 不降级 mock——PayoutClientFactory(:78-87)找不到配置渠道的实现时抛 TRADE_PAYOUT_CHANNEL_UNAVAILABLE,绝不静默发假钱。
  • 逐笔向下取整,禁总额反推——recordIncomecalcNet(BigDecimal.setScale(0, DOWN),IncomeServiceImpl.java:89)逐笔算净额并向下取整;对账以逐笔为权威(单笔 net == floor(gross × rate)),不能拿 总gross × 0.8 反推总额。
  • mock 不变量先定毛/净——接真渠道之前,要先定 revenue_amount 这一列存的是毛额还是净额,口径一旦定下,mock 和真渠道必须一致,否则切渠道时账面会错位。

七个资金动作的幂等键(可直接复用变现与单位经济§2 那张表,本档补上 source_ref 与 CAS 列):

资金动作 幂等键 source_ref / CAS
广告计费落台账 game_ad_revenue uk_trace(trace_id, event_type, tenant)
广告分账入账 game_trade_income uk_source(source, source_ref, tenant) source_ref = ad_revenue.id
打赏入账 game_trade_income uk_source source=TIP,source_ref = reward id
提现申请冻结 game_trade_withdraw uk_biz_no freeze 充足校验
提现审核 game_trade_withdraw CAS approveToPaying 0→1
打款终态 game_trade_withdraw CAS 回调/补偿 1→2 或 1→4
admin 赋订阅 game_trade_subscription_grant uk_biz_no renewByCas 续期

资金渠道切换要双人复核 + 配置审计(操作红线 · 待落地)。(创始人 2026-06-22 历史回收判定捡回)上面四条红线管的是代码逻辑——钱在哪个事务、哪个 CAS、哪条边界上动;这一条管的是人去改配置的那个动作本身,是流程规矩不是代码规则。变现链上有两个"切真"的开关:广告 provider 从 MockAdProvider 切到真实联盟(CsjAdProvider/GdtAdProvider)、打款 channel 从 MockPayoutClient 切到真实微信/支付宝(Nacos trade.payout-channel 设为 wxpay)。这两个开关一翻,系统就开始动真钱——配置填错的后果是直接的资损:渠道选错或密钥配错,可能让本该 fail-fast 的打款变成"以为切了真渠道、其实在发 mock 假钱",或者把真实联盟的回调对到错的归因上。

资金渠道是整条变现链风险最高的开关,绝不能允许单人改一行配置就切真。要钉死两条操作约束:① 双人复核——切换 provider/payout-channel 的配置变更必须经第二人复核确认后才生效,不允许单人直接改 Nacos/配置中心就上线;② 配置审计——每一次渠道切换都要留下"谁、什么时候、把哪个开关从什么改成什么"的审计记录,可追溯、可回查。这条和变现与单位经济§4.3"拿到真实账单前不对外锁价"是一类纪律——都是真钱面前的操作护栏,约束的是人的操作而非系统的逻辑。

它落地依赖配置变更的管控面,不是改业务码:双人复核挂在配置中心(Nacos)或运维变更流程的审批环节上,配置审计取配置中心自带的变更历史或单独记一条运维操作台账。待落地——当前 §8 标"打款执行=mock 桩",这两个切真开关都还没真正翻过,但红线要在切真之前就立好,别等手滑改错配置酿成资损才补。


7. 对账锚点链

钱跨表、跨模块流的时候,要能从任意一段反查到上下游。端到端的对账锚点串成一条链:

flowchart LR
  AR["game_ad_revenue<br/>id · trace_id · stat_date"] -->|source_ref = id| TI["game_trade_income<br/>source_ref · trace_id"]
  TI -->|余额变动| ACC["game_trade_account<br/>恒等式校验"]
  ACC --> WD["game_trade_withdraw<br/>biz_no · transfer_ref"]
  TI -.->|分析镜像 income_settled| TM["telemetry 埋点<br/>(不替代落账)"]

四个锚点各管一段对账:game_ad_revenue.idsource_ref 钉到 game_trade_income,是"台账→入账"的对账键;trace_id 全链透传,从计费一路带到入账;transfer_ref 是打款回调与渠道流水的对账键(V14 加的列);stat_date 是日期键,统一取上游的 stat_date,不要在下游另算一个日期,否则跨日的单子会对不上。

埋点和落账要分清主次:contracts/events.schema.json:68 里的 income_settled 事件携带 amount/source,但它只是 telemetry 的分析镜像,不替代 game_trade_income 落账——对账永远以数据库落账为权威,埋点丢了不影响钱,落账错了才影响钱。


8. 现状边界与接线点(纯钱流工程口径)

哪些已经真跑、哪些是桩、哪些还没接,以及每项的接线点在哪。这张表是钱流工程视角的;经济口径的待办在变现与单位经济§6。

能力 状态 接线点
广告计费落台账 已 real AdRevenueServiceImpl.bill()
T+1 分账入账 已 real SettlementServiceImpl.settle()
提现申请 + 冻结 + 审核 CAS 已 real WithdrawServiceImpl
打赏现金发放 已 real RewardPayoutServiceImpl
admin 赋余额 / 赋订阅 已 real AccountServiceImpl.grant / SubscriptionServiceImpl
打款执行 🟡 mock 桩 实现 PayoutClient 注入(委托 PayTransferApi.create/getTransfer),fail-fast 已就位,补真渠道实现即切
消费钱包内购扣款 未接 huijing-module-pay 的 PayWalletApi 未接入 game 业务;接 pay 后走 reduceWalletBalance
自助购买订阅 未接 需接真实支付收单 + 支付回调驱动 grantSubscription,替代 admin 手动赋
B 端收款 / 分账 未接(M4) biz 仅状态机,收款(T-BIZ-08)/在线签章/分账全留 M4,现仅 signed_offline 兜底
按游戏粒度 game_id 透传 🟡 部分 归因已到创作者(getCreatorUserId),game_id 维度的端到端透传待补全四步链

**真渠道的零改业务码切法:**打款这条线已经把抽象做对了——业务码只调 PayoutClient 接口和 PayoutClientFactory,具体渠道是 SPI。切到真实微信/支付宝,只要实现一个真 PayoutClient(内部委托 huijing-module-pay 的 PayTransferApi)并注册到 factory,WithdrawServiceImpl 的审核/发起/回调三段代码一行不改。fail-fast 这条红线反而帮了忙——没接真渠道时它硬拦,杜绝了"以为切了真渠道、其实还在发 mock"的隐患。

测试覆盖现状(find 计数):trade 9 个 Test、ad 3 个、biz 1 个。


9. 关键指针

  • 账怎么算、这门生意成不成立 → 变现与单位经济(R3 分账公式、eCPM 敏感性、回本 DAU、成本台账口径)。
  • 二清风险、持牌分账/银行存管/灵活用工三通道选型、提现实名税务闸门 → 合规闸门
  • 各合规闸门的当前状态(活数据) → A1 闸门看板
  • 接缝事实源(ad↔trade seam、资金边界口径) → contracts/api-schemas/{ad,trade,biz}.yamlcontracts/ad-slot.schema.json(广告位第 7 契约)、contracts/sdk-interface.d.ts(AdPlugin.showRewarded/showInterstitial,:48-52)、contracts/events.schema.json(income_settled 仅分析镜像,:68)。
  • 建表脚本与版本(版本号以实时核对迁移目录为准) → game-cloud/**/resources/db/migration/(ad=V6 / trade=V7+V14+V21 / biz=V13)。