diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 6d39879..8aef9c6 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -81,6 +81,10 @@ def _date_range(date_from: str, date_to: str) -> list[str]: # ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。 _AUDIT_SCENES = {"reward_video", "feed", "draw"} +# 激励视频未满足有效播放条件时不计客户端预估收益。客户端仍会在 onAdShow +# 上报 eCPM,随后才在关闭时补报以下终态,因此必须在展示/发奖合并后修正收益。 +_ZERO_REVENUE_REWARD_VIDEO_STATUSES = frozenset({"closed_early", "too_short"}) + # 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。 _REWARD_DETAIL_KEYS = ( @@ -202,6 +206,11 @@ def ad_revenue_report( "matched": bool(rwd["matched"]), "reward_detail": _reward_detail(rwd), }) + if ( + rec.ad_type == "reward_video" + and rwd["status"] in _ZERO_REVENUE_REWARD_VIDEO_STATUSES + ): + ev["revenue_yuan"] = 0.0 else: # 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。 ev.update({ diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index 6f9f49a..8143eff 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -26,7 +26,10 @@ class AdRevenueRecord(BaseModel): record_id: int created_at: datetime - status: str = Field(..., description="granted / capped / ecpm_missing") + status: str = Field( + ..., + description="granted / capped / ecpm_missing / closed_early / too_short", + ) ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)") ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空") units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数") @@ -44,7 +47,7 @@ class AdRevenueDaily(BaseModel): date: str = Field(..., description="北京时间 YYYY-MM-DD") impressions: int = Field(..., description="当天展示条数合计") - revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)") + revenue_yuan: float = Field(..., description="当天客户端有效预估收益合计(元;eCPM 折算)") pangle_revenue_yuan: float | None = Field( None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空" ) @@ -93,7 +96,10 @@ class AdRevenueRow(BaseModel): has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)") impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)") ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值") - revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0") + revenue_yuan: float = Field( + ..., + description="本次有效展示预估收益(元)= eCPM元 ÷ 1000;纯发奖、激励视频提前关闭/时长不足=0", + ) row_revenue_yuan: float | None = Field( None, description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;" @@ -150,7 +156,7 @@ class AdRevenueReportOut(BaseModel): total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)") truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)") total_impressions: int = Field(..., description="全量展示条数合计") - total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)") + total_revenue_yuan: float = Field(..., description="全量客户端有效预估收益合计(元;eCPM 折算)") total_pangle_revenue_yuan: float | None = Field( None, description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度," diff --git a/tests/test_admin_ad_revenue_scope.py b/tests/test_admin_ad_revenue_scope.py index b01c92a..f495d66 100644 --- a/tests/test_admin_ad_revenue_scope.py +++ b/tests/test_admin_ad_revenue_scope.py @@ -9,9 +9,11 @@ from app.admin.repositories import ad_revenue from app.db.session import SessionLocal from app.models.ad_ecpm import AdEcpmRecord from app.models.ad_pangle_revenue import AdPangleDailyRevenue +from app.models.ad_reward import AdRewardRecord from app.models.user import User REPORT_DATE = "2040-02-03" +PLAYBACK_DATE = "2040-02-04" def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) -> None: @@ -127,3 +129,82 @@ def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) - db.execute(delete(User).where(User.phone == "18800009991")) db.commit() db.close() + + +def test_reward_video_incomplete_playback_has_zero_revenue() -> None: + db = SessionLocal() + phone = "18800009992" + sessions = { + "closed_early": "rv-zero-closed", + "too_short": "rv-zero-short", + "capped": "rv-keep-capped", + "granted": "rv-keep-granted", + } + try: + user = User(phone=phone, username="29999999992", register_channel="sms") + db.add(user) + db.flush() + + for index, (status, session_id) in enumerate(sessions.items(), start=1): + created_at = datetime(2040, 2, 4, index, tzinfo=UTC) + db.add(AdEcpmRecord( + user_id=user.id, + ad_type="reward_video", + ad_session_id=session_id, + app_env="prod", + our_code_id="prod-reward", + ecpm_raw="10000", + report_date=PLAYBACK_DATE, + created_at=created_at, + )) + db.add(AdRewardRecord( + trans_id=f"{session_id}-trans", + user_id=user.id, + coin=0, + status=status, + reward_scene="reward_video", + ad_session_id=session_id, + app_env="prod", + our_code_id="prod-reward", + ecpm_raw="10000", + reward_date=PLAYBACK_DATE, + created_at=created_at, + )) + db.commit() + + result = ad_revenue.ad_revenue_report( + db, + date_from=PLAYBACK_DATE, + date_to=PLAYBACK_DATE, + user_id=user.id, + ad_type="reward_video", + app_env="prod", + revenue_scope="all", + granularity="hour", + ) + + revenue_by_status = {row["status"]: row["revenue_yuan"] for row in result["items"]} + assert revenue_by_status == { + "closed_early": 0.0, + "too_short": 0.0, + "capped": 0.1, + "granted": 0.1, + } + assert result["total_impressions"] == 4 + assert result["total_revenue_yuan"] == 0.2 + assert len(result["daily"]) == 1 + assert result["daily"][0]["date"] == PLAYBACK_DATE + assert result["daily"][0]["impressions"] == 4 + assert result["daily"][0]["revenue_yuan"] == 0.2 + assert sum(row["revenue_yuan"] for row in result["hourly"]) == 0.2 + assert result["type_stats"]["reward_video"] == { + "impressions": 4, + "revenue_yuan": 0.2, + } + finally: + db.rollback() + db.execute(delete(AdRewardRecord).where(AdRewardRecord.reward_date == PLAYBACK_DATE)) + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.report_date == PLAYBACK_DATE)) + db.execute(delete(User).where(User.phone == phone)) + db.commit() + db.close()