diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index bb05891..9b98afc 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -29,6 +29,8 @@ from app.schemas.ad import ( AdRewardStatusOut, EcpmReportIn, EcpmReportOut, + FeedPreviewIn, + FeedPreviewOut, FeedRewardIn, FeedRewardOut, PangleCallbackOut, @@ -343,6 +345,23 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N ) +def _feed_segments(payload: FeedRewardIn) -> tuple[list[tuple[str, int]], str]: + """把 feed-reward 入参归一成 `(segments, ecpm_repr)`。 + + 优先用逐条 segments(每条自带 eCPM);未传则回退把 ecpm + duration_seconds 当单段(旧客户端)。 + ecpm_repr 取末段 eCPM(报表/审计落库代表值)。两者都缺 → 422。 + """ + if payload.segments: + segs = [(s.ecpm, max(0, s.seconds)) for s in payload.segments] + return segs, segs[-1][0] + if payload.ecpm is not None: + return [(payload.ecpm, max(0, payload.duration_seconds))], payload.ecpm + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="feed-reward 需提供 segments 或 ecpm", + ) + + @router.post( "/feed-reward", response_model=FeedRewardOut, @@ -350,16 +369,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))], ) def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut: - """点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。 + """点位 2:信息流广告每展示满 10 秒累计一份奖励,整场结束(全程不关)一次性入账。 - 当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。 + 新客户端按条上报 [segments](每条 eCPM + 秒数),服务端按时序跨条进位精确结算;旧客户端只传 + ecpm + duration_seconds 时回退当单段。client_event_id 做幂等键,避免重试重复发。 """ + segments, ecpm_repr = _feed_segments(payload) rec = crud_feed.grant_feed_reward( db, user.id, client_event_id=payload.client_event_id, - ecpm=payload.ecpm, - duration_seconds=payload.duration_seconds, + segments=segments, + ecpm_repr=ecpm_repr, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, @@ -380,6 +401,23 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed ) +@router.post( + "/feed-reward/preview", + response_model=FeedPreviewOut, + summary="信息流播放中预演此刻将发放的金币(只读,不入账)", + dependencies=[Depends(rate_limit(600, 60, "ad-feed-preview"))], +) +def feed_reward_preview(payload: FeedPreviewIn, user: CurrentUser, db: DbSession) -> FeedPreviewOut: + """比价/领券金币小球实时显示**真实即将到账金额**用:按截至此刻的逐条 segments 算,与 + /feed-reward 同一口径(同份数封顶 / 同 LT / 同 eCPM 钳),但**不入账、不写库**。 + + 限流放宽到 600/分:小球每满 10 秒(或换条)拉一次,一整场最多十几次,留足并发余量。 + """ + segments = [(s.ecpm, max(0, s.seconds)) for s in payload.segments] + result = crud_feed.preview_feed_reward(db, user.id, segments) + return FeedPreviewOut(**result) + + @router.post( "/reward-noshow", response_model=RewardNoShowOut, diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index 64fe277..2295a01 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -16,10 +16,13 @@ from app.repositories import wallet as crud_wallet FEED_REWARD_UNIT_SECONDS = 10 -# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数 +# 单个 feed 事件的时长上限(秒):一期时长由客户端上报,伪造超长时长会刷份数 # (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。 # 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。 FEED_MAX_DURATION_SECONDS = 120 +# 单场份数上限(=120s//10):多条广告按时序「跨条进位」累计满 10 秒一份,封顶 12 份/场。 +# 时长改由逐条 segments 上报后,防刷的硬闸从「单事件时长钳」上移到这个份数上限(配合 eCPM 钳顶)。 +FEED_MAX_UNITS = FEED_MAX_DURATION_SECONDS // FEED_REWARD_UNIT_SECONDS def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None: @@ -42,21 +45,89 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() -def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int: - """按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。""" - if unit_count <= 0: - return 0 - existing_units = db.execute( - select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) - .where( - AdFeedRewardRecord.user_id == user_id, - AdFeedRewardRecord.status == "granted", +def _existing_granted_units(db: Session, user_id: int) -> int: + """该账号历史已 granted 的累计份数(LT 因子起点,不按天重置)。""" + return int( + db.execute( + select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)).where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.status == "granted", + ) + ).scalar_one() + ) + + +def _walk_unit_ecpms(segments: list[tuple[str, int]]) -> list[str]: + """按时序遍历各广告段,**跨条进位**累计满 10 秒结算一份,返回每份对应的 eCPM。 + + 每份归属「它满 10 秒那一刻正在播的那条广告的 eCPM」:换条时不足一份的零头进位到下一条 + (与旧版 `总时长//10` 份数口径一致、不浪费零头,但每份 eCPM 归属精确)。封顶 [FEED_MAX_UNITS] + 份;尾部不足一份的秒数丢弃。segments 为 `[(ecpm, seconds), ...]`,seconds≤0 的段跳过。 + """ + unit_ecpms: list[str] = [] + carry = 0 + for ecpm, seconds in segments: + if seconds <= 0: + continue + carry += seconds + while carry >= FEED_REWARD_UNIT_SECONDS and len(unit_ecpms) < FEED_MAX_UNITS: + carry -= FEED_REWARD_UNIT_SECONDS + unit_ecpms.append(ecpm) + if len(unit_ecpms) >= FEED_MAX_UNITS: + break + return unit_ecpms + + +def compute_feed_reward( + db: Session, user_id: int, segments: list[tuple[str, int]] +) -> tuple[int, int, int]: + """按逐条 segments 计算「此刻若结束将发放的金币」。**只读、不入账**,发奖与预览共用此口径。 + + 返回 `(coin, unit_count, next_unit_coin)`: + - coin:已累计满的各份金币之和(每份 = 所在段 eCPM × LT(账号累计份序号) × 汇率)。 + - unit_count:已满份数(封顶 [FEED_MAX_UNITS])。 + - next_unit_coin:下一份(第 unit_count+1 份)的边际金币,按当前在播段(segments 末段)eCPM 算; + 已封顶或无段时为 0。供客户端小球在「本份未满」区间内做平滑插值显示。 + """ + unit_ecpms = _walk_unit_ecpms(segments) + unit_count = len(unit_ecpms) + existing_units = _existing_granted_units(db, user_id) + coin = sum( + rewards.calculate_ad_reward_coin(ecpm, existing_units + i) + for i, ecpm in enumerate(unit_ecpms, start=1) + ) + if unit_count < FEED_MAX_UNITS and segments: + next_ecpm = segments[-1][0] + next_unit_coin = rewards.calculate_ad_reward_coin( + next_ecpm, existing_units + unit_count + 1 ) - ).scalar_one() - total = 0 - for offset in range(1, unit_count + 1): - total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset) - return total + else: + next_unit_coin = 0 + return coin, unit_count, next_unit_coin + + +def preview_feed_reward( + db: Session, user_id: int, segments: list[tuple[str, int]] +) -> dict: + """信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账、不写库)。 + + 给比价/领券金币小球实时显示**真实即将到账金额**用。would_status 预判若此刻结束的结算状态: + daily_capped(已达每日条数上限,整场将发 0)/ too_short(凑不满一份)/ granted。 + """ + coin, unit_count, next_unit_coin = compute_feed_reward(db, user_id, segments) + if _granted_today(db, user_id, cn_today().isoformat()) >= rewards.get_ad_daily_limit(db): + would_status = "daily_capped" + elif unit_count == 0: + would_status = "too_short" + else: + would_status = "granted" + return { + "coin": coin, + "unit_count": unit_count, + "next_unit_coin": next_unit_coin, + "session_full": unit_count >= FEED_MAX_UNITS, + "would_status": would_status, + } def grant_feed_reward( @@ -64,8 +135,8 @@ def grant_feed_reward( user_id: int, *, client_event_id: str, - ecpm: str, - duration_seconds: int, + segments: list[tuple[str, int]], + ecpm_repr: str, ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, @@ -75,102 +146,61 @@ def grant_feed_reward( ) -> AdFeedRewardRecord: """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。 - 发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。 + 发奖规则:**比价全程不关广告才发**,金额按整场逐条 [segments]「跨条进位」折份 + (每 10 秒 1 份,每份用所在段 eCPM,见 [_walk_unit_ecpms])。 - 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 条数上限,把单用户日产出锁进有限区间。 + segments 是逐条广告 `(ecpm, seconds)` 时序列表(旧客户端只传总时长时,API 层包成单段)。 + ecpm_repr 是落库代表 eCPM(报表/审计用,通常取末段)。三道硬闸防刷:份数封顶 [FEED_MAX_UNITS] + 限单场份数,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: return existing today = cn_today().isoformat() - # 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。 - safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) - unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + total_seconds = sum(max(0, s) for _, s in segments) + # 份数封顶在 compute 内做(FEED_MAX_UNITS);记录的 duration 仍钳一道,纯做报表展示防异常大值。 + safe_duration = min(total_seconds, FEED_MAX_DURATION_SECONDS) + coin, unit_count, _next = compute_feed_reward(db, user_id, segments) + + def _record(coin_val: int, status: str) -> AdFeedRewardRecord: + return 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_repr, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=coin_val, + status=status, + ) # 用户中途关闭广告:整场不发(全程不关才发),留一条 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) + return _commit_record(db, _record(0, "closed_early"), client_event_id) if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): - 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="capped", - ) - return _commit_record(db, rec, client_event_id) + return _commit_record(db, _record(0, "capped"), 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) + return _commit_record(db, _record(0, "too_short"), client_event_id) - coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( db, user_id, coin, biz_type="feed_ad_reward", ref_id=client_event_id, remark=f"信息流广告奖励 {unit_count}份", ) - 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=coin, - status="granted", - ) - return _commit_record(db, rec, client_event_id) + return _commit_record(db, _record(coin, "granted"), client_event_id) def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord: diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ae56aab..4e0e45d 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -120,19 +120,40 @@ class TestGrantOut(BaseModel): ) +class FeedSegmentIn(BaseModel): + """信息流轮播中的**一条广告段**:自带 eCPM + 观看秒数。 + + 新客户端按条上报(每条 eCPM 不同),服务端按时序「跨条进位」每满 10 秒结算一份、每份用所在段 + eCPM 计价(见 ad_feed_reward._walk_unit_ecpms)。 + """ + + ecpm: str = Field(..., description="该条广告 eCPM(分/千次展示,SDK getEcpm 原值,非元)") + seconds: int = Field(..., ge=0, description="该条广告的观看秒数") + + class FeedRewardIn(BaseModel): """比价/领券一整场信息流(轮播多条)结束后结算奖励。 - 规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于 - 客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 + 规则:全程不关广告才发,金额按整场折份(每 10 秒 1 份)。client_event_id 用于客户端超时重试 + 幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 + + 计价口径:优先用 [segments] 逐条精确结算(每条自带 eCPM);未传 segments 时回退用 + ecpm + duration_seconds 当**单段**(旧客户端兼容)。两者都没有则 422。 """ 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="整场累计观看秒数(轮播各条相加)") + segments: list[FeedSegmentIn] | None = Field( + None, description="逐条广告段(每条自带 eCPM + 观看秒数,时序)。新客户端传它做按条精确结算" + ) + ecpm: str | None = Field( + None, description="[旧客户端/回退] 本场信息流代表 eCPM(分/千次);传了 segments 时仅作落库代表值" + ) + duration_seconds: int = Field( + 0, ge=0, description="[旧客户端/回退] 整场累计观看秒数;传了 segments 时忽略" + ) adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") app_env: str | None = Field( @@ -154,6 +175,30 @@ class FeedRewardOut(BaseModel): daily_limit: int = Field(..., description="每日信息流展示次数上限") +class FeedPreviewIn(BaseModel): + """信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账)。 + + 给比价/领券金币小球实时显示**真实即将到账金额**用。segments 含在播那条的部分秒数。 + """ + + segments: list[FeedSegmentIn] = Field( + ..., min_length=1, description="截至此刻的逐条广告段(时序;含在播那条的部分秒数)" + ) + + +class FeedPreviewOut(BaseModel): + coin: int = Field(..., description="此刻结束将发放的累计金币(已满 10 秒的各份之和)") + unit_count: int = Field(..., description="已累计满的份数(封顶 12)") + next_unit_coin: int = Field( + ..., description="下一份(第 unit_count+1 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。" + "供小球在本份未满区间内做平滑插值显示" + ) + session_full: bool = Field(..., description="是否已达单场份数上限(12),小球应停涨") + would_status: str = Field( + ..., description="若此刻结束的结算预判:granted / too_short / daily_capped" + ) + + class RewardNoShowIn(BaseModel): """激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。 diff --git a/docs/api/README.md b/docs/api/README.md index 25e8ec6..69434f3 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) | +| 35a' | `POST /api/v1/ad/feed-reward/preview` | Bearer | [详情](./ad-feed-reward-preview.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) | diff --git a/docs/api/ad-feed-reward-preview.md b/docs/api/ad-feed-reward-preview.md new file mode 100644 index 0000000..c41eb92 --- /dev/null +++ b/docs/api/ad-feed-reward-preview.md @@ -0,0 +1,35 @@ +# POST /api/v1/ad/feed-reward/preview — 信息流播放中预演将发放的金币(只读) + +比价 / 领券信息流广告**播放过程中**,查询「若此刻结束、整场将发放多少金币」。给金币小球实时显示**真实即将到账金额**用:与 [feed-reward](./ad-feed-reward.md) **同一计价口径**(同份数封顶 / 同 LT / 同 eCPM 钳),但**只读、不入账、不写库**。 + +客户端每满 10 秒观看(或换条 eCPM 变化)拉一次;两次之间用 `next_unit_coin × 本份进度` 在本地做平滑插值显示。 + +## 鉴权 + +需要 Bearer token。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `segments` | array | 是 | 截至此刻的**逐条广告段**(时序,至少 1 项;含在播那条的部分秒数),每项 `{ecpm: string, seconds: int}` | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `coin` | int | 此刻结束将发放的累计金币(已满 10 秒的各份之和) | +| `unit_count` | int | 已累计满的份数(封顶 12) | +| `next_unit_coin` | int | 下一份(第 `unit_count+1` 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。供小球在本份未满区间内平滑插值 | +| `session_full` | bool | 是否已达单场份数上限(12)→ 小球应停涨 | +| `would_status` | string | 若此刻结束的结算预判:`granted` / `too_short`(凑不满一份) / `daily_capped`(已达每日条数上限,整场将发 0) | + +## 与 feed-reward 的关系 + +- 同样按 `segments` 跨条进位每满 10 秒一份、每份用所在段 eCPM 计价(详见 [feed-reward 计算口径](./ad-feed-reward.md#计算口径))。 +- preview 的 `coin` 等于「用相同 segments 调 feed-reward 实发的 `coin`」——小球显示值即整场结束实发额(除非中途 `would_status` 提示 `daily_capped`,那种情况整场实发 0)。 +- preview **不消耗每日上限、不写任何表**,可高频调用(限流 600/分)。 + +## 限流 + +600 次 / 分钟(`ad-feed-preview`)。 diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index 23516a7..4d098d8 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -1,6 +1,8 @@ # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 -点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 +点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 + +播放过程中查询「此刻将发放多少金币」(金币小球实时显示用)见 [feed-reward/preview](./ad-feed-reward-preview.md),与本接口同一计价口径但只读不入账。 ## 鉴权 @@ -8,37 +10,44 @@ ## 请求体 +计价优先用 `segments` 逐条精确结算;未传 `segments` 时回退用 `ecpm` + `duration_seconds` 当**单段**(旧客户端兼容)。两者都不传 → 422。 + | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | -| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) | +| `segments` | array\|null | 否* | **逐条广告段**(时序),每项 `{ecpm: string, seconds: int}`。新客户端传它做按条精确结算 | +| `ecpm` | string\|null | 否* | **[回退]** 本场代表 eCPM(穿山甲 getEcpm 原值,分/千次展示,非元);传了 `segments` 时仅作落库代表值 | +| `duration_seconds` | int | 否 | **[回退]** 整场累计观看秒数;传了 `segments` 时忽略。默认 0 | | `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` | +\* `segments` 与 `ecpm` 至少传其一。 + ## 响应 | 字段 | 类型 | 说明 | |---|---|---| | `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` | -| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) | +| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场<10s 凑不满一份) / `closed_early`(用户中途关闭) | | `coin` | int | 本次发放金币;非 granted 为 0 | -| `unit_count` | int | 按 10 秒折算出的奖励份数 | +| `unit_count` | int | 折算出的奖励份数(封顶 12) | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 | ## 计算口径 -- 奖励份数:`整场总时长 // 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`。 +- **奖励份数(跨条进位)**:按 `segments` 时序遍历,维护一个连续秒数计数器,每满 10 秒结算一份;换条时不足一份的零头进位到下一条。份数封顶 **12 份/场**(防刷硬闸,替代旧的「单事件时长钳 120s」)。 +- **每份归属的 eCPM**:该份「满 10 秒那一刻正在播的那条广告」的 eCPM。 +- **单份奖励**:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(账号累计份序号) × 10000`,四舍五入为整数金币。 +- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`(eCPM 钳顶 ¥500 CPM)。 +- LT 档(按**账号累计已发份数**,不按天重置):第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 +- 旧客户端单段口径 `[(ecpm, duration_seconds)]` 是上述的特例,份数 = `min(duration//10, 12)`,结果与改造前一致。 ## 数据写入 -- `ad_feed_reward_record` 新增一行。 +- `ad_feed_reward_record` 新增一行(`ecpm_raw` 存代表 eCPM,通常为末段)。 - 入账时 `coin_account` 增加余额。 - 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`。 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index 1943317..f1b28bc 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -315,6 +315,127 @@ def test_feed_reward_daily_display_cap(client, monkeypatch) -> None: assert _coin_balance(client, token) == before +def test_feed_reward_segments_per_ad_ecpm(client) -> None: + """按条 segments 结算:每份用「满 10 秒那刻在播段」的 eCPM,跨条进位不浪费零头。""" + phone = "13800003303" + token = _login(client, phone) + + # ad1: eCPM 200 看 25s;ad2: eCPM 400 看 7s。 + # 跨条进位:25s→2 份(@200,进位 5s);5+7=12s→第 3 份(@400,进位 2s 丢弃)。 + payload = { + "client_event_id": "feed_seg_0001", + "ad_session_id": "feedsess0001", + "segments": [ + {"ecpm": "200", "seconds": 25}, + {"ecpm": "400", "seconds": 7}, + ], + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + expected = ( + calculate_ad_reward_coin("200", 1) + + calculate_ad_reward_coin("200", 2) + + calculate_ad_reward_coin("400", 3) + ) + assert body["status"] == "granted" + assert body["unit_count"] == 3 + assert body["coin"] == expected + assert _coin_balance(client, token) == expected + + +def test_feed_reward_session_unit_cap(client) -> None: + """单场份数封顶 12:超长 segments 也只发 12 份。""" + phone = "13800003304" + token = _login(client, phone) + payload = { + "client_event_id": "feed_cap_unit_1", + "segments": [{"ecpm": "200", "seconds": 1000}], # 100 份 → 钳到 12 + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["unit_count"] == 12 + expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 13)) + assert r.json()["coin"] == expected + + +def test_feed_reward_aborted_no_grant(client) -> None: + """中途关闭(aborted)即使看够时长也整场不发,记 closed_early。""" + phone = "13800003305" + token = _login(client, phone) + payload = { + "client_event_id": "feed_abort_1", + "segments": [{"ecpm": "200", "seconds": 30}], + "aborted": True, + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["granted"] is False + assert r.json()["status"] == "closed_early" + assert r.json()["coin"] == 0 + assert _coin_balance(client, token) == 0 + + +def test_feed_reward_requires_segments_or_ecpm(client) -> None: + """既不传 segments 也不传 ecpm → 422。""" + phone = "13800003306" + token = _login(client, phone) + r = client.post( + "/api/v1/ad/feed-reward", + json={"client_event_id": "feed_bad_1", "duration_seconds": 30}, + headers=_auth(token), + ) + assert r.status_code == 422, r.text + + +def test_feed_preview_matches_grant(client) -> None: + """preview 与最终 feed-reward 同口径:preview 的 coin == 同 segments 实发 coin。""" + phone = "13800003307" + token = _login(client, phone) + segments = [{"ecpm": "200", "seconds": 25}, {"ecpm": "400", "seconds": 7}] + + pv = client.post( + "/api/v1/ad/feed-reward/preview", json={"segments": segments}, headers=_auth(token) + ) + assert pv.status_code == 200, pv.text + pvb = pv.json() + assert pvb["unit_count"] == 3 + assert pvb["would_status"] == "granted" + assert pvb["session_full"] is False + # 下一份(第 4 份)边际金币,按末段 eCPM=400 算 + assert pvb["next_unit_coin"] == calculate_ad_reward_coin("400", 4) + + # preview 不入账:此时余额仍为 0 + assert _coin_balance(client, token) == 0 + + # 真正结算,coin 应与 preview 一致 + gr = client.post( + "/api/v1/ad/feed-reward", + json={"client_event_id": "feed_pv_1", "segments": segments}, + headers=_auth(token), + ) + assert gr.status_code == 200, gr.text + assert gr.json()["coin"] == pvb["coin"] + assert _coin_balance(client, token) == pvb["coin"] + + +def test_feed_preview_too_short(client) -> None: + """不足 10 秒:preview would_status=too_short、coin=0、next_unit_coin>0(本份在攒)。""" + phone = "13800003308" + token = _login(client, phone) + pv = client.post( + "/api/v1/ad/feed-reward/preview", + json={"segments": [{"ecpm": "200", "seconds": 6}]}, + headers=_auth(token), + ) + assert pv.status_code == 200, pv.text + body = pv.json() + assert body["unit_count"] == 0 + assert body["coin"] == 0 + assert body["would_status"] == "too_short" + assert body["next_unit_coin"] == calculate_ad_reward_coin("200", 1) + + def test_callback_disabled_returns_503(client, monkeypatch) -> None: """未配置回调(开关关)时 → 503。""" monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)