From 3ac58c1670ddcb3dff6d862ea0bbcc0ffa4d4203 Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Mon, 15 Jun 2026 22:41:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(ad):=20=E8=AE=B0=E5=BD=95=E4=B8=8D?= =?UTF-8?q?=E5=8F=91=E9=87=91=E5=B8=81=E7=9A=84=E5=8E=9F=E5=9B=A0(closed?= =?UTF-8?q?=5Fearly=20/=20too=5Fshort)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让广告收益报表能呈现「有展示、没发金币」的原因: - grant_feed_reward 改整场结算:aborted→closed_early、整场总时长<10s→too_short(均 coin=0) - 新增 record_reward_noshow + POST /api/v1/ad/reward-noshow(激励视频提前关留痕; 同 ad_session_id 已 granted 则跳过,防与 S2S 发奖重复) - feed-reward 加 aborted 入参 - 文档:feed-reward / 两张表 status 取值、新增 ad-reward-noshow、README 索引 - alembic: 合并 ad_revenue_report_cols 与 store_mapping_tb_dl_invalid 双 head(空迁移) 报表复算无需改(非 granted 状态走 else 分支 expected=0 自动归类)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...merge_ad_revenue_report_cols_and_store_.py | 26 ++++++++++ app/api/v1/ad.py | 30 +++++++++++ app/repositories/ad_feed_reward.py | 51 +++++++++++++++++-- app/repositories/ad_reward.py | 48 +++++++++++++++++ app/schemas/ad.py | 42 +++++++++++++-- docs/api/README.md | 1 + docs/api/ad-feed-reward.md | 15 +++--- docs/api/ad-reward-noshow.md | 41 +++++++++++++++ docs/database/ad_feed_reward_record.md | 6 +-- docs/database/ad_reward_record.md | 8 +-- 10 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py create mode 100644 docs/api/ad-reward-noshow.md diff --git a/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py new file mode 100644 index 0000000..16fc94c --- /dev/null +++ b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py @@ -0,0 +1,26 @@ +"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads + +Revision ID: d4e68464761d +Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid +Create Date: 2026-06-15 21:55:58.115692 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4e68464761d' +down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 72ee34a..9508061 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -32,6 +32,8 @@ from app.schemas.ad import ( FeedRewardIn, FeedRewardOut, PangleCallbackOut, + RewardNoShowIn, + RewardNoShowOut, TestGrantIn, TestGrantOut, WatchReportIn, @@ -363,6 +365,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed slot_id=payload.slot_id, app_env=payload.app_env, our_code_id=payload.our_code_id, + aborted=payload.aborted, ) logger.info( "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", @@ -375,3 +378,30 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed unit_count=rec.unit_count, daily_limit=rewards.get_ad_daily_limit(db), ) + + +@router.post( + "/reward-noshow", + response_model=RewardNoShowOut, + summary="激励视频提前关闭/未发奖留痕", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))], +) +def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut: + """激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕, + 让广告收益报表能呈现「有展示、没发金币」的原因。不发金币;同一 session 已发奖则跳过。 + """ + rec = crud_ad.record_reward_noshow( + db, + user.id, + ad_session_id=payload.ad_session_id, + ecpm=payload.ecpm, + adn=payload.adn, + slot_id=payload.slot_id, + app_env=payload.app_env, + our_code_id=payload.our_code_id, + ) + logger.info( + "ad reward noshow user_id=%d session=%s watched=%ds -> status=%s", + user.id, payload.ad_session_id, payload.watched_seconds, rec.status, + ) + return RewardNoShowOut(ok=True, status=rec.status) diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index cd93b8b..64fe277 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -71,12 +71,17 @@ def grant_feed_reward( slot_id: str | None = None, app_env: str | None = None, our_code_id: str | None = None, + aborted: bool = False, ) -> AdFeedRewardRecord: - """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。 - 一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS - 限单事件份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额; - 叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 + 发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。 + - aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。 + - 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。 + - 命中当日条数上限:记 status='capped' 不发。 + duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 + FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 + AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 """ existing = _find_by_event(db, client_event_id) if existing is not None: @@ -87,6 +92,25 @@ def grant_feed_reward( safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + # 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。 + if aborted: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="closed_early", + ) + return _commit_record(db, rec, client_event_id) + if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): rec = AdFeedRewardRecord( client_event_id=client_event_id, @@ -105,6 +129,25 @@ def grant_feed_reward( ) return _commit_record(db, rec, client_event_id) + # 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。 + if unit_count == 0: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=0, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="too_short", + ) + return _commit_record(db, rec, client_event_id) + coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index 3c034e7..f9fb16f 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -145,6 +145,54 @@ def grant_ad_reward( return _commit_record(db, rec, trans_id) +def record_reward_noshow( + db: Session, + user_id: int, + *, + ad_session_id: str, + ecpm: str | None = None, + adn: str | None = None, + slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, + reward_scene: str = "reward_video", +) -> AdRewardRecord: + """客户端上报「激励视频展示了但用户提前关/跳过、未触发发奖」,落一条 coin=0 status='closed_early' + 记录,供广告收益报表把「不发金币的原因」也呈现出来(只留痕,不发币)。 + + 幂等键 trans_id = 'noreward:{ad_session_id}'(每次展示唯一)。若同一 ad_session_id 已有 granted + 记录(S2S 已发奖,正常路径),说明用户其实看完了 → 跳过不写、原样返回那条,避免与正常发奖重复。 + app_env/our_code_id 由客户端直接带上(它本就持有);查不到 user 抛 UnknownUserError。 + """ + if db.get(User, user_id) is None: + raise UnknownUserError + # 同一次展示已正常发奖 → 不再记 closed_early(防与 S2S granted 重复) + granted = db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.status == "granted", + ) + .limit(1) + ).scalar_one_or_none() + if granted is not None: + return granted + + trans_id = f"noreward:{ad_session_id}" + existing = _find_by_trans(db, trans_id) + if existing is not None: + return existing + + rec = AdRewardRecord( + trans_id=trans_id, user_id=user_id, coin=0, status="closed_early", + reward_date=cn_today().isoformat(), reward_name=None, raw=None, + reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + app_env=app_env, our_code_id=our_code_id, + ) + return _commit_record(db, rec, trans_id) + + def record_external_reward( db: Session, user_id: int, diff --git a/app/schemas/ad.py b/app/schemas/ad.py index dac05a9..ae56aab 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -121,17 +121,18 @@ class TestGrantOut(BaseModel): class FeedRewardIn(BaseModel): - """信息流广告完成后结算奖励。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。 - 每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。 + 规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于 + 客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 """ client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) - ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)") - duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") + ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)") + duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)") adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") app_env: str | None = Field( @@ -140,11 +141,42 @@ class FeedRewardIn(BaseModel): our_code_id: str | None = Field( None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)" ) + aborted: bool = Field( + False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early" + ) class FeedRewardOut(BaseModel): granted: bool = Field(..., description="本次是否入账。达上限时 false") - status: str = Field(..., description="granted / capped") + status: str = Field(..., description="granted / capped / too_short / closed_early") coin: int = Field(..., description="本次发放金币") unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") daily_limit: int = Field(..., description="每日信息流展示次数上限") + + +class RewardNoShowIn(BaseModel): + """激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。 + + 供广告收益报表把「有展示、没发金币」的原因也记录下来。user_id 由 JWT 取,不在 body。 + """ + + ad_session_id: str = Field( + ..., min_length=8, max_length=64, description="本次展示会话 id(与 ecpm 上报、S2S extra 一致)" + ) + watched_seconds: int = Field(0, ge=0, description="关闭前已观看秒数(仅留痕参考,不入库)") + ecpm: str | None = Field(None, description="本次展示 eCPM(分/千次,SDK getEcpm 原值);可空") + adn: str | None = Field(None, description="实际投放 ADN") + slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod / test" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(104xxx)" + ) + + +class RewardNoShowOut(BaseModel): + ok: bool = Field(..., description="是否处理成功(落库或幂等命中)") + status: str = Field( + ..., description="closed_early(已留痕) / granted(该次其实已发奖,跳过未写)" + ) diff --git a/docs/api/README.md b/docs/api/README.md index 6297ba5..25e8ec6 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -63,6 +63,7 @@ | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | +| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index 4a72f38..23516a7 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -1,6 +1,6 @@ # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 -点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 +点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 ## 鉴权 @@ -12,26 +12,27 @@ |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | -| `duration_seconds` | int | 是 | 实际展示/播放秒数 | +| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | +| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) | | `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) | | `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | | `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | | `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 | +| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` | ## 响应 | 字段 | 类型 | 说明 | |---|---|---| -| `granted` | bool | 本次是否入账;达上限时为 `false` | -| `status` | string | `granted` / `capped` | -| `coin` | int | 本次发放金币 | +| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` | +| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) | +| `coin` | int | 本次发放金币;非 granted 为 0 | | `unit_count` | int | 按 10 秒折算出的奖励份数 | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 | ## 计算口径 -- 奖励份数:`duration_seconds // 10`。 +- 奖励份数:`整场总时长 // 10`。 - 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 - eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。 - LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 diff --git a/docs/api/ad-reward-noshow.md b/docs/api/ad-reward-noshow.md new file mode 100644 index 0000000..a36b69b --- /dev/null +++ b/docs/api/ad-reward-noshow.md @@ -0,0 +1,41 @@ +# POST /api/v1/ad/reward-noshow — 激励视频提前关闭/未发奖留痕 + +激励视频**展示了但用户提前关闭/跳过、未触发 S2S 发奖**时,客户端 best-effort 上报一条留痕记录, +让运营后台「广告数据」能呈现「有展示、没发金币」的原因。**不发金币**。 + +## 鉴权 + +需要 Bearer token。`user_id` 由 JWT 取,不在 body。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `ad_session_id` | string | 是 | 本次展示会话 id(与 eCPM 上报、S2S `extra.ad_session_id` 一致),8-64 字符 | +| `watched_seconds` | int | 否 | 关闭前已观看秒数(仅留痕参考,**不入库**)。默认 0 | +| `ecpm` | string\|null | 否 | 本次展示 eCPM(穿山甲 getEcpm 原值,分/千次展示) | +| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充子渠道) | +| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | +| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit) | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ok` | bool | 是否处理成功(落库或幂等命中) | +| `status` | string | `closed_early`(已留痕) / `granted`(该次其实已发奖,跳过未写) | + +## 说明 + +- 在 `onAdClose` 时若本次**未触发发奖**(无 `onRewardVerify`/`onRewardArrived`)调用。 +- **幂等**:写入 `ad_reward_record`,`trans_id = noreward:{ad_session_id}`(每次展示唯一),重试不重复。 +- **防与正常发奖重复**:若同一 `ad_session_id` 已有 `status='granted'` 记录(S2S 已发,说明用户其实看完了), + 则跳过不写,返回 `status='granted'`。 +- best-effort:客户端 fire-and-forget,失败只 log,不影响主流程。 +- 信息流的「用户中途关闭」走 [ad-feed-reward](./ad-feed-reward.md) 的 `aborted=true`(记 `closed_early`),不走本接口。 + +## 数据写入 + +- `ad_reward_record` 新增一行(`coin=0`、`status='closed_early'`、`reward_scene='reward_video'`)。 +- **不**写 `coin_account` / `coin_transaction`(不发币)。 diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index b0ab406..99cccb4 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -11,15 +11,15 @@ | `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | -| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 | +| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) | | `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 | | `adn` | String(32) | nullable | 实际投放 ADN(聚合后实际填充子渠道) | | `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试),客户端 feed-reward 上报;旧数据 NULL。广告收益报表金币侧按它聚合 | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx,客户端上报;旧数据 NULL | -| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 | -| `status` | String(16) | NOT NULL | `granted` / `capped` | +| `coin` | Integer | NOT NULL | 实发金币,非 `granted` 时为 0 | +| `status` | String(16) | NOT NULL | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途 ✕ 关闭,全程未看完) | | `created_at` | DateTime | index, NOT NULL | 创建时间 | ## 约束 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 18db21b..07c08be 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -5,7 +5,7 @@ 每条 = 穿山甲一次**服务端激励回调**。`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 公式发币。 +- **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`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 @@ -13,15 +13,15 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) | +| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`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` 普通激励视频;`signin_boost` 签到膨胀 | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | | `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`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` | +| `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 留痕)/ `not_signed`/`already_boosted`/`last_day` | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) |