From beadce31ed3c2b3a7e3d076a2d90b490a5915622 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Tue, 21 Jul 2026 13:53:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(ad):=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E4=B8=8D=E8=B6=B3=E4=B8=80=E7=A7=92=E6=9B=9D=E5=85=89?= =?UTF-8?q?=E6=94=B6=E7=9B=8A=E4=B8=BA=E9=9B=B6=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 - eCPM 上报新增可选 exposure_ms,兼容旧客户端 - exposure_ms < 1000 时保留展示记录并强制有效 eCPM 为 0 - 失败领券任务允许保留短曝光零收益 trace,后台显示 0 而不是未填充 - 其他失败后的迟到曝光仍按原规则解绑 trace ## 验证 - 相关 pytest:10 passed - Ruff:通过 - compileall:通过 依赖:先合并 Server #149。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/150 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/api/v1/ad.py | 10 ++++++--- app/repositories/ad_ecpm.py | 25 +++++++++++++++++++--- app/schemas/ad.py | 6 ++++++ tests/test_ad_ecpm_trace_revenue.py | 32 +++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 5093d87..cff56ee 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -281,7 +281,10 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm 丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。 """ attributed_trace_id = crud_ecpm.attributable_trace_id( - db, feed_scene=payload.feed_scene, trace_id=payload.trace_id + db, + feed_scene=payload.feed_scene, + trace_id=payload.trace_id, + exposure_ms=payload.exposure_ms, ) if payload.trace_id and attributed_trace_id is None: logger.info( @@ -296,11 +299,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm feed_scene=payload.feed_scene, trace_id=attributed_trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, + exposure_ms=payload.exposure_ms, ) logger.info( - "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", + "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s", user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm, - payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, + payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 65bc604..9013cae 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -15,9 +15,22 @@ from app.core.rewards import cn_today from app.models.ad_ecpm import AdEcpmRecord from app.models.coupon_state import CouponSession +MIN_REVENUE_EXPOSURE_MS = 1000 + + +def effective_ecpm_raw(ecpm_raw: str, exposure_ms: int | None) -> str: + """曝光不足一秒时保留展示记录,但把该条有效 eCPM 归零。""" + if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS: + return "0" + return ecpm_raw + def attributable_trace_id( - db: Session, *, feed_scene: str | None, trace_id: str | None + db: Session, + *, + feed_scene: str | None, + trace_id: str | None, + exposure_ms: int | None = None, ) -> str | None: """返回广告展示允许归属的业务 trace。 @@ -31,7 +44,12 @@ def attributable_trace_id( session_status = db.execute( select(CouponSession.status).where(CouponSession.trace_id == trace_id) ).scalar_one_or_none() - return None if session_status in {"failed", "abandoned"} else trace_id + if session_status not in {"failed", "abandoned"}: + return trace_id + # 已真实上墙但不足一秒的曝光要在终态明细中明确显示 0,而不是被误判成“未填充”。 + if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS: + return trace_id + return None def create_ecpm_record( @@ -47,6 +65,7 @@ def create_ecpm_record( trace_id: str | None = None, app_env: str | None = None, our_code_id: str | None = None, + exposure_ms: int | None = None, ) -> AdEcpmRecord: """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。 @@ -67,7 +86,7 @@ def create_ecpm_record( trace_id=trace_id, app_env=app_env, our_code_id=our_code_id, - ecpm_raw=ecpm_raw, + ecpm_raw=effective_ecpm_raw(ecpm_raw, exposure_ms), report_date=cn_today().isoformat(), ) db.add(rec) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ceb74af..7a6feb4 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -69,6 +69,12 @@ class EcpmReportIn(BaseModel): description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", ) + exposure_ms: int | None = Field( + None, + ge=0, + le=86_400_000, + description="本条广告真实在屏曝光毫秒数;小于 1000ms 时收益强制按 0 计算。旧客户端不传则保持原口径", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) diff --git a/tests/test_ad_ecpm_trace_revenue.py b/tests/test_ad_ecpm_trace_revenue.py index a88d3e2..c3601c0 100644 --- a/tests/test_ad_ecpm_trace_revenue.py +++ b/tests/test_ad_ecpm_trace_revenue.py @@ -58,6 +58,32 @@ def test_revenue_yuan_by_trace_empty() -> None: db.close() +def test_short_exposure_keeps_record_with_zero_revenue() -> None: + """不足一秒仍落展示记录,以便后台显示 0 而不是未填充。""" + db = SessionLocal() + try: + rec = crud_ecpm.create_ecpm_record( + db, 1, ad_type="draw", ecpm_raw="350", + ad_session_id="sess-short-exposure", feed_scene="coupon", + trace_id="trace-short-exposure", exposure_ms=999, + ) + assert rec.ecpm_raw == "0" + assert crud_ecpm.revenue_yuan_by_trace(db, ["trace-short-exposure"]) == { + "trace-short-exposure": 0.0 + } + finally: + db.execute(delete(AdEcpmRecord).where( + AdEcpmRecord.ad_session_id == "sess-short-exposure" + )) + db.commit() + db.close() + + +def test_one_second_exposure_keeps_original_ecpm() -> None: + assert crud_ecpm.effective_ecpm_raw("350", 1000) == "350" + assert crud_ecpm.effective_ecpm_raw("350", None) == "350" + + def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None: """领券失败或被放弃后才到达的广告展示保留收益记录,但不再关联死亡 trace。""" db = SessionLocal() @@ -80,6 +106,12 @@ def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None: assert crud_ecpm.attributable_trace_id( db, feed_scene="coupon", trace_id="abandoned-before-ad" ) is None + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="failed-before-ad", exposure_ms=999 + ) == "failed-before-ad" + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="abandoned-before-ad", exposure_ms=999 + ) == "abandoned-before-ad" assert crud_ecpm.attributable_trace_id( db, feed_scene="comparison", trace_id="failed-before-ad" ) == "failed-before-ad"