Compare commits

...

7 Commits

Author SHA1 Message Date
OuYingJun1024 b01b251f83 Merge remote-tracking branch 'origin/main' into feat/feed-ad-realcoin-orb 2026-06-17 22:39:58 +08:00
OuYingJun1024 2472cfc44a chore(alembic): cps_v2_platforms 跨方言(SQLite batch)兼容修复
随本分支一并提交的工作区既有改动(非本次信息流发奖功能):
batch_alter_table + 按方言区分 server_default,让迁移在 SQLite 本地也能跑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:28:05 +08:00
OuYingJun1024 c958546e07 feat(ad): 信息流广告按条 segments 精确发奖 + 只读 preview 接口
- 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>
2026-06-17 22:27:22 +08:00
OuYingJun1024 a4c0fa0de6 Merge branch 'feat/ops-config-ad-flags-reconcile' 2026-06-17 21:34:32 +08:00
OuYingJun1024 a173e07c5f Merge remote-tracking branch 'origin/main' into feat/ops-config-ad-flags-reconcile 2026-06-17 19:44:24 +08:00
OuYingJun1024 d3e3516ce9 docs(ad): 对齐 S2S 多 m-key API + 记录穿山甲打线上超时风险
- ad-pangle-callback / integrations/pangle: 文档改为 verify_callback_sign_any
  + 命名变量 PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD(多激励位共用同一回调 URL)
- 看广告赚金币上线清单: 记录 2026-06-16 实测穿山甲打线上 errorCode=50002 超时
  (头号上线阻塞,m-key 配对也救不了)+ 生产 .env 命名变量配置 + AD_REWARD_TEST_GRANT 红线

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:59:55 +08:00
OuYingJun1024 599432cd05 feat(admin/ad): 提现自动对账后台开关 + 客户端广告 flags 端点 + 穿山甲多 m-key 验签
- 自动对账接入 app_config 运行时配置(新增 bool 配置类型):env 部署总闸 + DB 运营日常开关
  双层;worker 每轮读 DB 即时生效,健康检查改报「env AND DB」实际生效态
- 新增 GET /api/v1/platform/flags(不鉴权)下发 comparing_ad_enabled 远程 kill-switch,
  客户端拉取后缓存;空库回退默认 True
- 穿山甲发奖回调支持多激励位 m-key(三命名项 PANGLE_REWARD_SECRET_TEST/_DEDICATED/_PROD
  + 旧逗号分隔合并去重),verify_callback_sign_any 逐个验签任一通过即受理;向后兼容旧单 key
- 补 test_platform / bool config 用例;app_config 文档同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:28:10 +08:00
8 changed files with 422 additions and 129 deletions
+31 -17
View File
@@ -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",
# 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\"]'")
)
# 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=sa.text("'[\"meituan\"]'::jsonb"),
),
server_default=platforms_default,
)
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
)
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
View File
@@ -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,
+116 -86
View File
@@ -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(
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()
total = 0
for offset in range(1, unit_count + 1):
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
return total
)
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
)
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
View File
@@ -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):
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
+1
View File
@@ -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) |
+35
View File
@@ -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
View File
@@ -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`
+121
View File
@@ -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)