games-development-ai/docs/agent-specs/2026-07-02-M4广告SDK预接线-设计.md
lili 8b91f34b13
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
docs(design): SVG 尾波补 M4 广告预接线门面图 m4ad-00(三层切换开关+回调 fail-closed 铸币面防护)
W-DSGN 尾波实际残项唯一 1 张(R3/M4 门面图),补齐并接入 M4 设计档 §0。
主控终审:qlmanage 亲眼渲染验证,修两处单行文本溢出(顶部状态条 tspan 超框
裁字「防重放」→ 精简正文他处已凸显的细节;页脚映射行冗余「状态」段与顶部
约定条重复 → 删段),两处修后重渲染确认完整落框。docs-gate 七检全绿。
作战清单点名的其余尾波图(plan①顶图/quality-01·02/rethink-00/cfgctl2-01~04)
经核对早已提交,无需重画。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:44:33 -07:00

206 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
date: 2026-07-02
topic: M4 广告 SDK 预接线
status: 回炉修订 · 双评审 BLOCKER/MAJOR 已吃(回调铸币面 fail-closed 代码已落 + 冲正/熔断/契约在档定死)· 待复评
sot-impact: 不新建 canonical topic(R3 工单实现设计,归属既有 SoT「变现端到端」)。修订触点 = 变现端到端 §6(切换红线由「待落地」落成 runbook)、§8(切真接线点细化);落地新增 Nacos 配置约定(per-provider 密钥命名空间 + ad.force-mock 熔断 + ad.<provider>.callback-replay-window-seconds 防重放窗口);新增 ad 段错误码 1-111-002-005(AD_REWARD_CALLBACK_PROVIDER_NOT_READY);force-mock 语义 = 回调路径拒真实联盟位、不作用于 calcRevenue。契约漂移登记:ad-slot.schema.json 的 callback 枚举已于 R4(bc0f486b)补齐、基线已含;AdSlotSaveReqVO.provider 仍无 enum 强校验(R3 不补,理由见 §1.1)。不改 ad.yaml、不改 mock 计费路径、不改 trade 代码(冲正只做设计)。
上级: docs/mvp/可行性方案16周-波次映射.md
关联: game-cloud/game-module-ad/game-module-ad-server/.../framework/provider/{AdProvider,AdProviderFactory(get/getStrict),MockAdProvider,CallbackAdProvider,AdRewardCallbackContext}.java · service/revenue/AdRevenueServiceImpl.java:reportRewardCallback · controller/app/AdController.java:94-102 · game-module-ad-api/.../enums/ErrorCodeConstants.java(SIGN_INVALID / PROVIDER_NOT_READY) · contracts/api-schemas/ad.yaml · contracts/ad-slot.schema.json · docs/architecture/运营/变现端到端.md §6/§8 @ 4d220e77
图清单: [图0 三层切换与铸币面防护(SVG 门面图,assets/m4ad-00-三层切换与铸币面防护.svg,已补);图1 接入面边界与三层切换开关(mermaid)]
---
# M4 广告 SDK 预接线 · 设计(穿山甲 / 优量汇适配层切真通路)
## 0 一图看懂
![图 · M4 广告 SDK 预接线:三层切换开关与回调 fail-closed 铸币面防护](assets/m4ad-00-三层切换与铸币面防护.svg)
> 门面图并置这条通路的两条主轴:公网回调链上三道 fail-closed 闸(force-mock 熔断 → getStrict 严格命中 → 验签)兜住铸币面,命门是「算钱可降级、验签不可降级」;三层切换开关(代码 / 配置 / 数据)默认全等于 mock、切真是叠加。数值与字段以下方 Mermaid 及后续各节为准。
```mermaid
flowchart TB
subgraph 端侧["端侧 SDK 轨(game-studio,不在本单)"]
SDK["WanxiangGameSDK · AdPlugin<br/>showRewarded / showInterstitial<br/>拉起联盟 SDK 展示广告"]
end
subgraph 服务端["服务端轨 · ad 模块(本单核心)"]
CB["/app-api/ad/reward/callback<br/>@PermitAll + IP 限频"]
FUSE{"force-mock 熔断?<br/>真实联盟位(非 mock/callback)"}
STRICT["getStrict(provider)<br/>严格命中,未注册返 null 即拒<br/>绝不降级 mock"]
V["provider.verifyRewardCallback<br/>验签(密钥 per-provider)<br/>mock 恒拒 · callback 空密钥拒"]
BILL["bill():合规→uk_trace 幂等→归因→算收入→落台账"]
REC["ad↔联盟对账<br/>不估算入账,以联盟 T+1 账单<br/>实际金额为唯一 trade 入账源"]
end
SDK -. 用户看完,联盟服务端回调 .-> CB
CB --> FUSE
FUSE -->|真实位 + 熔断| X["拒:不计费不发奖 + 告警<br/>PROVIDER_NOT_READY / SIGN_INVALID"]
FUSE -->|放行| STRICT
STRICT -->|null 未注册| X
STRICT -->|命中真实实现| V
V -->|通过| BILL
V -->|失败| X
BILL --> REC
classDef out fill:#eef,stroke:#88a;
classDef new fill:#fff7e6,stroke:#d90;
class 端侧 out; class REC,FUSE,STRICT new;
```
三层切换开关,默认值一律等于 mock 现状,切真是叠加、不改 mock 计费路径。**代码层**是注入真实 `CsjAdProvider`/`GdtAdProvider` 实现类;**配置层**是 Nacos 的 per-provider 验签密钥、`ad.force-mock` 熔断和防重放窗口;**数据层**是广告位 `game_ad_slot.provider` 字段加 `providerSlotId`。这三层里藏着一条容易被忽略的安全分界:算钱可以降级,验签不能降级。回调端点是 `@PermitAll` 的公网入口,验签是它唯一的安全凭据,所以任何一层"没切到位"时,回调都必须拒绝而不是放行——这条 fail-closed 原则贯穿全篇,是本次回炉的核心。
## 1 意图与边界
### 1.1 意图
把已经落地的广告联盟 SPI 骨架,补成一条从 mock 到真、一键可切、随时可回滚、切完能对账的通路,让穿山甲(csj)和优量汇(gdt)资质下证当天以最小动作切真而不动 mock。R3 是预接线:下证前能做的全部就位,把真正等官方文档的部分压到最小。
预接线并非从零起步。当前已就位、本单直接复用的骨架有四块:
- **SPI 与工厂**:`AdProvider` 定义 `getProvider`/`calcRevenue`/`verifyReward`/`verifyRewardCallback` 四个方法;`AdProviderFactory` 按 provider 路由。工厂有两条取实现的路径,语义不同,见 §2.2——展示计费用宽松的 `get`(未注册降级 mock),回调验签用严格的 `getStrict`(未注册返 null、由 Service 拒绝)。
- **回调验签桩**:`CallbackAdProvider`(provider=callback)已实现服务端回调的完整形态——MD5 验签骨架、`ad.callback.sign-secret` 密钥开关、`ad.callback.sandbox-passthrough` 桩直通开关,以及 P2-2 安全红线(空密钥默认拒绝,防 `@PermitAll` 公网端点被伪造回调铸币),配 7 个单测。
- **入站端点与计费**:`/app-api/ad/reward/callback`(`@PermitAll` + IP 限频),`reportRewardCallback` 走「取广告位 → 熔断判定 → 取验签实现 → 验签 → 按 reward 计费」,计费幂等键是 `uk_trace(trace_id, event_type, tenant)`
- **广告位契约**:契约 #7 `ad-slot.schema.json``provider` 枚举已含 `csj/gdt/mock/callback`(R4 commit `bc0f486b` 补齐,本基线已含),`api-schemas/ad.yaml` 同步含 callback。`providerSlotId`(联盟真实广告位 ID)在 provider≠mock 时必填、admin 录入、恒不下发前端(前端不持有联盟广告位 ID,由服务端代理)。
有一处要如实说明:admin 入参 `AdSlotSaveReqVO.provider` 目前是 `@NotBlank String`,没有 enum 白名单强校验,所谓"枚举"只存在于契约 JSON Schema 和 ad.yaml,不在 Java 入参层。R3 不补这道 enum 校验,理由是回调路径已经对非法或未注册的 provider 做了 fail-closed:`getStrict` 命中不到就拒,伪造一个 `provider=csjj` 顶多让展示计费降级 mock 按 mock 口径算钱,不产生真实结算、不构成铸币面。也就是说 enum 强校验是防呆、不是防铸币,列为 follow-up 不阻断切真。
真实联盟与 mock 的差别,在**验签算法**这一维上确实收敛到一个 provider 实现类里;端点、幂等、归因、限频、安全红线全部共用。但**收益口径的对账**是另一回事——它牵涉 trade 侧结算,不在 provider 类内闭合,这一段单独在 §3 定死。
### 1.2 边界(四个接入面的归属)
| 接入面 | 归属 | 本单 |
|---|---|---|
| 端侧展示广告(showRewarded/showInterstitial,拉起联盟 SDK) | game-studio + WanxiangGameSDK | 否(另轨,切真时需协同) |
| 服务端激励回调验签计费(reward/callback) | ad 模块 | **是 · 核心** |
| 平台↔联盟收益对账(真实 eCPM/结算额) | ad 模块 provider + trade 结算 | **是 · 设计,下证落地** |
| 提现打款渠道(payout) | trade 模块 | 否(与广告切真是两个独立开关) |
不做的事:不写联盟真实签名算法(强依赖官方文档,提前写就是编造);不动 mock 计费路径任何代码;不接端侧 SDK;不改 trade 代码(冲正模型在 §3 定死,落地随 trade 结算线另单)。
## 2 切换开关设计
### 2.1 三层开关矩阵
| 层 | 开关 | mock 现状(默认) | 切真取值 | 落点 |
|---|---|---|---|---|
| 代码 | 真实 provider 实现类 | 仅 mock/callback 注入 | 注入 CsjAdProvider/GdtAdProvider | 新增类(下证当天,以 CallbackAdProvider 为模板) |
| 配置 | 验签密钥(per-provider) | 空 / sandbox-passthrough=false | 配 `ad.{csj,gdt}.sign-secret` 真实密钥 | Nacos,双人复核 + 审计 |
| 配置 | `ad.force-mock` 全局熔断 | false | 保持 false;止血时置 true | Nacos(本单新增) |
| 配置 | `ad.<provider>.callback-replay-window-seconds` 防重放窗口 | 预置默认值 | 按联盟重试间隔校准 | Nacos(本单新增,见 §3) |
| 数据 | 广告位 `provider` + `providerSlotId` | provider=mock | 改为 csj/gdt + 填联盟广告位 ID | admin CRUD,可按广告位灰度 |
数据层按广告位粒度,天然支持灰度:先把一个低流量广告位切 csj 验证,再逐位放开,不需要额外的全局灰度旋钮。
### 2.2 两种降级不是一回事:算钱可降级,验签不可降级(B1 铸币面)
工厂原本只有一个 `get(provider)`:未注册就降级到 mock。这个降级用在**展示计费**(`calcRevenue`)上是对的——一个还没切真的广告位来了曝光,按 mock 口径算个钱落台账,既不阻断用户看广告,也不产生任何真实结算,是宽松且可接受的。
但同一个降级套到**回调验签**(`verifyRewardCallback`)上就是灾难。回调端点公网可达,验签是唯一凭据。如果 `provider=csj` 而真实类还没注入,`get("csj")` 降级到 `MockAdProvider`,而 mock 若不覆写 `verifyRewardCallback` 就会继承接口 default、回退到恒真的 `verifyReward()`——于是任何人对着这个公网端点伪造一条回调,验签直通,计费落台账、T+1 真结算。这就是双评审判定回炉的铸币面。它有三条触发路径:数据层领先代码层(admin 把广告位改成 csj 而真实类未注入)、mock 广告位的公网回调被伪造、以及 `force-mock` 熔断把所有真实位一起降级 mock 时铸币面反而全开。
回炉后的做法是把两种降级彻底分开,并让验签这条 fail-closed:
1. **工厂增设 `getStrict(provider)`**:精确命中,未注册返回 `null`,绝不降级 mock。回调路径只用 `getStrict`;展示计费继续用 `get`。二者语义相反,注释和方法名都写死这条分界。
2. **Service 层 fail-closed**:`reportRewardCallback` 拿到 `null` 就抛 `AD_REWARD_CALLBACK_PROVIDER_NOT_READY`(新增错误码 1-111-002-005),不计费不发奖、留告警日志。
3. **`MockAdProvider` 覆写 `verifyRewardCallback` 返回 false**:mock 广告位本就不接联盟服务端回调,堵死这条不影响它的曝光计费和前端直通发奖(`verifyReward``calcRevenue` 不变),与"mock 零回归"前提不冲突。这是纵深防御的第一层——即便某条路径把 mock 当成了验签实现,也拒。
4. **回调铸币面全组合单测**:`provider∈{mock, callback(空密钥/直通), csj(未注册)} × force-mock{off,on}` 的行为矩阵,断言任何组合都打不开铸币面,已随代码落地(见 §5)。
### 2.3 `ad.force-mock` 熔断:只拒真实位回调,不碰验签之外的东西
`ad.force-mock` 是联盟故障或对账严重不平时的止血开关。它的语义在回炉后收窄并写死,避免重蹈"熔断放大铸币面"的覆辙:
- **force-mock=true 时,回调路径拒绝一切真实联盟位(即 provider 不属于内置桩集合 `{mock, callback}`)的回调**,抛 `PROVIDER_NOT_READY`、不计费不发奖。这才是止血的本义——熔断期干脆不接可能有问题的联盟回调,而不是"照收但换个口径算钱"。
- **熔断不作用于展示计费(`calcRevenue`)**。展示不涉铸币;真实位在未注册时的展示计费本来就走 `get` 降级 mock 算钱,是现行为,force-mock 不需要、也不去改它。
- **内置桩位(mock/callback)不受熔断影响**,继续走各自的验签语义:mock 恒拒回调,callback 按 secret 严格校验或空密钥默认拒。桩位不涉真实联盟结算,不是止血对象。
这条语义和代码一致:验签路径先过熔断判定,再走 `getStrict`,`force-mock` 永远不会把请求引到降级 mock 上。即便熔断开关因为热刷新未生效(见 §2.4)一时没能置上,铸币面也已经由 `getStrict` 的 fail-closed 和 mock/callback 各自的默认拒兜住——`force-mock` 是额外的主动熔断,不是唯一防线。
### 2.4 熔断与密钥的热生效:@RefreshScope 方案与当前的重启兜底(M1)
`ad.force-mock` 和各 `ad.*.sign-secret` 目前都用普通 `@Value` 承载。普通 `@Value` 在 Bean 初始化时一次性绑定,Nacos 改了配置不会自动刷新,要重启进程才生效。这与"一键实时熔断"和"配密钥即切真"的预期有落差:熔断要快,重启慢且有损;切密钥若不重启,验签仍读旧的空值。
按"生产稳定优先于开发期简单"的原则,正确方向是给这两类配置的载体 Bean 加 `@RefreshScope`(或 `@NacosValue(autoRefreshed=true)`),让 Nacos 推送后热生效。落地时倾向把 `force-mock` 与 per-provider 密钥抽到一个独立的 `@RefreshScope` 配置 Bean,而不是直接给核心计费 Service 加注解——`@RefreshScope` 会把 Bean 变成每次刷新重建的代理,加在计费 Service 这种带事务和多依赖注入的核心 Bean 上有未验证的代理副作用,独立配置 Bean 更干净、影响面可控。
这一步在 R3 标记为 follow-up,原因是本机没有 Nacos 环境,热刷新是否真的生效验证不了,不能靠"加了注解"就宣称可用。在热刷新落地并验证之前,runbook 按"改 force-mock 或密钥 = 滚动重启"操作(见 §4),并明确重启窗口的资损敞口:滚动重启期间新旧实例并存,旧实例仍读旧配置,若正处在"刚配好真实密钥、想立刻切真"的窗口,旧实例会以旧空密钥拒掉真实回调(fail-closed,不铸币、只是丢几条真实发奖),不会反向铸币。这个敞口方向安全,可接受。
## 3 失败模式(红线:外部交互必处理超时 / 重试 / 幂等 / 验签)
| 失败模式 | 已落地 | R3 补 |
|---|---|---|
| 回调超时 / 丢失 | 平台被动接收、快速响应;联盟侧超时自行重推,`uk_trace` 保证重推安全 | 回调端点验签与重活解耦、快速回执防联盟误判超时;回调彻底丢失的兜底 = 平台主动对账拉取补记(依赖联盟查询接口,待文档) |
| 验签失败 | `AD_REWARD_CALLBACK_SIGN_INVALID`(1-111-002-004),不计费不发奖 + warn 日志 | provider 未就绪单列 `AD_REWARD_CALLBACK_PROVIDER_NOT_READY`(1-111-002-005),便于告警区分"签名错"与"provider 未注册/熔断";失败计数告警;防重放窗口见下 |
| 重复回调幂等 | `uk_trace(trace_id, event_type)` 去重,重复只计一次、只发奖一次 | 沿用;与防重放窗口分工见下 |
| 对账不平 | ad↔trade 有 `source_ref` + `trace_id` 锚点链 | 冲正模型定死(见下):不估算入账,以联盟账单实际金额为唯一 trade 入账源 |
平台对联盟回调是被动入站,超时与重试的主动方是联盟;平台的责任是幂等接收、快速回执、验签。真正的平台主动外呼(拉联盟收益报表对账)才需要自己处理超时、重试、降级,那部分随对账接口一并接入。
### 3.1 防重放:窗口配置项预置(不编造联盟数值,但预置项目侧框架)
`AdRewardCallbackContext` 已带 `timestamp` 字段,但当前验签没用它。R3 预置一个项目侧的防重放窗口配置,框架先立住,联盟侧的具体数值下证时再校准:
- **配置项**:`ad.<provider>.callback-replay-window-seconds`,owner = ad 模块(WS5)。
- **默认值 300 秒、上限 900 秒**:默认给一个不至于误杀联盟正常重推的宽度,上限封住"配得过大等于没防"。这两个数是项目侧的保守缺省,联盟真实重试间隔以官方文档为准校准。
- **timestamp 缺失的 fail-safe**:真实 provider 要求回调必带 `timestamp`,缺失即拒(视为不合规回调);桩 callback 的 timestamp 可选(sandbox 自测无联盟时间戳)。这条写进真实 provider 的验签实现里,默认从严。
要点是三道防线分工,不能相互替代:`uk_trace` 防的是同一 `trace_id` 的重复计费(重推安全);防重放窗口防的是陈旧报文重放,并把服务端需要永久去重的窗口收窄到有限时间;而 `timestamp` 只有被纳入联盟 `sign` 才真正防篡改——否则攻击者改了 timestamp 绕过窗口,签名照样通过。桩 `CallbackAdProvider``sign=md5(traceId|transId|secret)` 目前不含 timestamp,真实联盟 sign 的组成(是否含 timestamp/nonce)是 §5 待核实项,直接决定 timestamp 防重放到底成不成立。
### 3.2 冲正模型定死:不估算入账,以联盟账单为唯一入账源(B2)
初稿写"回调若未携带实际金额则先按 `ecpmFloor` 估算入账、T+1 对账时以联盟账单冲正差额",双评审判为孤儿设计——冲正差额无处落账。根因在于 trade 侧入账是 add-only 的:`recordIncome` 幂等键 `uk_source(source, source_ref=ad_revenue.id)`,同一 `source_ref` 入过账再来就 `return false`。于是估算入账之后想冲正,复用同键会被幂等挡死;另造一笔负向分录走 `addIncome(负值)`,而 `AccountMapper.addIncome` 没有充足校验,会把 `balance` 扣成负、`total_income` 下调,可钱可能已经 `freeze` 甚至提现,账户恒等式 `balance + frozen + total_withdraw = total_income` 左边就大于右边,账破了;若改走带充足校验的扣减,余额不足直接抛错回滚,冲正卡死。三条路都撞资损或破账红线。
回炉采评审首选,从根上消除冲正:**回调不带实际金额时不估算入账,联盟 T+1 账单的实际金额是唯一的 trade 入账源。**具体地:
- 回调验签通过后,ad 侧照常落 `game_ad_revenue` 台账、记录发奖这件事的发生(`uk_trace` 幂等、归因照旧),但当 provider 是真实联盟且回调不含实际金额时,这笔的 `revenue_amount` 是"待联盟账单回填"状态,**不驱动 trade 入账**(结算 job 跳过"金额待定"的笔)。
- T+1 联盟对账单到账,用实际金额回填 `revenue_amount`,这时才驱动 trade 一次性入账,`source_ref` 仍是这条 `ad_revenue.id`。因为每个 `source_ref` 只入账一次、且入的就是实际金额,`uk_source` 的 add-only 幂等天然成立,恒等式的 `total_income` 从不被事后下调,不存在"钱已提现却要回收"的场景。
- mock 与 callback 桩不受影响:它们的 `calcRevenue` 给的是确定值(`ecpm/1000`)、回调即带确定金额,照现状即时入账,没有"金额待定"。所以这条只对真实联盟的"回调不带金额"生效,mock 计费路径零回归。
`revenue_amount` 的口径仍锁毛额(变现端到端 §6 红线),回填的是联盟结算的毛额;分账与回标沿用现有 `settle_status 0→1` 与 creator 聚合链路,只是入账时点从"回调即入"推迟到"账单回填后入"。对账查询以 `game_ad_revenue` 的"待回填 / 已回填 / 已入账"三态 + `trace_id`/`source_ref` 锚点链为准。
这一段是设计,落地牵涉 ad 结算 job 与 trade 入账触发时点的改动,随对账接口和 trade 结算线另单实现,本单不动 trade 代码。
## 4 切换 runbook 骨架
**前置(R3 预置,下证前完成)**:per-provider 密钥项空值登记进 Nacos;`ad.force-mock` 开关登记(默认 false);`ad.<provider>.callback-replay-window-seconds` 登记(默认 300);对账 job 骨架 + 差异告警;切换 checklist;回调铸币面 fail-closed 单测已随代码落地(见 §5)。
**切前演练(不碰真钱、不需联盟资质,证明开关和防线可用)**:
1. **canary provider 往返**:选一个 canary 广告位,provider 从 mock 改 callback、再改回 mock,确认改到"未注册的真实位名"(如 `csj`)时回调被 `PROVIDER_NOT_READY` 拒,而不是降级 mock 放行——这是 fail-closed 的现场验证。
2. **force-mock 开关往返**:`false → true → false`,确认置 true 时真实位回调被熔断拒、mock/callback 位不受影响,置回 false 后恢复。
3. **热刷新 / 重启确认**:在当前普通 `@Value` 下,确认改 force-mock 或密钥后按滚动重启生效(热刷新 follow-up 落地后改为确认热刷新已生效)。
4. **无新增真实计费断言**:演练全程不产生真实联盟台账(全走 mock/callback 桩),核对 `game_ad_revenue` 无真实 provider 的入账。
**下证当天(D-day)**:
1.`CallbackAdProvider` 写真实 provider 类、填联盟签名算法与防重放 fail-safe + 单测,灰度发布(唯一代码增量)。真实类的 `verifyRewardCallback` 在签名算法填入前默认返回 false(继承 P2-2 精神),绝不因"类已注入但算法未就绪"静默放行。
2. **双人复核**下配置:填 `ad.{csj,gdt}.sign-secret` 真实密钥,留配置审计(谁、何时、从什么改成什么)。改配置后按 §2.4 确认生效(当前 = 滚动重启)。
3. **切 provider 前的 fail-closed 闸**:admin 改广告位 provider 之前,先确认工厂确实命中了真实类、而不是降级 mock(切前演练第 1 步的现场复核),命中真实类才继续。
4. admin 选一个低流量广告位:`provider` 改 csj/gdt + 填 `providerSlotId`(灰度起点)。
5. 端侧 SDK 灰度接入该广告位(端侧另轨,需协同)。
6. 验证:真回调验签通过 → 落台账(待账单回填,不即时入账)→ T+1 账单回填后 trade 入账、对账平;验签失败率、`PROVIDER_NOT_READY` 计数、告警在阈内。
7. **放量与停止阈值**:逐位放开;验签失败率或对账差异超过预设阈值即停止放量。异常升级时的回退:admin 把广告位 `provider` 改回 mock/callback;大面积异常直接 `ad.force-mock=true` 全局熔断真实位回调。
双人复核 + 配置审计贯穿步骤 24,把变现端到端 §6 那条"待落地"的操作红线,落成这份可执行清单。
## 5 验证
- **mock 零回归(硬红线)**:provider=mock 计费路径代码零改动;现有 ad 模块单测全绿(`CallbackAdProviderTest` 7 例、`AdSlotServiceImplTest` 6 例未改仍绿,`AdRevenueServiceImplTest` 原有计费/幂等/合规用例仍绿);`ad.force-mock` 默认 false 时,mock/callback 广告位的回调与计费行为与改造前一致。
- **回调铸币面 fail-closed 矩阵(已落地)**:`provider∈{mock, callback(空密钥/直通), csj(未注册)} × force-mock{off,on}` 的回调行为矩阵——mock 位恒拒(`SIGN_INVALID`)、callback 空密钥默认拒、csj 未注册拒(`PROVIDER_NOT_READY`)、force-mock 熔断拒真实位;并各配一格正路径(callback 显式直通 + force-mock 两态)证明改造不误伤桩位正常计费。合计 33 个 ad 模块单测全绿。
- **真实 provider fail-safe 单测的时序**:"真实类签名算法未填时 `verifyRewardCallback` 返回 false"这条单测在 R3 预置期没有对象(真实类 D-day 才写),属正常时序错位,随 D-day 步骤 1 的真实类单测一起补,不在 R3 清单里勾选。
- **切换演练**:sandbox(provider=callback + sandbox-passthrough)跑通「回调 → 验签 → 计费」全链,作为真实化前的形态验证;此演练不碰真钱、不需联盟资质。
## 6 待官方文档核实清单(不编造,下证时逐项落实)
- 穿山甲(csj)/ 优量汇(gdt)各自的服务端激励回调:端点方向、请求参数名(交易号 / 签名 / 时间戳 / nonce 等)、签名算法(md5 / sha256 / hmac)、密钥获取与格式、防重放窗口的联盟重试间隔、超时判定策略。
- **回调成功应答协议**:联盟判定"平台已成功接收"要求的 HTTP 状态码、响应 body、content-type;失败时联盟的重推规则与次数;据此才能把"快速回执防误判超时"实现成可验证的应答。
- **回调来源边界**:联盟回调的出口 IP / 网段白名单,或在无固定 IP 时的替代来源校验方式(验签是主凭据,来源白名单是纵深防御的补充)。
- **nonce 契约触点**:若联盟用 nonce 防重放,回调 VO(`AdRewardCallbackReqVO`)与 `ad.yaml` 需新增 nonce 字段——这是一个契约扩展点,下证时按联盟实际字段决定是否补,补则走 contract-first。
- 联盟实际收益 / eCPM 数据来源:是否随回调携带实际金额(直接决定 §3.2 是否触发"待账单回填")、是否需平台主动拉报表接口、结算口径(毛 / 净 / 税)、对账文件格式与周期。
- 端侧 SDK(game-studio 另轨):穿山甲 / 优量汇在目标运行环境的接入方式,以及 `providerSlotId` 在端侧取位与服务端代理之间的分工。
## 7 回炉修订记录(供复评核对)
本轮相对初稿的实质变更,以及已落代码的位置:
- **B1 铸币面(代码已落)**:`AdProviderFactory``getStrict`(未注册返 null、不降级);`MockAdProvider` 覆写 `verifyRewardCallback` 返 false;`AdRevenueServiceImpl.reportRewardCallback` 改用 `getStrict` 并加 `PROVIDER_NOT_READY` fail-closed;新增错误码 `AD_REWARD_CALLBACK_PROVIDER_NOT_READY`(1-111-002-005);展示计费的 `get` 降级路径保持不变。全组合矩阵单测 8 格随代码落地,ad 模块 33 单测全绿。
- **B2 冲正模型(档内定死)**:采首选"不估算入账、以联盟 T+1 账单实际金额为唯一 trade 入账源",消除冲正,论证了 `uk_source` add-only 幂等与账户恒等式为何成立;落地随 trade 结算线另单,本单不动 trade 代码。
- **M1 热刷新(方案入档 + 代码 follow-up)**:`@RefreshScope` 载体 Bean 方案写入 §2.4,本机无 Nacos 验证不了故标 follow-up,runbook 给滚动重启兜底与资损敞口(方向安全)。
- **M2 契约断言(改真话)**:§1.1 更正为 ad-slot.schema.json 的 callback 已于 R4 `bc0f486b` 补齐、基线已含;如实写明 `AdSlotSaveReqVO.provider` 无 enum 强校验且 R3 不补的理由。
- **force-mock 语义(收窄定死)**:§2.3 改为"回调拒真实位、不碰 calcRevenue、不影响内置桩",与代码一致,废弃初稿"工厂 get 全局回退 mock"的旧语义。
- **防重放 / ACK / 来源 IP(预置与补清单)**:§3.1 预置 `callback-replay-window-seconds` 的 owner/默认 300/上限 900/timestamp 缺失 fail-safe,并辨析 uk_trace 与 timestamp 分工;§6 补回调 ACK 协议、来源 IP 白名单、nonce 契约触点。
- **runbook(切前演练与放量阈值)**:§4 增 canary provider 往返、force-mock 往返、切前 fail-closed 闸、无新增真实计费断言、放量与停止阈值、回退演练。