c958546e07
- compute_feed_reward/preview_feed_reward: 逐条 (eCPM,秒) 跨条进位、每满 10s 一份、 每份用所在段 eCPM × LT,封顶 12 份/场;发奖与预览同一口径 - grant_feed_reward 改收 segments(旧 ecpm/duration 单段自动回退,兼容旧客户端) - 新增 POST /api/v1/ad/feed-reward/preview(只读不入账,给比价金币小球实时显示真实即将到账额) - schemas 加 FeedSegmentIn/FeedPreviewIn/FeedPreviewOut;docs/api 补 preview 文档 + 更新 feed-reward - tests: 按条结算/12份封顶/aborted/preview==grant 等 6 个用例 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
11 KiB
Python
228 lines
11 KiB
Python
"""看激励视频发奖相关 schemas。
|
|
|
|
约定同其余模块:字段 snake_case、金额/次数存整数。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class PangleCallbackOut(BaseModel):
|
|
"""回给穿山甲 GroMore 的回调响应。
|
|
|
|
GroMore 服务端激励回调规范(supportcenter/26240)要求响应体为
|
|
`{"is_verify": bool, "reason": int}`:
|
|
- `is_verify=true` + `reason=0`:校验通过、发放奖励(发奖成功 或 当日已达上限均算"已处理")。
|
|
- `is_verify=false`:不发放,`reason` 带错误码透传客户端 SDK(见 ad.py 的 REASON_*)。
|
|
验签失败由 API 层直接返 403,不走这里。"""
|
|
|
|
is_verify: bool = True
|
|
reason: int = 0
|
|
|
|
|
|
class AdRewardStatusOut(BaseModel):
|
|
"""客户端查今日看广告发奖进度(福利页"看视频赚金币"用)。"""
|
|
|
|
used_today: int = Field(..., description="今日已成功发奖次数")
|
|
daily_limit: int = Field(..., description="每日发奖次数上限")
|
|
remaining: int = Field(..., description="今日剩余可领次数")
|
|
coin_per_ad: int = Field(..., description="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
|
|
round_count: int = Field(
|
|
..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却"
|
|
)
|
|
cooldown_until: datetime | None = Field(
|
|
None,
|
|
description="广告关闭后 3 秒短冷却结束时间(UTC ISO),null 表示不在冷却。"
|
|
"冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点",
|
|
)
|
|
# ===== 旧版每日观看总时长字段:当前产品仅保留每日 500 次上限,limit=0 表示未启用时长闸 =====
|
|
watched_seconds_today: int = Field(0, description="今日已观看激励视频总秒数(前端累计上报)")
|
|
watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒);0 表示当前未启用")
|
|
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截")
|
|
|
|
|
|
class EcpmReportIn(BaseModel):
|
|
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
|
|
|
|
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)。
|
|
"""
|
|
|
|
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
|
|
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
|
ad_session_id: str | None = Field(
|
|
None, min_length=8, max_length=64,
|
|
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
|
|
)
|
|
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
|
|
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(AdConfig 的 104xxx,非底层 rit)"
|
|
)
|
|
|
|
|
|
class EcpmReportOut(BaseModel):
|
|
"""eCPM 上报结果。best-effort,落库即 ok。"""
|
|
|
|
ok: bool = True
|
|
|
|
|
|
class WatchReportIn(BaseModel):
|
|
"""客户端上报一次激励视频的实际观看时长(onAdClose 时,onAdShow→onAdClose 墙钟秒数)。
|
|
|
|
user_id 由 JWT 取(Bearer),不在 body。seconds 服务端会夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
|
"""
|
|
|
|
seconds: int = Field(..., ge=0, description="本次观看秒数")
|
|
|
|
|
|
class WatchReportOut(BaseModel):
|
|
"""上报后返回当日累计 + 剩余,客户端即时更新"今日还能看多久"。"""
|
|
|
|
watched_seconds_today: int = Field(..., description="今日累计观看秒数")
|
|
watch_seconds_limit: int = Field(..., description="每日上限(秒)")
|
|
watch_seconds_remaining: int = Field(..., description="今日剩余可观看秒数")
|
|
|
|
|
|
class TestGrantIn(BaseModel):
|
|
"""[仅本地联调]模拟发奖入参。"""
|
|
|
|
reward_scene: str = Field(
|
|
"reward_video",
|
|
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
|
|
)
|
|
ad_session_id: str | None = Field(
|
|
None, min_length=8, max_length=64,
|
|
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
|
|
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
|
|
)
|
|
|
|
|
|
class TestGrantOut(BaseModel):
|
|
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
|
|
|
|
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
|
|
status: str = Field(
|
|
..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene"
|
|
)
|
|
coin: int = Field(..., description="本次发放金币(capped 时为 0)")
|
|
used_today: int = Field(..., description="今日已成功发奖次数")
|
|
daily_limit: int = Field(..., description="每日发奖次数上限")
|
|
remaining: int = Field(..., description="今日剩余可领次数")
|
|
coin_per_ad: int = Field(..., description="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
|
|
round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut")
|
|
cooldown_until: datetime | None = Field(
|
|
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
|
|
)
|
|
|
|
|
|
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)。
|
|
|
|
计价口径:优先用 [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"
|
|
)
|
|
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(
|
|
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
|
)
|
|
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 / too_short / closed_early")
|
|
coin: int = Field(..., description="本次发放金币")
|
|
unit_count: int = Field(..., description="按 10 秒折算出的奖励份数")
|
|
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):
|
|
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
|
|
|
|
供广告收益报表把「有展示、没发金币」的原因也记录下来。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(该次其实已发奖,跳过未写)"
|
|
)
|