Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b01b251f83 | |||
| 2472cfc44a | |||
| c958546e07 | |||
| a4c0fa0de6 | |||
| a173e07c5f | |||
| d3e3516ce9 | |||
| 599432cd05 |
@@ -28,34 +28,48 @@ def upgrade() -> None:
|
||||
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
|
||||
|
||||
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
|
||||
op.add_column(
|
||||
"cps_group",
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[\"meituan\"]'::jsonb"),
|
||||
),
|
||||
# server_default 按方言区分:Postgres 用 ::jsonb 转换;SQLite(本地)用纯 JSON 文本字面量
|
||||
#(SQLite 不认 ::jsonb,会报 unrecognized token: ":")。
|
||||
bind = op.get_bind()
|
||||
platforms_default = (
|
||||
sa.text("'[\"meituan\"]'::jsonb")
|
||||
if bind.dialect.name == "postgresql"
|
||||
else sa.text("'[\"meituan\"]'")
|
||||
)
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
|
||||
# alter_column 在 SQLite 原生不支持 ALTER COLUMN,必须走 batch(重建表);Postgres 下 batch 直接 ALTER。
|
||||
# 用 batch_alter_table 跨方言通吃(render_as_batch 只管 autogenerate 渲染,不会自动包裹手写的 alter_column)。
|
||||
with op.batch_alter_table("cps_group") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=platforms_default,
|
||||
)
|
||||
)
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# link:sid 可空 + target 加长
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
|
||||
with op.batch_alter_table("cps_link") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
|
||||
|
||||
# click:sid 可空 + 事件类型
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.add_column(
|
||||
"cps_click",
|
||||
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
|
||||
)
|
||||
with op.batch_alter_table("cps_click") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
batch_op.add_column(
|
||||
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cps_click", "event_type")
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_group", "platforms")
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_click") as batch_op:
|
||||
batch_op.drop_column("event_type")
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_link") as batch_op:
|
||||
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_group") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
batch_op.drop_column("platforms")
|
||||
op.drop_column("cps_activity", "payload")
|
||||
|
||||
+42
-4
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+49
-4
@@ -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):
|
||||
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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`)。
|
||||
+19
-10
@@ -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`。
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user