From 00ebeedc50ec4c41945c51fdd05b459833c4a066 Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 2 Jul 2026 20:41:08 -0700 Subject: [PATCH] =?UTF-8?q?fix(ad):=20M4=20=E5=9B=9E=E8=B0=83=E9=AA=8C?= =?UTF-8?q?=E7=AD=BE=20fail-closed=20=E5=B0=81=E9=93=B8=E5=B8=81=E9=9D=A2?= =?UTF-8?q?=20+=20M4=E5=B9=BF=E5=91=8ASDK=E9=A2=84=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=9B=9E=E7=82=89(R3=20=E5=8F=8C=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20BLOCKER/MAJOR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码加固(仅 game-module-ad): - AdProviderFactory 增 getStrict(未注册返 null、绝不降级 mock),回调路径改用之;get() 展示计费降级保持不变 - MockAdProvider 覆写 verifyRewardCallback 返 false(mock 不接联盟回调,纵深防御第一层) - reportRewardCallback fail-closed:provider 未就绪抛 PROVIDER_NOT_READY(新增 1-111-002-005) - ad.force-mock 熔断:只拒真实联盟位回调、不碰 calcRevenue、不影响 mock/callback 桩 - 回调铸币面全组合矩阵单测 8 格(provider×force-mock);ad 模块 33 单测全绿(Callback 7/AdSlot 6/AdRevenue 20) 设计档回炉(逐条吃双评审): - B1 三层开关安全语义拆分(算钱降级宽松 vs 验签 fail-closed)+ runbook 切前 fail-closed 闸 - B2 冲正定死:不估算入账,以联盟 T+1 账单实际金额为唯一 trade 入账源(消除冲正,uk_source 幂等/账户恒等式成立) - M1 热刷新 @RefreshScope 方案入档(本机验不了标 follow-up,runbook 滚动重启兜底 + 资损敞口) - M2 契约断言改真话(callback 枚举 R4 bc0f486b 已补齐;AdSlotSaveReqVO 无 enum 强校验如实、R3 不补理由) - 防重放窗口配置项预置(owner/默认300/上限900/timestamp fail-safe)+ ACK/来源IP进待核清单 + runbook 放量停止阈值 红线守住:不动 trade(冲正只设计)/不动 contracts(R4 已收口)/不写真实 provider 类。docs-gate 七检全绿。 Co-Authored-By: Claude Fable 5 --- .../2026-07-02-M4广告SDK预接线-设计.md | 162 +++++++++++++----- .../module/ad/enums/ErrorCodeConstants.java | 6 + .../framework/provider/AdProviderFactory.java | 18 ++ .../ad/framework/provider/MockAdProvider.java | 9 + .../service/revenue/AdRevenueServiceImpl.java | 45 ++++- .../revenue/AdRevenueServiceImplTest.java | 150 +++++++++++++++- 6 files changed, 339 insertions(+), 51 deletions(-) diff --git a/docs/agent-specs/2026-07-02-M4广告SDK预接线-设计.md b/docs/agent-specs/2026-07-02-M4广告SDK预接线-设计.md index 63047a22..fe6d5be0 100644 --- a/docs/agent-specs/2026-07-02-M4广告SDK预接线-设计.md +++ b/docs/agent-specs/2026-07-02-M4广告SDK预接线-设计.md @@ -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..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
@PermitAll + IP 限频"] - F["AdProviderFactory.get(provider)
未注册→降级 mock;force-mock→全局回退"] - V["provider.verifyRewardCallback
验签(密钥 per-provider)"] + FUSE{"force-mock 熔断?
真实联盟位(非 mock/callback)"} + STRICT["getStrict(provider)
严格命中,未注册返 null 即拒
绝不降级 mock"] + V["provider.verifyRewardCallback
验签(密钥 per-provider)
mock 恒拒 · callback 空密钥拒"] BILL["bill():合规→uk_trace 幂等→归因→算收入→落台账"] - REC["ad↔联盟对账(本单新增设计)
估算入账 vs 联盟账单,T+1 冲正"] + REC["ad↔联盟对账
不估算入账,以联盟 T+1 账单
实际金额为唯一 trade 入账源"] end SDK -. 用户看完,联盟服务端回调 .-> CB - CB --> F --> V -->|通过| BILL + CB --> FUSE + FUSE -->|真实位 + 熔断| X["拒:不计费不发奖 + 告警
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..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..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..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..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 闸、无新增真实计费断言、放量与停止阈值、回退演练。 diff --git a/game-cloud/game-module-ad/game-module-ad-api/src/main/java/com/wanxiang/huijing/game/module/ad/enums/ErrorCodeConstants.java b/game-cloud/game-module-ad/game-module-ad-api/src/main/java/com/wanxiang/huijing/game/module/ad/enums/ErrorCodeConstants.java index d9e4ed3d..11664974 100644 --- a/game-cloud/game-module-ad/game-module-ad-api/src/main/java/com/wanxiang/huijing/game/module/ad/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-ad/game-module-ad-api/src/main/java/com/wanxiang/huijing/game/module/ad/enums/ErrorCodeConstants.java @@ -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,禁回退) */ diff --git a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/AdProviderFactory.java b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/AdProviderFactory.java index 044543bd..eb5f0bf4 100644 --- a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/AdProviderFactory.java +++ b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/AdProviderFactory.java @@ -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); + } + } diff --git a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/MockAdProvider.java b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/MockAdProvider.java index 0d6e3763..6cc50b17 100644 --- a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/MockAdProvider.java +++ b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/framework/provider/MockAdProvider.java @@ -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; + } + } diff --git a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImpl.java b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImpl.java index a4c7ca8d..a49b5ecf 100644 --- a/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImpl.java +++ b/game-cloud/game-module-ad/game-module-ad-server/src/main/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImpl.java @@ -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 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(); diff --git a/game-cloud/game-module-ad/game-module-ad-server/src/test/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImplTest.java b/game-cloud/game-module-ad/game-module-ad-server/src/test/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImplTest.java index 2095a45c..af5da81e 100644 --- a/game-cloud/game-module-ad/game-module-ad-server/src/test/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImplTest.java +++ b/game-cloud/game-module-ad/game-module-ad-server/src/test/java/com/wanxiang/huijing/game/module/ad/service/revenue/AdRevenueServiceImplTest.java @@ -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();