# 穿山甲激励视频发奖回调 — 验签（pangle）

> 文件:`app/integrations/pangle.py` | 关联接口:[ad-pangle-callback](../api/ad-pangle-callback.md) | [← 集成索引](./README.md)

## 作用
穿山甲激励视频播完后，穿山甲**服务器**会 GET 回调我们的 `/api/v1/ad/pangle-callback`（带 `user_id` / `trans_id` / `sign` 等 query）。本模块负责**验签**，确认回调真来自穿山甲而非伪造请求；验签通过后由数据层（`repositories/ad_reward`）幂等发金币。客户端不参与发奖，被破解也刷不到钱。

## 验签方案（关键）
**`sign = SHA256("{m-key}:{trans_id}")` 的十六进制串。**

- 是**普通 SHA256**，不是 HMAC，也不是 RSA。
- **只对 `m-key:trans_id` 这一个字符串**签名，`user_id` / `ecpm` / `extra` 等其余参数**不参与**签名。它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 连合法 sign 都造不出，自然无法注入假 `user_id`。
- 这是穿山甲 **GroMore「广告位 → 服务端激励回调」官方规范**（supportcenter/26240）。我们客户端是 `useMediation(true)` 融合，回调走 **GroMore 广告位层级**，不是联盟代码位层级。

## 函数
| 函数 | 说明 |
|---|---|
| `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用，保证两端一致 |
| `verify_callback_sign(params, secret) -> bool` | 用**单个** m-key 校验回调签名。密钥空 / 缺 `trans_id` 一律失败；比较用 `hmac.compare_digest` 定长比较防时序侧信道 |
| `verify_callback_sign_any(params, secrets) -> bool` | 用**一组** m-key 逐个验签、任一通过即接受(多激励位共用同一回调 URL 时用)。空列表 → 失败 |

## 配置(多激励位 = 多 m-key)
每个 GroMore 激励位的 m-key 由后台各自生成、互不相同,但本服务用**同一个回调 URL** 接所有位的回调,因此把用到的位的 m-key 都配上;验签时 `verify_callback_sign_any` 逐个试、任一过即接受(仍安全:伪造者须知道其中某个 m-key 才能造出合法 sign)。m-key 在后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取。`settings.pangle_reward_secrets` 把下列来源汇总去重:

| 配置项 | 说明 |
|---|---|
| `PANGLE_REWARD_SECRET_TEST` | 测试应用 激励位 104099649 的 m-key |
| `PANGLE_REWARD_SECRET_TEST_DEDICATED` | 测试应用 专属激励位 104127529 的 m-key |
| `PANGLE_REWARD_SECRET_PROD` | 正式应用 激励位 104099389 的 m-key |
| `PANGLE_REWARD_SECRET` | 旧用法:单个或逗号分隔多个 m-key,仍兼容(与上面三个命名项合并去重) |

## 踩坑
- **别用联盟代码位那套**：联盟代码位层级用的是另一套 Security Key + `isValid` 响应体，与 GroMore 广告位层级不通用。我们走 GroMore，别接错。
- 验签失败时 api 层返 `403`（留给真请求重试）；验签过但参数缺/坏或 user 不存在，则受理不发奖、不让穿山甲重试。
- 本地无公网、回调打不到时，用 [ad-test-grant](../api/ad-test-grant.md) 模拟（仅联调，开关控制）。
