From 622d68aa974d99cc9395bbe3341b61861d68c3ad Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Tue, 9 Jun 2026 21:53:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(ad):=20eCPM=20=E5=8D=95=E4=BD=8D=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=BA=E5=88=86=20+=20=E6=96=B0=E5=A2=9E=E7=9C=8B?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E9=87=91=E5=B8=81=E5=AE=A1=E8=AE=A1=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eCPM 口径修正:getEcpm 原值是分/千次,新增 parse_ecpm_fen;parse_ecpm_yuan 改为 ÷100 转元,因子1 阈值按元判档(100/200/400),收益换算用元 - test-grant 用客户端按 ad_session_id 上报的真实 eCPM 发奖(取不到/≤0 兜底 200) - 新增 GET /admin/api/ad-coin-audit 只读复算对账(router/repo/schema),复用发奖公式 - 同步更新 docs/api、docs/database 的分/元口径 Co-Authored-By: Claude Opus 4.8 --- app/admin/main.py | 2 + app/admin/repositories/ad_audit.py | 168 +++++++++++++++++++++++++++++ app/admin/routers/ad_audit.py | 44 ++++++++ app/admin/schemas/ad_audit.py | 57 ++++++++++ app/api/v1/ad.py | 12 ++- app/core/rewards.py | 22 +++- app/models/ad_ecpm.py | 2 +- app/schemas/ad.py | 11 +- docs/api/README.md | 1 + docs/api/ad-ecpm-report.md | 2 +- docs/api/ad-feed-reward.md | 2 +- docs/api/ad-test-grant.md | 3 + docs/api/admin-ad-coin-audit.md | 69 ++++++++++++ docs/database/ad_ecpm_record.md | 2 +- 14 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 app/admin/repositories/ad_audit.py create mode 100644 app/admin/routers/ad_audit.py create mode 100644 app/admin/schemas/ad_audit.py create mode 100644 docs/api/admin-ad-coin-audit.md diff --git a/app/admin/main.py b/app/admin/main.py index 74a06bb..7bf3d13 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -13,6 +13,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.admin.routers.ad_audit import router as ad_audit_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router @@ -84,3 +85,4 @@ admin_app.include_router(feedback_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) +admin_app.include_router(ad_audit_router) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py new file mode 100644 index 0000000..c882b12 --- /dev/null +++ b/app/admin/repositories/ad_audit.py @@ -0,0 +1,168 @@ +"""看广告金币审计:复算 expected_coin 并与实发对比。 + +只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): +- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号 + (与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。 +- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计 + (与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。 + +非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 +""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core import rewards +from app.models.ad_feed_reward import AdFeedRewardRecord +from app.models.ad_reward import AdRewardRecord +from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS + + +def _reward_video_rows( + db: Session, *, date: str, user_id: int | None +) -> list[dict]: + """看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。""" + stmt = ( + select(AdRewardRecord) + .where( + AdRewardRecord.reward_date == date, + AdRewardRecord.reward_scene == "reward_video", + ) + .order_by(AdRewardRecord.user_id, AdRewardRecord.created_at) + ) + if user_id is not None: + stmt = stmt.where(AdRewardRecord.user_id == user_id) + + granted_n: dict[int, int] = {} # user_id -> 已 granted 份数 + rows: list[dict] = [] + for rec in db.execute(stmt).scalars(): + if rec.status == "granted": + nth = granted_n.get(rec.user_id, 0) + 1 + granted_n[rec.user_id] = nth + expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth) + rows.append({ + "scene": "reward_video", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)), + "units": 1, + "lt_index_start": nth, + "lt_index_end": nth, + "lt_factor_start": rewards.ad_lt_factor(nth), + "lt_factor_end": rewards.ad_lt_factor(nth), + "expected_coin": expected, + "actual_coin": rec.coin, + "matched": expected == rec.coin, + }) + else: + # capped / ecpm_missing:不发金币,校验实发确为 0 + rows.append({ + "scene": "reward_video", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": None, + "units": 1, + "lt_index_start": None, + "lt_index_end": None, + "lt_factor_start": None, + "lt_factor_end": None, + "expected_coin": 0, + "actual_coin": rec.coin, + "matched": rec.coin == 0, + }) + return rows + + +def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: + """信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。""" + stmt = ( + select(AdFeedRewardRecord) + .where(AdFeedRewardRecord.reward_date == date) + .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at) + ) + if user_id is not None: + stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) + + granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计 + rows: list[dict] = [] + for rec in db.execute(stmt).scalars(): + if rec.status == "granted": + existing = granted_units.get(rec.user_id, 0) + units = rec.unit_count + expected = sum( + rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset) + for offset in range(1, units + 1) + ) + granted_units[rec.user_id] = existing + units + start = existing + 1 if units > 0 else None + end = existing + units if units > 0 else None + rows.append({ + "scene": "feed", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)), + "units": units, + "lt_index_start": start, + "lt_index_end": end, + "lt_factor_start": rewards.ad_lt_factor(start) if start else None, + "lt_factor_end": rewards.ad_lt_factor(end) if end else None, + "expected_coin": expected, + "actual_coin": rec.coin, + "matched": expected == rec.coin, + }) + else: + rows.append({ + "scene": "feed", + "record_id": rec.id, + "user_id": rec.user_id, + "created_at": rec.created_at, + "status": rec.status, + "ecpm": rec.ecpm_raw, + "ecpm_factor": None, + "units": rec.unit_count, + "lt_index_start": None, + "lt_index_end": None, + "lt_factor_start": None, + "lt_factor_end": None, + "expected_coin": 0, + "actual_coin": rec.coin, + "matched": rec.coin == 0, + }) + return rows + + +def ad_coin_audit( + db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int +) -> list[dict]: + """返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。 + + scene: None=两类都要 / "reward_video" / "feed"。 + 份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。 + """ + rows: list[dict] = [] + if scene in (None, "reward_video"): + rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) + if scene in (None, "feed"): + rows.extend(_feed_rows(db, date=date, user_id=user_id)) + rows.sort(key=lambda r: r["created_at"], reverse=True) + return rows[:limit] + + +def formula_snapshot() -> dict: + """当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。""" + return { + "coin_per_yuan": rewards.COIN_PER_YUAN, + "feed_unit_seconds": FEED_REWARD_UNIT_SECONDS, + "ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE], + "lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE], + } diff --git a/app/admin/routers/ad_audit.py b/app/admin/routers/ad_audit.py new file mode 100644 index 0000000..fd4b057 --- /dev/null +++ b/app/admin/routers/ad_audit.py @@ -0,0 +1,44 @@ +"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。 + +任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import ad_audit +from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut +from app.core.rewards import cn_today + +router = APIRouter( + prefix="/admin/api/ad-coin-audit", + tags=["admin-ad-coin-audit"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)") +def get_ad_coin_audit( + db: AdminDb, + date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None, + user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None, + scene: Annotated[ + str | None, Query(description="reward_video / feed;不传=两类都要") + ] = None, + limit: Annotated[int, Query(ge=1, le=500)] = 100, +) -> AdCoinAuditOut: + audit_date = date or cn_today().isoformat() + rows = ad_audit.ad_coin_audit( + db, date=audit_date, user_id=user_id, scene=scene, limit=limit, + ) + items = [AdCoinAuditRow(**r) for r in rows] + return AdCoinAuditOut( + date=audit_date, + formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()), + total=len(items), + mismatch_count=sum(1 for it in items if not it.matched), + items=items, + ) diff --git a/app/admin/schemas/ad_audit.py b/app/admin/schemas/ad_audit.py new file mode 100644 index 0000000..cd5f949 --- /dev/null +++ b/app/admin/schemas/ad_audit.py @@ -0,0 +1,57 @@ +"""看广告金币审计 schemas。 + +只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录, +用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin, +和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AdCoinAuditRow(BaseModel): + """单条发奖记录的复算明细。""" + + scene: str = Field(..., description="reward_video(看视频) / feed(信息流)") + record_id: int = Field(..., description="对应记录表主键") + user_id: int + created_at: datetime + status: str = Field(..., description="granted / capped / ecpm_missing") + ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)") + ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)") + units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数") + lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)") + lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起") + lt_factor_start: float | None = Field(None, description="因子2(LT)起值") + lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值") + expected_coin: int = Field(..., description="按公式复算应发金币") + actual_coin: int = Field(..., description="实际入账金币") + matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)") + + +class AdCoinFormulaOut(BaseModel): + """当前金币公式参数(给前端展示规则参照)。""" + + description: str = Field( + "eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);" + "因子1 按 eCPM元 判档(阈值 100/200/400 元)", + description="公式说明", + ) + coin_per_yuan: int = Field(..., description="金币:元 汇率") + ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径") + feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份") + # [(因子值, 区间下限, 区间上限或 null)] + ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表") + lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表") + + +class AdCoinAuditOut(BaseModel): + """审计响应:公式参照 + 命中条数 + 明细。""" + + date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)") + formula: AdCoinFormulaOut + total: int = Field(..., description="返回的明细条数") + mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)") + items: list[AdCoinAuditRow] diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 3c21819..0c7e351 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -304,10 +304,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N db.commit() db.refresh(rec) else: + # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); + # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 + ad_session_id = payload.ad_session_id if payload is not None else None + ecpm_val = "200" + if ad_session_id: + ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) + if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: + ecpm_val = ecpm_rec.ecpm_raw try: rec = crud_ad.grant_ad_reward( - db, user.id, trans_id, ecpm="200", reward_name="测试发奖", - raw="client debug test-grant ecpm=200", + db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, + reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", ) except crud_ad.UnknownUserError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e diff --git a/app/core/rewards.py b/app/core/rewards.py index 38a7059..949dee1 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -87,7 +87,11 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72 # ===== 看激励视频 / 信息流广告发金币 ===== -# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。 +# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元! +# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元; +# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。 +# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。 +# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。 AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( (0.1, 0, 100), (0.3, 101, 200), @@ -103,8 +107,8 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( ) -def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: - """解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。""" +def parse_ecpm_fen(ecpm: str | int | float | None) -> float: + """解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。""" if ecpm is None: return 0.0 try: @@ -114,8 +118,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: return max(0.0, value) +def parse_ecpm_yuan(ecpm: str | int | float | None) -> float: + """eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。""" + return parse_ecpm_fen(ecpm) / 100.0 + + def ad_ecpm_factor(ecpm_yuan: float) -> float: - """eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。""" + """eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。 + 产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。""" if ecpm_yuan > 400: return 0.6 if ecpm_yuan > 200: @@ -137,7 +147,9 @@ def ad_lt_factor(today_count_after_this: int) -> float: def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int: """按金币数值体系计算单份广告奖励金币。 - 单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。 + eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。 + 单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT); + 再按 1 元=10000 金币取整。 """ ecpm_yuan = parse_ecpm_yuan(ecpm) yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index 28b06f5..0f46b51 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -35,7 +35,7 @@ class AdEcpmRecord(Base): adn: Mapped[str | None] = mapped_column(String(32), nullable=True) # 实际展示用的代码位(底层 mediation rit,非客户端配置位) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - # 客户端上报的 eCPM 原始字符串(单位:元/千次展示,原样存) + # 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index e64cd9c..a1cb7e0 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel): class EcpmReportIn(BaseModel): """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 - user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按元/千次展示处理。 + user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)。 """ ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等") - ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按元/千次展示处理") + 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 会透传同值", @@ -89,6 +89,11 @@ class TestGrantIn(BaseModel): "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): @@ -119,7 +124,7 @@ class FeedRewardIn(BaseModel): ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) - ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理") + 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="实际展示代码位") diff --git a/docs/api/README.md b/docs/api/README.md index 6556d39..7ff8981 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -102,6 +102,7 @@ | A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | | A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | | A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | +| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/ad-ecpm-report.md b/docs/api/ad-ecpm-report.md index 40782b7..b29fefb 100644 --- a/docs/api/ad-ecpm-report.md +++ b/docs/api/ad-ecpm-report.md @@ -9,7 +9,7 @@ |---|---|---|---| | `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | | `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | -| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 | +| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 | | `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index be46efa..0510a0f 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -12,7 +12,7 @@ |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 | +| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | | `duration_seconds` | int | 是 | 实际展示/播放秒数 | | `adn` | string\|null | 否 | 实际投放 ADN | | `slot_id` | string\|null | 否 | 实际展示代码位 | diff --git a/docs/api/ad-test-grant.md b/docs/api/ad-test-grant.md index 9c439cb..c4a0638 100644 --- a/docs/api/ad-test-grant.md +++ b/docs/api/ad-test-grant.md @@ -10,6 +10,7 @@ | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| | `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 | +| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 | ## 出参 响应 `200`:`TestGrantOut` @@ -30,4 +31,6 @@ ## 说明 没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。 +`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。 + `reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 diff --git a/docs/api/admin-ad-coin-audit.md b/docs/api/admin-ad-coin-audit.md new file mode 100644 index 0000000..8b69e71 --- /dev/null +++ b/docs/api/admin-ad-coin-audit.md @@ -0,0 +1,69 @@ +# Admin 看广告金币审计 + +> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md) + +把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。 + +相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。 + +## 金币公式(展示参照) + +``` +eCPM元 = getEcpm分 ÷ 100 +单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000 +``` +- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。 +- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101–200→0.3,201–400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。 +- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第4–10→1.1,≥11→1.0。 +- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。 + +## GET /admin/api/ad-coin-audit — 复算对比 + +- 入参(均 query,可选): + | 参数 | 类型 | 默认 | 说明 | + |---|---|---|---| + | `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 | + | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | + | `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 | + | `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + +- 出参 `200`:`AdCoinAuditOut` + | 字段 | 类型 | 说明 | + |---|---|---| + | `date` | string | 审计日期 | + | `formula` | object | 当前公式参数快照(见下) | + | `total` | int | 返回明细条数 | + | `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** | + | `items` | `AdCoinAuditRow[]` | 明细(见下) | + +### AdCoinFormulaOut(`formula`) +| 字段 | 类型 | 说明 | +|---|---|---| +| `description` | string | 公式文字说明 | +| `coin_per_yuan` | int | 金币:元 汇率(10000) | +| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) | +| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) | +| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) | +| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 | + +### AdCoinAuditRow(`items[]`) +| 字段 | 类型 | 说明 | +|---|---|---| +| `scene` | string | `reward_video` / `feed` | +| `record_id` | int | 对应记录表主键 | +| `user_id` | int | | +| `created_at` | datetime | | +| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) | +| `ecpm` | string \| null | 本次采用的 eCPM 原始值 | +| `ecpm_factor` | float \| null | 因子1;非 granted 为 null | +| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 | +| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) | +| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) | +| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) | +| `actual_coin` | int | 实际入账金币 | +| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 | + +## 说明 +- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。 +- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。 +- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。 diff --git a/docs/database/ad_ecpm_record.md b/docs/database/ad_ecpm_record.md index 687f5fc..127636f 100644 --- a/docs/database/ad_ecpm_record.md +++ b/docs/database/ad_ecpm_record.md @@ -18,7 +18,7 @@ | `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 | | `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | | `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | -| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**;后端按“元/千次展示”参与金币公式 | +| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | -- 2.52.0