merge: R3 广告回调验签 fail-closed 封铸币面 + M4 预接线设计回炉(双评审 BLOCKER/MAJOR 全修,ad 模块 33 单测绿)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
0daa5e5011
@ -1,10 +1,10 @@
|
||||
---
|
||||
date: 2026-07-02
|
||||
topic: M4 广告 SDK 预接线
|
||||
status: 草稿 · 待 Codex+Opus 双评审 → 通过后进 R3 实现
|
||||
sot-impact: 不新建 canonical topic(R3 工单实现设计,归属既有 SoT「变现端到端」)。修订触点 = 变现端到端 §6(切换红线由「待落地」落成 runbook)、§8(切真接线点细化);落地时新增 Nacos 配置约定(per-provider 密钥命名空间 + ad.force-mock 熔断);不改 ad.yaml API 契约、不改 mock 路径代码。
|
||||
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,MockAdProvider,CallbackAdProvider,AdRewardCallbackContext}.java · controller/app/AdController.java:94-102 · service/revenue/AdRevenueServiceImpl.java:101-210 · contracts/api-schemas/ad.yaml · contracts/ad-slot.schema.json · docs/architecture/运营/变现端到端.md §6/§8 @ 4d220e77
|
||||
关联: 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
|
||||
图清单: [图1 接入面边界与三层切换开关(mermaid);SVG 门面图收口时按 atlas 补]
|
||||
---
|
||||
|
||||
@ -19,36 +19,44 @@ flowchart TB
|
||||
end
|
||||
subgraph 服务端["服务端轨 · ad 模块(本单核心)"]
|
||||
CB["/app-api/ad/reward/callback<br/>@PermitAll + IP 限频"]
|
||||
F["AdProviderFactory.get(provider)<br/>未注册→降级 mock;force-mock→全局回退"]
|
||||
V["provider.verifyRewardCallback<br/>验签(密钥 per-provider)"]
|
||||
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/>估算入账 vs 联盟账单,T+1 冲正"]
|
||||
REC["ad↔联盟对账<br/>不估算入账,以联盟 T+1 账单<br/>实际金额为唯一 trade 入账源"]
|
||||
end
|
||||
SDK -. 用户看完,联盟服务端回调 .-> CB
|
||||
CB --> F --> V -->|通过| BILL
|
||||
CB --> FUSE
|
||||
FUSE -->|真实位 + 熔断| X["拒:不计费不发奖 + 告警<br/>PROVIDER_NOT_READY / SIGN_INVALID"]
|
||||
FUSE -->|放行| STRICT
|
||||
STRICT -->|null 未注册| X
|
||||
STRICT -->|命中真实实现| V
|
||||
V -->|通过| BILL
|
||||
V -->|失败| X
|
||||
BILL --> REC
|
||||
V -->|失败 1-111-002-004| X["不计费不发奖 + 告警"]
|
||||
classDef out fill:#eef,stroke:#88a;
|
||||
classDef new fill:#fff7e6,stroke:#d90;
|
||||
class 端侧 out; class REC new;
|
||||
class 端侧 out; class REC,FUSE,STRICT new;
|
||||
```
|
||||
|
||||
三层切换开关(默认值一律 = mock 现状,切真是叠加、不改 mock 路径):**代码层** = 注入真实 `CsjAdProvider`/`GdtAdProvider` 实现类;**配置层** = Nacos 的 per-provider 验签密钥 + `ad.force-mock` 熔断;**数据层** = 广告位 `game_ad_slot.provider` 字段 + `providerSlotId`。
|
||||
三层切换开关,默认值一律等于 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 骨架,补成一条从 mock 到真、一键可切、随时可回滚、切完能对账的通路,让穿山甲(csj)和优量汇(gdt)资质下证当天以最小动作切真而不动 mock。R3 是预接线:下证前能做的全部就位,把真正等官方文档的部分压到最小。
|
||||
|
||||
预接线并非从零。当前已就位、本单直接复用的骨架:
|
||||
预接线并非从零起步。当前已就位、本单直接复用的骨架有四块:
|
||||
|
||||
- **SPI 与工厂**:`AdProvider`(`getProvider`/`calcRevenue`/`verifyReward`/`verifyRewardCallback`)+ `AdProviderFactory` 按 provider 路由、未注册降级 mock。
|
||||
- **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)`。
|
||||
- **广告位契约**:`provider` 枚举已含 csj/gdt/mock/callback;`providerSlotId`(联盟真实广告位 ID)provider≠mock 时必填、admin 录入、**恒不下发前端**(前端不持有联盟广告位 ID,由服务端代理)。
|
||||
- **入站端点与计费**:`/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,由服务端代理)。
|
||||
|
||||
因此真实联盟与 mock 的差别,收敛到一个 provider 实现类里的**验签算法**与**收益口径**;端点、幂等、归因、限频、安全红线全部共用。
|
||||
有一处要如实说明: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 边界(四个接入面的归属)
|
||||
|
||||
@ -56,10 +64,10 @@ flowchart TB
|
||||
|---|---|---|
|
||||
| 端侧展示广告(showRewarded/showInterstitial,拉起联盟 SDK) | game-studio + WanxiangGameSDK | 否(另轨,切真时需协同) |
|
||||
| 服务端激励回调验签计费(reward/callback) | ad 模块 | **是 · 核心** |
|
||||
| 平台↔联盟收益对账(真实 eCPM/结算额) | ad 模块 provider | **是 · 设计,下证落地** |
|
||||
| 平台↔联盟收益对账(真实 eCPM/结算额) | ad 模块 provider + trade 结算 | **是 · 设计,下证落地** |
|
||||
| 提现打款渠道(payout) | trade 模块 | 否(与广告切真是两个独立开关) |
|
||||
|
||||
**不做**:不写联盟真实签名算法(强依赖官方文档,提前写即编造);不动 mock 路径任何代码;不接端侧 SDK。
|
||||
不做的事:不写联盟真实签名算法(强依赖官方文档,提前写就是编造);不动 mock 计费路径任何代码;不接端侧 SDK;不改 trade 代码(冲正模型在 §3 定死,落地随 trade 结算线另单)。
|
||||
|
||||
## 2 切换开关设计
|
||||
|
||||
@ -70,56 +78,124 @@ flowchart TB
|
||||
| 代码 | 真实 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 「下证当天可切」= 预置一切、当天只补一个类
|
||||
### 2.2 两种降级不是一回事:算钱可降级,验签不可降级(B1 铸币面)
|
||||
|
||||
下证当天唯一的代码增量,是照着 `CallbackAdProvider` 写出真实 provider 类、填入联盟签名算法。其余全是**已预置好的配置与数据操作**:配密钥、改广告位 provider 字段。这就是「藏 flag 后当天可切」的实指——flag(密钥项、熔断开关、provider 字段)在 R3 全部预埋,平时走 mock,当天翻开即真。
|
||||
工厂原本只有一个 `get(provider)`:未注册就降级到 mock。这个降级用在**展示计费**(`calcRevenue`)上是对的——一个还没切真的广告位来了曝光,按 mock 口径算个钱落台账,既不阻断用户看广告,也不产生任何真实结算,是宽松且可接受的。
|
||||
|
||||
### 2.3 R3 落地的设计增量(不依赖联盟文档的部分)
|
||||
但同一个降级套到**回调验签**(`verifyRewardCallback`)上就是灾难。回调端点公网可达,验签是唯一凭据。如果 `provider=csj` 而真实类还没注入,`get("csj")` 降级到 `MockAdProvider`,而 mock 若不覆写 `verifyRewardCallback` 就会继承接口 default、回退到恒真的 `verifyReward()`——于是任何人对着这个公网端点伪造一条回调,验签直通,计费落台账、T+1 真结算。这就是双评审判定回炉的铸币面。它有三条触发路径:数据层领先代码层(admin 把广告位改成 csj 而真实类未注入)、mock 广告位的公网回调被伪造、以及 `force-mock` 熔断把所有真实位一起降级 mock 时铸币面反而全开。
|
||||
|
||||
- **per-provider 密钥命名空间**:现仅一个 `ad.callback.sign-secret`,真实需 csj/gdt 各自独立密钥。约定 `ad.<provider>.sign-secret`(下证前以空值占位登记进 Nacos,纳入配置基线),避免下证当天临时新造配置项。**具体密钥格式/字段名待官方文档核实**。
|
||||
- **`ad.force-mock` 全局熔断**:`AdProviderFactory.get()` 前置一个 `@Value("${ad.force-mock:false}")` 判断,为 true 时对任意 provider 一律返回 `MockAdProvider`。默认 false 即当前行为,mock/callback 广告位不受影响;联盟故障或对账严重不平时,一个开关把全平台广告回退 mock 止血。
|
||||
- **真实 provider 的 fail-safe 语义**:真实类在签名算法填入前,`verifyRewardCallback` 必须默认返回 false(继承 P2-2 精神),绝不因「类已注入但算法未就绪」静默放行铸币。
|
||||
- **收益口径先锁毛/净**:`revenue_amount`(分)在 mock 与真实之间口径必须一致(变现端到端 §6 红线)。R3 锁定其为**毛额**;真实收益以联盟为准,回调若未携带实际金额则先按 `ecpmFloor` 估算入账、T+1 对账时以联盟账单冲正差额。**联盟是否在回调中回传实际金额待官方文档核实**。
|
||||
回炉后的做法是把两种降级彻底分开,并让验签这条 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 日志 | 失败计数告警(密钥错配 / 疑似伪造);启用 `timestamp` 时间窗口防重放(窗口大小待联盟重试间隔文档) |
|
||||
| 重复回调幂等 | `uk_trace(trace_id, event_type)` 去重,重复只计一次、只发奖一次 | 沿用,无需新增 |
|
||||
| 对账不平 | ad↔trade 有 `source_ref` + `trace_id` 锚点链 | 新增 ad↔联盟对账面:平台估算入账 vs 联盟实际结算,T+1 差异检测 + 告警 + 以联盟账单为准的冲正口径 |
|
||||
| 验签失败 | `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);对账 job 骨架 + 差异告警;切换 checklist;单测加固(见 §5)。
|
||||
**前置(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 类、填联盟签名算法 + 单测,灰度发布(唯一代码增量)。
|
||||
2. **双人复核**下配置:填 `ad.{csj,gdt}.sign-secret` 真实密钥,留配置审计(谁 / 何时 / 从什么改成什么)。
|
||||
3. admin 选一个低流量广告位:`provider` 改 csj/gdt + 填 `providerSlotId`(灰度起点)。
|
||||
4. 端侧 SDK 灰度接入该广告位(端侧另轨,需协同)。
|
||||
5. 验证:真回调验签通过→计费落台账→T+1 对账平;失败率、验签失败告警在阈内。
|
||||
6. 全量:逐位把其余广告位切真。
|
||||
7. **回滚**:异常时 admin 把广告位 `provider` 改回 mock/callback;大面积异常直接 `ad.force-mock=true` 全局熔断。
|
||||
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` 全局熔断真实位回调。
|
||||
|
||||
双人复核 + 配置审计贯穿步骤 2、3,把变现端到端 §6 那条「待落地」的操作红线,落成这份可执行清单。
|
||||
双人复核 + 配置审计贯穿步骤 2–4,把变现端到端 §6 那条"待落地"的操作红线,落成这份可执行清单。
|
||||
|
||||
## 5 验证
|
||||
|
||||
- **mock 零回归(硬红线)**:provider=mock 路径代码零改动;现有 ad 模块单测(含 `CallbackAdProvider` 7 例)全绿;`ad.force-mock` 默认 false 时,工厂对 mock/callback 广告位的返回逐一比对切换前后一致。
|
||||
- **新增单测**:`force-mock=true` 时任意 provider 返回 mock;per-provider 密钥装配正确;真实 provider fail-safe(算法未填时 `verifyRewardCallback` 返回 false);对账差异检测命中人造差额。
|
||||
- **切换演练**:sandbox(provider=callback + sandbox-passthrough)跑通「回调→验签→计费→对账」全链,作为真实化前的形态验证;此演练不碰真钱、不需联盟资质。
|
||||
- **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)、密钥获取与格式、防重放窗口、重试与超时判定策略。
|
||||
- 联盟实际收益 / eCPM 数据来源:是否随回调携带实际金额、是否需平台主动拉报表接口、结算口径(毛 / 净 / 税)、对账文件格式与周期。
|
||||
- 穿山甲(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 闸、无新增真实计费断言、放量与停止阈值、回退演练。
|
||||
|
||||
@ -33,6 +33,12 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode AD_REPORT_CREATOR_NOT_FOUND = new ErrorCode(1_111_002_003, "归因失败:游戏不存在或无创作者");
|
||||
/** 激励视频回调验签失败(M4:服务端→联盟回调校验防作弊,sign 不通过则不计费不发奖) */
|
||||
ErrorCode AD_REWARD_CALLBACK_SIGN_INVALID = new ErrorCode(1_111_002_004, "激励视频回调验签失败");
|
||||
/**
|
||||
* 激励视频回调 provider 未就绪(fail-closed 防铸币):广告位 provider 声明为真实联盟(csj/gdt)但实现未注入,
|
||||
* 或 ad.force-mock 熔断期拒真实联盟位回调。回调端点 @PermitAll 公网可达,验签是唯一凭据——
|
||||
* 未命中真实实现绝不降级 mock 直通(mock 直通 = 任意伪造回调铸币),故此处 fail-closed 拒绝、不计费不发奖。
|
||||
*/
|
||||
ErrorCode AD_REWARD_CALLBACK_PROVIDER_NOT_READY = new ErrorCode(1_111_002_005, "激励视频回调 provider 未就绪,拒绝回调");
|
||||
|
||||
// ========== 收入结算回标 1-111-003-*** ==========
|
||||
/** 结算回标:非法状态流转(settle_status 仅允许 0→1,禁回退) */
|
||||
|
||||
@ -35,6 +35,9 @@ public class AdProviderFactory {
|
||||
/**
|
||||
* 按广告位 provider 取实现;未注册(如 csj/gdt 未注入)降级到 mock
|
||||
*
|
||||
* 用于展示计费(calcRevenue)路径:未注册降级 mock 是宽松、可接受的——曝光算钱走 mock 口径不涉铸币,
|
||||
* 保证 MVP 全 mock 计费可跑通。**回调验签路径禁用本方法**(降级 mock 会让验签直通铸币),改用 {@link #getStrict}。
|
||||
*
|
||||
* @param provider 广告位 provider 标识
|
||||
* @return 对应 AdProvider(保证非 null:兜底 mock)
|
||||
*/
|
||||
@ -43,4 +46,19 @@ public class AdProviderFactory {
|
||||
return p != null ? p : providerMap.get(MockAdProvider.PROVIDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格取实现:精确命中 provider,**未注册返回 null,绝不降级 mock**
|
||||
*
|
||||
* 专供回调验签路径(reportRewardCallback)使用。回调端点 @PermitAll 公网可达,验签是唯一安全凭据,
|
||||
* 必须 fail-closed:provider 声明为真实联盟(csj/gdt)但实现未注入时返回 null,由 Service 层拒绝回调,
|
||||
* 而不能像 {@link #get} 那样降级到 mock——mock 的 verifyRewardCallback 一旦被当作验签入口,等于放行任意伪造回调铸币。
|
||||
* 这与 get() 的展示计费宽松降级语义相反:算钱降级宽松、验签降级 = 拆防护,二者不共用同一条降级策略。
|
||||
*
|
||||
* @param provider 广告位 provider 标识
|
||||
* @return 精确命中的 AdProvider;未注册返回 null(调用方须按 fail-closed 拒绝)
|
||||
*/
|
||||
public AdProvider getStrict(String provider) {
|
||||
return providerMap.get(provider);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -40,4 +40,13 @@ public class MockAdProvider implements AdProvider {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyRewardCallback(AdRewardCallbackContext ctx) {
|
||||
// fail-closed:mock 广告位本就不接联盟服务端回调,验签恒不通过。
|
||||
// 若不覆写,接口 default 会回退到 verifyReward()(恒 true)——一旦某条路径把 mock 当验签实现
|
||||
//(工厂对未注册 provider 降级 mock、或 force-mock 熔断降级),@PermitAll 公网回调端点即被任意伪造放行铸币。
|
||||
// 覆写返回 false 是纵深防御第一层;不影响 mock 现行为:verifyReward()(前端直通发奖)与 calcRevenue()(曝光计费)不变。
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ import com.wanxiang.huijing.game.module.ad.framework.compliance.AdComplianceResu
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.AdProvider;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.AdProviderFactory;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.AdRewardCallbackContext;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.CallbackAdProvider;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.MockAdProvider;
|
||||
import com.wanxiang.huijing.game.module.ad.service.revenue.bo.AdBillingResult;
|
||||
import com.wanxiang.huijing.game.module.ad.service.slot.AdSlotService;
|
||||
import com.wanxiang.huijing.game.module.project.api.ProjectApi;
|
||||
@ -24,12 +26,14 @@ import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.wanxiang.huijing.game.module.ad.enums.ErrorCodeConstants.*;
|
||||
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
@ -53,6 +57,12 @@ public class AdRevenueServiceImpl implements AdRevenueService {
|
||||
/** rewarded 广告形式编码(收入口径判定用:rewarded 时曝光不计收入) */
|
||||
private static final String SLOT_TYPE_REWARDED = "rewarded";
|
||||
|
||||
/**
|
||||
* 内置桩 provider 集合(mock/callback):不涉真实联盟结算,force-mock 熔断不针对它们。
|
||||
* 熔断(止血)目标 = 真实联盟位(csj/gdt)的回调计费;桩位继续走各自验签语义(mock 恒拒、callback 按 secret)。
|
||||
*/
|
||||
private static final Set<String> STUB_PROVIDERS = Set.of(MockAdProvider.PROVIDER, CallbackAdProvider.PROVIDER);
|
||||
|
||||
@Resource
|
||||
private AdRevenueMapper adRevenueMapper;
|
||||
|
||||
@ -72,6 +82,16 @@ public class AdRevenueServiceImpl implements AdRevenueService {
|
||||
@Resource
|
||||
private ProjectApi projectApi;
|
||||
|
||||
/**
|
||||
* 全局熔断开关(Nacos:ad.force-mock,默认 false)。
|
||||
* 联盟故障或对账严重不平时置 true 止血:回调路径拒绝一切真实联盟位(csj/gdt)回调,不再接受可能有问题的联盟结算,
|
||||
* 杜绝熔断期继续入伪账;内置桩位(mock/callback)不涉真实结算、不受影响。
|
||||
* 本字段为普通 @Value,Bean 初始化时一次性绑定,改配置需滚动重启才生效(热刷新 @RefreshScope 见设计档 M1,接 Nacos 时落地);
|
||||
* 但回调铸币面已由 getStrict fail-closed + mock/callback 各自 fail-closed 兜底,force-mock 是额外的主动熔断、非唯一防线。
|
||||
*/
|
||||
@Value("${ad.force-mock:false}")
|
||||
private boolean forceMock;
|
||||
|
||||
@Override
|
||||
public AdReportResultVO reportImpression(AdImpressionReqVO reqVO) {
|
||||
// 计费(合规拦截/会话超限会在 bill 内抛错误码,不返回受理结果)
|
||||
@ -100,21 +120,36 @@ public class AdRevenueServiceImpl implements AdRevenueService {
|
||||
|
||||
@Override
|
||||
public AdRewardCallbackResultVO reportRewardCallback(AdRewardCallbackReqVO reqVO) {
|
||||
// 1) 取广告位配置(停用/不存在不计费),据 provider 选验签实现(防作弊:服务端→联盟回调验签)
|
||||
// 1) 取广告位配置(停用/不存在不计费)
|
||||
AdSlotDO slot = adSlotService.getSlotBySlotId(reqVO.getSlotId());
|
||||
if (slot == null || !Boolean.TRUE.equals(slot.getEnabled())) {
|
||||
throw exception(AD_REPORT_SLOT_DISABLED);
|
||||
}
|
||||
// 2) 验签:provider 的 verifyRewardCallback(callback 桩/真实 csj/gdt 用联盟密钥校验 sign);失败不计费不发奖
|
||||
AdProvider provider = adProviderFactory.get(slot.getProvider());
|
||||
String providerKey = slot.getProvider();
|
||||
// 2) 熔断(止血):force-mock=true 时拒绝一切真实联盟位(csj/gdt)回调,不再接受可能有问题的联盟结算;
|
||||
// 内置桩位(mock/callback)不涉真实结算、不受影响,继续走各自验签语义。
|
||||
if (forceMock && !STUB_PROVIDERS.contains(providerKey)) {
|
||||
log.warn("[reportRewardCallback] force-mock 熔断期,拒绝真实联盟位回调(不计费不发奖)slotId={} traceId={} provider={}",
|
||||
reqVO.getSlotId(), reqVO.getTraceId(), providerKey);
|
||||
throw exception(AD_REWARD_CALLBACK_PROVIDER_NOT_READY);
|
||||
}
|
||||
// 3) fail-closed 取验签实现:回调端点 @PermitAll 公网可达,验签是唯一凭据,必须精确命中真实实现——
|
||||
// provider 声明为真实联盟但实现未注入(getStrict 返回 null)时拒绝,绝不降级 mock 直通铸币(禁用宽松的 get())。
|
||||
AdProvider provider = adProviderFactory.getStrict(providerKey);
|
||||
if (provider == null) {
|
||||
log.warn("[reportRewardCallback] provider 未注册真实实现,拒绝回调(防降级 mock 铸币)slotId={} traceId={} provider={}",
|
||||
reqVO.getSlotId(), reqVO.getTraceId(), providerKey);
|
||||
throw exception(AD_REWARD_CALLBACK_PROVIDER_NOT_READY);
|
||||
}
|
||||
// 4) 验签:provider 的 verifyRewardCallback(callback 桩/真实 csj/gdt 用联盟密钥校验 sign);失败不计费不发奖
|
||||
AdRewardCallbackContext ctx = new AdRewardCallbackContext(reqVO.getSlotId(), reqVO.getTraceId(),
|
||||
reqVO.getTransId(), reqVO.getSign(), reqVO.getTimestamp(), reqVO.getExtra());
|
||||
if (!provider.verifyRewardCallback(ctx)) {
|
||||
log.warn("[reportRewardCallback] 回调验签失败,不计费不发奖 slotId={} traceId={} provider={}",
|
||||
reqVO.getSlotId(), reqVO.getTraceId(), slot.getProvider());
|
||||
reqVO.getSlotId(), reqVO.getTraceId(), providerKey);
|
||||
throw exception(AD_REWARD_CALLBACK_SIGN_INVALID);
|
||||
}
|
||||
// 3) 验签通过:按 reward 计费落台账(复用 bill,幂等键=traceId,归因同 impression/reward)
|
||||
// 5) 验签通过:按 reward 计费落台账(复用 bill,幂等键=traceId,归因同 impression/reward)
|
||||
AdBillingResult billed = bill(reqVO.getSlotId(), reqVO.getGameId(), reqVO.getTraceId(),
|
||||
null, AdEventTypeEnum.REWARD);
|
||||
AdRewardCallbackResultVO result = new AdRewardCallbackResultVO();
|
||||
|
||||
@ -11,6 +11,7 @@ import com.wanxiang.huijing.game.module.ad.enums.AdEventTypeEnum;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.compliance.AdComplianceChecker;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.compliance.AdComplianceResult;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.AdProviderFactory;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.CallbackAdProvider;
|
||||
import com.wanxiang.huijing.game.module.ad.framework.provider.MockAdProvider;
|
||||
import com.wanxiang.huijing.game.module.ad.service.slot.AdSlotService;
|
||||
import com.wanxiang.huijing.game.module.project.api.ProjectApi;
|
||||
@ -21,6 +22,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
@ -209,12 +211,13 @@ class AdRevenueServiceImplTest extends BaseMockitoUnitTest {
|
||||
d.setId(100L);
|
||||
return 1;
|
||||
}).when(adRevenueMapper).insert(any(AdRevenueDO.class));
|
||||
// 工厂返回 callback provider(验签通过,计 reward 收入)
|
||||
// 工厂返回 callback provider(验签走 fail-closed 的 getStrict、算钱走 get,均命中已注册的 callback 实现)
|
||||
com.wanxiang.huijing.game.module.ad.framework.provider.AdProvider cbProvider =
|
||||
mock(com.wanxiang.huijing.game.module.ad.framework.provider.AdProvider.class);
|
||||
when(cbProvider.verifyRewardCallback(any())).thenReturn(true);
|
||||
when(cbProvider.calcRevenue(3000L)).thenReturn(3L);
|
||||
when(adProviderFactory.get("callback")).thenReturn(cbProvider);
|
||||
when(adProviderFactory.getStrict("callback")).thenReturn(cbProvider); // 回调验签走 getStrict(不降级 mock)
|
||||
when(adProviderFactory.get("callback")).thenReturn(cbProvider); // bill 内算钱仍走 get
|
||||
|
||||
var result = adRevenueService.reportRewardCallback(callbackReq("slot_cb", "trace-cb", "validsign"));
|
||||
|
||||
@ -234,7 +237,7 @@ class AdRevenueServiceImplTest extends BaseMockitoUnitTest {
|
||||
com.wanxiang.huijing.game.module.ad.framework.provider.AdProvider cbProvider =
|
||||
mock(com.wanxiang.huijing.game.module.ad.framework.provider.AdProvider.class);
|
||||
when(cbProvider.verifyRewardCallback(any())).thenReturn(false); // 验签失败
|
||||
when(adProviderFactory.get("callback")).thenReturn(cbProvider);
|
||||
when(adProviderFactory.getStrict("callback")).thenReturn(cbProvider); // 回调验签走 getStrict;失败抛错不进 bill
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> adRevenueService.reportRewardCallback(callbackReq("slot_cb", "trace-cb", "badsign")));
|
||||
@ -250,6 +253,124 @@ class AdRevenueServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(AD_REPORT_SLOT_DISABLED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ============================== M4/R3 加固:回调铸币面 fail-closed 全组合矩阵 ==============================
|
||||
// provider∈{mock, callback(空 secret), csj(未注册)} × force-mock{off,on}。
|
||||
// 核心断言:任何组合都打不开回调铸币面——未命中真实验签实现的回调一律拒绝、不落台账;force-mock 熔断只收紧不放行。
|
||||
|
||||
@Test
|
||||
void testCallback_mock_forceMockOff_rejectedNoBilling() {
|
||||
// provider=mock(内置桩,不接联盟回调):MockAdProvider.verifyRewardCallback fail-closed 返回 false → 拒
|
||||
AdSlotDO slot = enabledSlot("slot_m", "rewarded", 3000L); // enabledSlot 默认 provider=mock
|
||||
when(adSlotService.getSlotBySlotId("slot_m")).thenReturn(slot);
|
||||
when(adProviderFactory.getStrict("mock")).thenReturn(new MockAdProvider());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> adRevenueService.reportRewardCallback(callbackReq("slot_m", "trace-m", "anysign")));
|
||||
assertEquals(AD_REWARD_CALLBACK_SIGN_INVALID.getCode(), ex.getCode());
|
||||
verify(adRevenueMapper, never()).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallback_mock_forceMockOn_rejectedNoBilling() {
|
||||
// force-mock=on 不改变 mock 位回调语义(mock∈内置桩,不触发熔断拒),仍走 fail-closed 返回 false → 拒
|
||||
ReflectionTestUtils.setField(adRevenueService, "forceMock", true);
|
||||
AdSlotDO slot = enabledSlot("slot_m", "rewarded", 3000L);
|
||||
when(adSlotService.getSlotBySlotId("slot_m")).thenReturn(slot);
|
||||
when(adProviderFactory.getStrict("mock")).thenReturn(new MockAdProvider());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> adRevenueService.reportRewardCallback(callbackReq("slot_m", "trace-m", "anysign")));
|
||||
assertEquals(AD_REWARD_CALLBACK_SIGN_INVALID.getCode(), ex.getCode());
|
||||
verify(adRevenueMapper, never()).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallback_callbackNoSecret_forceMockOff_rejectedNoBilling() {
|
||||
// provider=callback,空 sign-secret 且未开 sandbox-passthrough → CallbackAdProvider 默认拒(P2-2)→ 拒
|
||||
AdSlotDO slot = enabledSlot("slot_cb", "rewarded", 3000L);
|
||||
slot.setProvider("callback");
|
||||
when(adSlotService.getSlotBySlotId("slot_cb")).thenReturn(slot);
|
||||
when(adProviderFactory.getStrict("callback")).thenReturn(callbackProvider("", false));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> adRevenueService.reportRewardCallback(callbackReq("slot_cb", "trace-cb", "anysign")));
|
||||
assertEquals(AD_REWARD_CALLBACK_SIGN_INVALID.getCode(), ex.getCode());
|
||||
verify(adRevenueMapper, never()).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallback_callbackNoSecret_forceMockOn_rejectedNoBilling() {
|
||||
// force-mock=on 不改变 callback 桩语义(callback∈内置桩,不触发熔断拒),空 secret 仍默认拒 → 拒
|
||||
ReflectionTestUtils.setField(adRevenueService, "forceMock", true);
|
||||
AdSlotDO slot = enabledSlot("slot_cb", "rewarded", 3000L);
|
||||
slot.setProvider("callback");
|
||||
when(adSlotService.getSlotBySlotId("slot_cb")).thenReturn(slot);
|
||||
when(adProviderFactory.getStrict("callback")).thenReturn(callbackProvider("", false));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> adRevenueService.reportRewardCallback(callbackReq("slot_cb", "trace-cb", "anysign")));
|
||||
assertEquals(AD_REWARD_CALLBACK_SIGN_INVALID.getCode(), ex.getCode());
|
||||
verify(adRevenueMapper, never()).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallback_csjUnregistered_forceMockOff_providerNotReady() {
|
||||
// provider=csj 但真实实现未注入(getStrict 返回 null)→ fail-closed 拒,绝不降级 mock 直通铸币
|
||||
AdSlotDO slot = enabledSlot("slot_csj", "rewarded", 3000L);
|
||||
slot.setProvider("csj");
|
||||
when(adSlotService.getSlotBySlotId("slot_csj")).thenReturn(slot);
|
||||
// 不 stub getStrict("csj"):@Mock 默认返回 null,正是"真实实现未注册"
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> adRevenueService.reportRewardCallback(callbackReq("slot_csj", "trace-csj", "anysign")));
|
||||
assertEquals(AD_REWARD_CALLBACK_PROVIDER_NOT_READY.getCode(), ex.getCode());
|
||||
verify(adRevenueMapper, never()).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallback_csjUnregistered_forceMockOn_circuitRejected() {
|
||||
// force-mock=on 熔断期:csj 为真实联盟位(∉内置桩)→ 验签前直接拒(止血),不调 getStrict、不计费
|
||||
ReflectionTestUtils.setField(adRevenueService, "forceMock", true);
|
||||
AdSlotDO slot = enabledSlot("slot_csj", "rewarded", 3000L);
|
||||
slot.setProvider("csj");
|
||||
when(adSlotService.getSlotBySlotId("slot_csj")).thenReturn(slot);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> adRevenueService.reportRewardCallback(callbackReq("slot_csj", "trace-csj", "anysign")));
|
||||
assertEquals(AD_REWARD_CALLBACK_PROVIDER_NOT_READY.getCode(), ex.getCode());
|
||||
verify(adRevenueMapper, never()).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallback_callbackPassthrough_forceMockOff_billsReward() {
|
||||
// 正路径不误伤:provider=callback + 显式 sandbox-passthrough(验签通过)+ force-mock=off → 正常计费落台账
|
||||
AdSlotDO slot = enabledSlot("slot_cb", "rewarded", 3000L);
|
||||
slot.setProvider("callback");
|
||||
stubCallbackBillReady(slot, "trace-cb", callbackProvider("", true)); // 空 secret + passthrough:验形态直通
|
||||
|
||||
var result = adRevenueService.reportRewardCallback(callbackReq("slot_cb", "trace-cb", "anysign"));
|
||||
|
||||
assertTrue(result.getAccepted());
|
||||
assertTrue(result.getRewarded());
|
||||
assertEquals(3L, result.getRevenueAmount()); // callback 桩计费口径 = ecpm/1000 = 3
|
||||
verify(adRevenueMapper).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallback_callbackPassthrough_forceMockOn_stubStillBills() {
|
||||
// 熔断精确:force-mock=on 只拒真实联盟位,不误伤内置 callback 桩 → callback 正常回调仍计费
|
||||
ReflectionTestUtils.setField(adRevenueService, "forceMock", true);
|
||||
AdSlotDO slot = enabledSlot("slot_cb", "rewarded", 3000L);
|
||||
slot.setProvider("callback");
|
||||
stubCallbackBillReady(slot, "trace-cb", callbackProvider("", true));
|
||||
|
||||
var result = adRevenueService.reportRewardCallback(callbackReq("slot_cb", "trace-cb", "anysign"));
|
||||
|
||||
assertTrue(result.getRewarded());
|
||||
assertEquals(3L, result.getRevenueAmount());
|
||||
verify(adRevenueMapper).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/** 备齐「合规通过 + 无重复 + 归因到 99 + 工厂返回 mock provider + insert 回填 ID」的计费前置 */
|
||||
@ -266,6 +387,29 @@ class AdRevenueServiceImplTest extends BaseMockitoUnitTest {
|
||||
}).when(adRevenueMapper).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
/** 构造真实 CallbackAdProvider 并注入 sign-secret/sandbox-passthrough(回调矩阵用真实桩验签逻辑,不打桩掉验签) */
|
||||
private static CallbackAdProvider callbackProvider(String signSecret, boolean sandboxPassthrough) {
|
||||
CallbackAdProvider cb = new CallbackAdProvider();
|
||||
ReflectionTestUtils.setField(cb, "signSecret", signSecret);
|
||||
ReflectionTestUtils.setField(cb, "sandboxPassthrough", sandboxPassthrough);
|
||||
return cb;
|
||||
}
|
||||
|
||||
/** callback 位验签通过后走 bill 落台账的前置桩(验签走 getStrict、算钱走 get,均命中同一 callback 实例) */
|
||||
private void stubCallbackBillReady(AdSlotDO slot, String traceId, CallbackAdProvider cb) {
|
||||
when(adSlotService.getSlotBySlotId(slot.getSlotId())).thenReturn(slot);
|
||||
when(adProviderFactory.getStrict("callback")).thenReturn(cb);
|
||||
when(adProviderFactory.get("callback")).thenReturn(cb);
|
||||
when(adComplianceChecker.checkBilling(eq(slot), any())).thenReturn(AdComplianceResult.PASS);
|
||||
when(adRevenueMapper.selectByTrace(traceId, AdEventTypeEnum.REWARD.getType())).thenReturn(null);
|
||||
when(projectApi.getCreatorUserId(any())).thenReturn(CommonResult.success(99L));
|
||||
doAnswer(inv -> {
|
||||
AdRevenueDO d = inv.getArgument(0);
|
||||
d.setId(100L);
|
||||
return 1;
|
||||
}).when(adRevenueMapper).insert(any(AdRevenueDO.class));
|
||||
}
|
||||
|
||||
/** 构造启用广告位(provider=mock) */
|
||||
private static AdSlotDO enabledSlot(String slotId, String type, Long ecpmFloor) {
|
||||
AdSlotDO slot = new AdSlotDO();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user