feat(ad): 广告收益支持环境与业务代码位筛选
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user