From 9a0a7fbbb3d2ba38d4124bc69d4c2b63c2777da1 Mon Sep 17 00:00:00 2001 From: unknown <798648091@qq.com> Date: Tue, 21 Jul 2026 20:02:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(ad):=20=E5=B9=BF=E5=91=8A=E6=94=B6?= =?UTF-8?q?=E7=9B=8A=E6=94=AF=E6=8C=81=E7=8E=AF=E5=A2=83=E4=B8=8E=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E4=BB=A3=E7=A0=81=E4=BD=8D=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/repositories/ad_revenue.py | 47 ++++++- app/admin/routers/ad_revenue.py | 10 +- app/repositories/ad_pangle_revenue.py | 4 + docs/api/admin/ad/admin-ad-revenue-report.md | 4 +- tests/test_admin_ad_revenue_scope.py | 129 +++++++++++++++++++ 5 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 tests/test_admin_ad_revenue_scope.py diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index b1e2162..6d39879 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -33,7 +33,29 @@ from app.admin.repositories import stats as admin_stats from app.core import rewards from app.models.ad_ecpm import AdEcpmRecord from app.models.user import User -from app.repositories import ad_pangle_revenue +from app.repositories import ad_pangle_revenue, app_config + +# 已上线过的正式业务代码位要永久保留,避免运营切换当前配置后,历史报表把旧业务位误判成测试流量。 +_KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"}) + +# 测试应用中实际承载业务链路的代码位。广告测试 demo 的插屏/半屏/信息流测试位不在这里, +# 避免“业务口径”把开发诊断曝光混进客户端与穿山甲对账。 +_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"}) + + +def _business_code_ids(db: Session, app_env: str | None) -> set[str]: + """返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。""" + prod_config = app_config.get_ad_config(db) + prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | { + str(prod_config.get(key) or "").strip() + for key in ("reward_code_id", "compare_draw_code_id", "coupon_draw_code_id") + } + prod_ids.discard("") + if app_env == "prod": + return prod_ids + if app_env == "test": + return set(_TEST_BUSINESS_CODE_IDS) + return prod_ids | set(_TEST_BUSINESS_CODE_IDS) def _cn_hour(dt: datetime) -> int: @@ -82,6 +104,7 @@ def ad_revenue_report( ad_type: str | None = None, feed_scene: str | None = None, app_env: str | None = None, + revenue_scope: str = "all", granularity: str = "day", limit: int = 500, offset: int = 0, @@ -277,14 +300,18 @@ def ad_revenue_report( if feed_scene is not None: events = [e for e in events if e.get("feed_scene") == feed_scene] - # app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均): - # 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为 - # test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器 - # (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤 - # (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。 + # app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合, + # 保证客户端预估与 GroMore 汇总使用同一应用环境口径。 if app_env is not None: events = [e for e in events if e.get("app_env") == app_env] + # 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试 + # demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。 + business_code_ids: set[str] | None = None + if revenue_scope == "business": + business_code_ids = _business_code_ids(db, app_env) + events = [e for e in events if e.get("our_code_id") in business_code_ids] + # 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排; # 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。 if sort == "ecpm": @@ -336,7 +363,13 @@ def ad_revenue_report( total_pangle_revenue_yuan: float | None = None total_pangle_api_revenue_yuan: float | None = None if pangle_filterable: - pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to) + pangle_aggs = ad_pangle_revenue.aggregate_by_date( + db, + date_from=date_from, + date_to=date_to, + app_env=app_env, + our_code_ids=business_code_ids, + ) if pangle_aggs: by_date = {a["date"]: a for a in pangle_aggs} for d in daily: diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index 77685d4..c958e31 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import date as _date -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query @@ -63,6 +63,13 @@ def get_ad_revenue_report( "建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均" ), ] = None, + revenue_scope: Annotated[ + Literal["business", "all"], + Query( + description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ " + "all=穿山甲应用全部代码位(包含广告测试等非业务曝光)" + ), + ] = "all", granularity: Annotated[ str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day") ] = "day", @@ -83,6 +90,7 @@ def get_ad_revenue_report( result = ad_revenue.ad_revenue_report( db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env, + revenue_scope=revenue_scope, granularity=granularity, limit=limit, offset=offset, sort=sort, ) return AdRevenueReportOut( diff --git a/app/repositories/ad_pangle_revenue.py b/app/repositories/ad_pangle_revenue.py index 51ad39e..78fde1f 100644 --- a/app/repositories/ad_pangle_revenue.py +++ b/app/repositories/ad_pangle_revenue.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +from collections.abc import Collection from typing import Any, TypedDict from sqlalchemy import func, select @@ -73,6 +74,7 @@ def aggregate_by_date( date_to: str, app_env: str | None = None, our_code_id: str | None = None, + our_code_ids: Collection[str] | None = None, ) -> list[PangleDateAgg]: """按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。 @@ -97,6 +99,8 @@ def aggregate_by_date( stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env) if our_code_id is not None: stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id) + if our_code_ids is not None: + stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids)) out: list[PangleDateAgg] = [] for report_date, rev, api_rev, imp in db.execute(stmt).all(): diff --git a/docs/api/admin/ad/admin-ad-revenue-report.md b/docs/api/admin/ad/admin-ad-revenue-report.md index b4c2b07..a1c1132 100644 --- a/docs/api/admin/ad/admin-ad-revenue-report.md +++ b/docs/api/admin/ad/admin-ad-revenue-report.md @@ -28,6 +28,8 @@ | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 | | `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 | + | `app_env` | string | 全部 | `prod`=正式应用 / `test`=测试应用;同时过滤客户端预估与穿山甲汇总 | + | `revenue_scope` | string | `all` | `business`=仅业务代码位,用于同口径对账 / `all`=应用全部代码位,包含广告测试等非业务曝光 | | `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** | | `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 | | `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` | @@ -131,5 +133,5 @@ - **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。 - **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。 - **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。 -- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env` 的 `PANGLE_REPORT_*`。 +- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在未按 user/类型/场景过滤时展示。`app_env` 与 `revenue_scope` 会同时过滤客户端和穿山甲数据,其中 `business` 排除广告测试等非业务代码位。配置见 `.env` 的 `PANGLE_REPORT_*`。 - **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。 diff --git a/tests/test_admin_ad_revenue_scope.py b/tests/test_admin_ad_revenue_scope.py new file mode 100644 index 0000000..b01c92a --- /dev/null +++ b/tests/test_admin_ad_revenue_scope.py @@ -0,0 +1,129 @@ +"""广告收益报表的环境与业务代码位口径。""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete + +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.user import User + +REPORT_DATE = "2040-02-03" + + +def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) -> None: + db = SessionLocal() + try: + user = User( + phone="18800009991", + username="29999999991", + register_channel="sms", + ) + db.add(user) + db.flush() + db.add_all([ + AdEcpmRecord( + user_id=user.id, ad_type="reward_video", ad_session_id="scope-prod-business", + app_env="prod", our_code_id="prod-reward", ecpm_raw="10000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="draw", ad_session_id="scope-prod-demo", + app_env="prod", our_code_id="prod-demo", ecpm_raw="20000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="draw", ad_session_id="scope-prod-known-business", + app_env="prod", our_code_id="104098712", ecpm_raw="40000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="reward_video", ad_session_id="scope-test-business", + app_env="test", our_code_id="104127529", ecpm_raw="30000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="prod-reward", + adn="", revenue_yuan=1.5, api_revenue_yuan=1.2, impressions=10, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="prod-demo", + adn="", revenue_yuan=8.0, api_revenue_yuan=7.0, impressions=40, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="104098712", + adn="", revenue_yuan=2.5, api_revenue_yuan=2.0, impressions=20, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="test", our_code_id="104127529", + adn="", revenue_yuan=9.0, api_revenue_yuan=8.0, impressions=50, + ), + ]) + db.commit() + monkeypatch.setattr( + ad_revenue.app_config, + "get_ad_config", + lambda _db: { + "reward_code_id": "prod-reward", + "compare_draw_code_id": "prod-draw", + "coupon_draw_code_id": "prod-draw", + }, + ) + + business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="prod", + revenue_scope="business", + ) + assert business["total_impressions"] == 2 + assert business["total_revenue_yuan"] == 0.5 + assert business["total_pangle_revenue_yuan"] == 4.0 + assert business["total_pangle_api_revenue_yuan"] == 3.2 + + all_codes = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="prod", + revenue_scope="all", + ) + assert all_codes["total_impressions"] == 3 + assert all_codes["total_revenue_yuan"] == 0.7 + assert all_codes["total_pangle_revenue_yuan"] == 12.0 + assert all_codes["total_pangle_api_revenue_yuan"] == 10.2 + + test_business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="test", + revenue_scope="business", + ) + assert test_business["total_impressions"] == 1 + assert test_business["total_revenue_yuan"] == 0.3 + assert test_business["total_pangle_revenue_yuan"] == 9.0 + assert test_business["total_pangle_api_revenue_yuan"] == 8.0 + + all_env_business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env=None, + revenue_scope="business", + ) + assert all_env_business["total_impressions"] == 3 + assert all_env_business["total_revenue_yuan"] == 0.8 + assert all_env_business["total_pangle_revenue_yuan"] == 13.0 + assert all_env_business["total_pangle_api_revenue_yuan"] == 11.2 + finally: + db.rollback() + db.execute(delete(AdPangleDailyRevenue).where(AdPangleDailyRevenue.report_date == REPORT_DATE)) + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.report_date == REPORT_DATE)) + db.execute(delete(User).where(User.phone == "18800009991")) + db.commit() + db.close() -- 2.52.0