diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 6ce16b4..b297fbf 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"}) _TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"}) -def _business_code_ids(db: Session, app_env: str | None) -> set[str]: +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) | { @@ -320,10 +320,10 @@ def ad_revenue_report( # 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试 # demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。 - business_code_ids: set[str] | None = None + business_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] + business_ids = business_code_ids(db, app_env) + events = [e for e in events if e.get("our_code_id") in business_ids] # 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排; # 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。 @@ -381,7 +381,7 @@ def ad_revenue_report( date_from=date_from, date_to=date_to, app_env=app_env, - our_code_ids=business_code_ids, + our_code_ids=business_ids, ) if pangle_aggs: by_date = {a["date"]: a for a in pangle_aggs} diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 41f113c..a2158ee 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from app.core import rewards from app.core.config import settings +from app.models.ad_ecpm import AdEcpmRecord from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.admin import AdminAuditLog @@ -1257,11 +1258,15 @@ def user_reward_stats( date_from: datetime | None = None, date_to: datetime | None = None, withdraw_source: str | None = None, + app_env: str | None = None, + revenue_scope: str = "all", + feed_scene: str | None = None, ) -> dict: """提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。 - 口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加); - 平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。 + 口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加)。 + 平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算, + 不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。 传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。 """ withdraw_source_conds = ( @@ -1296,30 +1301,68 @@ def user_reward_stats( # 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时, # SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。 + business_ids: set[str] | None = None + if revenue_scope == "business": + # 与广告收益报表共用正式/测试业务代码位集合,避免两个页面随配置切换后再次漂移。 + from app.admin.repositories.ad_revenue import business_code_ids + + business_ids = business_code_ids(db, app_env) + + rv_conds = [ + AdRewardRecord.user_id == user_id, + AdRewardRecord.reward_scene == "reward_video", + AdRewardRecord.status == "granted", + *_window_conds(AdRewardRecord.created_at, date_from, date_to), + ] + if app_env is not None: + rv_conds.append(AdRewardRecord.app_env == app_env) + if business_ids is not None: + rv_conds.append(AdRewardRecord.our_code_id.in_(business_ids)) rv = db.execute( select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where( - AdRewardRecord.user_id == user_id, - AdRewardRecord.reward_scene == "reward_video", - AdRewardRecord.status == "granted", - *_window_conds(AdRewardRecord.created_at, date_from, date_to), + *rv_conds, ) ).all() rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw] rv_coins = sum(r.coin for r in rv) - feed = db.execute( + feed_reward_conds = [ + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.status == "granted", + *_window_conds(AdFeedRewardRecord.created_at, date_from, date_to), + ] + if app_env is not None: + feed_reward_conds.append(AdFeedRewardRecord.app_env == app_env) + if feed_scene is not None: + feed_reward_conds.append(AdFeedRewardRecord.feed_scene == feed_scene) + if business_ids is not None: + feed_reward_conds.append(AdFeedRewardRecord.our_code_id.in_(business_ids)) + feed_rewards = db.execute( select( AdFeedRewardRecord.unit_count, - AdFeedRewardRecord.ecpm_raw, AdFeedRewardRecord.coin, ).where( - AdFeedRewardRecord.user_id == user_id, - AdFeedRewardRecord.status == "granted", - *_window_conds(AdFeedRewardRecord.created_at, date_from, date_to), + *feed_reward_conds, ) ).all() - feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw] - feed_coins = sum(f.coin for f in feed) + feed_coins = sum(f.coin for f in feed_rewards) + + feed_impression_conds = [ + AdEcpmRecord.user_id == user_id, + AdEcpmRecord.ad_type.in_(("draw", "feed")), + *_window_conds(AdEcpmRecord.created_at, date_from, date_to), + ] + if app_env is not None: + feed_impression_conds.append(AdEcpmRecord.app_env == app_env) + if feed_scene is not None: + feed_impression_conds.append(AdEcpmRecord.feed_scene == feed_scene) + if business_ids is not None: + feed_impression_conds.append(AdEcpmRecord.our_code_id.in_(business_ids)) + feed_impressions = db.execute( + select(AdEcpmRecord.ecpm_raw).where(*feed_impression_conds) + ).all() + # 与 ad_revenue.category_stats 相同:每次展示权重相同,非法原值按 parse_ecpm_fen 记 0。 + feed_ecpms = [rewards.parse_ecpm_fen(row.ecpm_raw) for row in feed_impressions] trad_coins = db.execute( select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where( @@ -1338,7 +1381,7 @@ def user_reward_stats( "reward_video_count": len(rv), "reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0, "reward_video_cash_cents": _coins_to_cents(rv_coins), - "feed_count": int(sum(f.unit_count for f in feed)), + "feed_count": int(sum(f.unit_count for f in feed_rewards)), "feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0, "feed_cash_cents": _coins_to_cents(feed_coins), } diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 1d8b056..bb044ef 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -88,6 +88,11 @@ def get_user_reward_stats( withdraw_source: Annotated[ str | None, Query(pattern="^(coin_cash|invite_cash)$") ] = None, + app_env: Annotated[str | None, Query(pattern="^(prod|test)$")] = None, + revenue_scope: Annotated[str, Query(pattern="^(business|all)$")] = "all", + feed_scene: Annotated[ + str | None, Query(pattern="^(comparison|coupon|welfare)$") + ] = None, ) -> UserRewardStats: """提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。""" if not user_repo.user_exists(db, user_id): @@ -99,6 +104,9 @@ def get_user_reward_stats( date_from=date_from, date_to=date_to, withdraw_source=withdraw_source, + app_env=app_env, + revenue_scope=revenue_scope, + feed_scene=feed_scene, ) ) diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index aa826da..2e5a757 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -60,7 +60,7 @@ class UserRewardStats(BaseModel): reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次) reward_video_cash_cents: int # 激励视频提现(金币折现) feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加) - feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次) + feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示) feed_cash_cents: int # 信息流广告提现(金币折现) diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 084caac..806faed 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -219,6 +219,115 @@ def test_user_reward_stats_can_scope_withdrawals_by_account( assert invite.json()["cash_balance_cents"] == 456 +def test_user_reward_stats_draw_ecpm_uses_all_filtered_impressions( + admin_client: TestClient, admin_token: str +) -> None: + """Draw 平均 eCPM 应与收益报表一致,不能只平均成功发奖记录。""" + from app.models.ad_ecpm import AdEcpmRecord + from app.models.ad_feed_reward import AdFeedRewardRecord + + uid = _seed_user_with_data("13800000024") + created_at = datetime(2038, 1, 15, 4, tzinfo=UTC) + db = SessionLocal() + try: + db.add_all( + [ + AdFeedRewardRecord( + client_event_id="reward-stats-granted-high", + ad_session_id="reward-stats-granted-high", + user_id=uid, + reward_date="2038-01-15", + duration_seconds=10, + unit_count=1, + ecpm_raw="9000", + ad_type="draw", + feed_scene="coupon", + app_env="prod", + our_code_id="104098712", + coin=9, + status="granted", + created_at=created_at, + ), + AdEcpmRecord( + user_id=uid, + ad_type="draw", + feed_scene="coupon", + ad_session_id="reward-stats-impression-low", + app_env="prod", + our_code_id="104098712", + ecpm_raw="1000", + report_date="2038-01-15", + created_at=created_at, + ), + AdEcpmRecord( + user_id=uid, + ad_type="feed", + feed_scene="coupon", + ad_session_id="reward-stats-impression-mid", + app_env="prod", + our_code_id="104098712", + ecpm_raw="3000", + report_date="2038-01-15", + created_at=created_at, + ), + # 同用户但不同场景/环境/非业务代码位,均不应进入本次详情筛选。 + AdEcpmRecord( + user_id=uid, + ad_type="draw", + feed_scene="comparison", + ad_session_id="reward-stats-other-scene", + app_env="prod", + our_code_id="104098712", + ecpm_raw="7000", + report_date="2038-01-15", + created_at=created_at, + ), + AdEcpmRecord( + user_id=uid, + ad_type="draw", + feed_scene="coupon", + ad_session_id="reward-stats-test-env", + app_env="test", + our_code_id="104127529", + ecpm_raw="8000", + report_date="2038-01-15", + created_at=created_at, + ), + AdEcpmRecord( + user_id=uid, + ad_type="draw", + feed_scene="coupon", + ad_session_id="reward-stats-non-business", + app_env="prod", + our_code_id="demo-slot", + ecpm_raw="9000", + report_date="2038-01-15", + created_at=created_at, + ), + ] + ) + db.commit() + finally: + db.close() + + response = admin_client.get( + f"/admin/api/users/{uid}/reward-stats", + params={ + "date_from": "2038-01-15T00:00:00Z", + "date_to": "2038-01-15T23:59:59Z", + "app_env": "prod", + "revenue_scope": "business", + "feed_scene": "coupon", + }, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["feed_count"] == 1 + # 全部真实展示 (1000 + 3000) / 2;不能返回成功发奖记录的 9000。 + assert data["feed_avg_ecpm"] == 2000.0 + + def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None: """线上 PostgreSQL 返回 aware,SQLite/历史转换可能返回 naive,二者必须可混排。""" naive = datetime(2038, 1, 1, 8, 0)