# ad_reward_record — 看激励视频发奖记录(S2S 回调)

> 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)

每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、提现看视频等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。

## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。
- **U / D**:无。
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。

## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) |
| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频(当前唯一发币场景);`withdrawal_ad` 提现门槛视频(只留痕不发币);`signin_boost` **历史值,2026-07 已下线** |
| `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` |
| `boost_round_id` | String(64) | nullable | 「这条广告属于哪一轮膨胀」,来自 `extra.boost_round_id`。一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条。**纯标签,不参与发奖判定**;仅供 `/ad/reward-result` 求和出 `round_coin`(弹窗显示的累计值)。老客户端 / extra 丢失时 NULL |
| `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 |
| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 |
| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) |
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |

## 关系 / Join Key
- `user_id` → `user.id`(多对一)。
- `trans_id` ← 被 `coin_transaction.ref_id` 引用(`granted` 那条发币流水);未发币状态无对应流水。
- `ad_session_id` → 可关联 `ad_ecpm_record.ad_session_id`。

## 索引与约束
- PK `id`;UNIQUE+index `trans_id`;index `user_id`、`reward_date`、`created_at`、`ad_session_id`。
- 复合 index `ix_ad_reward_user_boost_round` = `(user_id, boost_round_id)`:算「本轮累计已发」用。**求和恒带 `user_id`** —— `boost_round_id` 是客户端生成的,不带 `user_id` 等于让任何人拿别人的轮 id 查别人发了多少。

## 注意
- 普通激励视频按 eCPM 公式发奖;若 S2S 与客户端会话上报都缺 eCPM,记录 `status='ecpm_missing'`、`coin=0`,不发币。
- 签到膨胀(`reward_scene=signin_boost`)2026-07 已下线,存量行保留供对账;签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`(按 eCPM 公式发)。
- **膨胀轮累计**:`SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted'`,由 `/ad/reward-result` 返回为 `round_coin`。客户端就算一直复用同一个轮 id,也只是把展示数字滚大 —— 求和的是**已发生**的发奖记录,不产生任何新入账,无资损风险。
- 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。
