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/repositories/queries.py b/app/admin/repositories/queries.py index 7bbe522..26a36db 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, case, desc, func, or_, select @@ -209,6 +209,45 @@ def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | P r.nickname = nick +def _comparison_conditions( + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> list: + """比价列表与概览共用筛选条件;日期按北京自然日闭区间解释。""" + conditions = [] + if user_id is not None: + conditions.append(ComparisonRecord.user_id == user_id) + if phone: + conditions.append( + ComparisonRecord.user_id.in_( + select(User.id).where(User.phone.like(f"{phone}%")) + ) + ) + if status: + conditions.append(ComparisonRecord.status == status) + if business_type: + conditions.append(ComparisonRecord.business_type == business_type) + if store: + conditions.append(ComparisonRecord.store_name.like(f"%{store}%")) + if product: + conditions.append(ComparisonRecord.product_names.like(f"%{product}%")) + beijing = ZoneInfo("Asia/Shanghai") + if date_from is not None: + start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at >= start_utc) + if date_to is not None: + end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at < end_utc) + return conditions + + def list_comparison_records( db: Session, *, @@ -218,30 +257,19 @@ def list_comparison_records( business_type: str | None = None, store: str | None = None, product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[ComparisonRecord], int | None, int]: """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。 join User 取 phone/nickname 瞬态挂记录上。""" - stmt = select(ComparisonRecord) - if user_id is not None: - stmt = stmt.where(ComparisonRecord.user_id == user_id) - if phone: - stmt = stmt.where( - ComparisonRecord.user_id.in_( - select(User.id).where(User.phone.like(f"{phone}%")) - ) - ) - if status: - stmt = stmt.where(ComparisonRecord.status == status) - if business_type: - stmt = stmt.where(ComparisonRecord.business_type == business_type) - if store: - stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%")) - if product: - # 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。 - stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%")) + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + stmt = select(ComparisonRecord).where(*conditions) items, next_cursor, total = offset_paginate( db, stmt, (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), @@ -256,6 +284,92 @@ def list_comparison_records( return items, next_cursor, total +def _comparison_percentile(sorted_values: list[int], q: float) -> int | None: + """线性插值分位数(非负毫秒值四舍五入;单条数据返回自身)。""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * q + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + value = sorted_values[lower] * (upper - index) + sorted_values[upper] * (index - lower) + return int(value + 0.5) + + +def comparison_records_summary( + db: Session, + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> dict: + """比价记录页概览聚合;主耗时均值及分位数只取成功记录。""" + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)), + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + func.avg(ComparisonRecord.llm_cost_yuan), + func.sum(case(( + (ComparisonRecord.status == "success") + & (ComparisonRecord.saved_amount_cents > 0), 1 + ), else_=0)), + func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), + ).where(*conditions) + ).one() + started = int(row[0] or 0) + completed = int(row[1] or 0) + success = int(row[2] or 0) + lower_price = int(row[4] or 0) + cancelled = int(row[5] or 0) + success_durations = sorted(db.execute( + select(ComparisonRecord.total_ms).where( + *conditions, + ComparisonRecord.status == "success", + ComparisonRecord.total_ms.is_not(None), + ) + ).scalars().all()) + cancelled_durations = sorted(db.execute( + select(ComparisonRecord.total_ms).where( + *conditions, + ComparisonRecord.status == "cancelled", + ComparisonRecord.total_ms.is_not(None), + ) + ).scalars().all()) + success_rate_denominator = started - cancelled + return { + "started": started, + "completed": completed, + "success": success, + "success_rate": success / success_rate_denominator if success_rate_denominator else None, + "avg_token_cost": float(row[3]) if row[3] is not None else None, + "lower_price_rate": lower_price / success if success else None, + "avg_duration_ms": ( + int(sum(success_durations) / len(success_durations) + 0.5) + if success_durations else None + ), + "p5_duration_ms": _comparison_percentile(success_durations, 0.05), + "p50_duration_ms": _comparison_percentile(success_durations, 0.5), + "p95_duration_ms": _comparison_percentile(success_durations, 0.95), + "p99_duration_ms": _comparison_percentile(success_durations, 0.99), + "cancelled": cancelled, + "cancelled_rate": cancelled / started if started else None, + "cancelled_p5_ms": _comparison_percentile(cancelled_durations, 0.05), + "cancelled_p50_ms": _comparison_percentile(cancelled_durations, 0.5), + "cancelled_p95_ms": _comparison_percentile(cancelled_durations, 0.95), + } + + def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" rec = db.get(ComparisonRecord, record_id) 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/admin/routers/comparison.py b/app/admin/routers/comparison.py index 38dc242..8876dc0 100644 --- a/app/admin/routers/comparison.py +++ b/app/admin/routers/comparison.py @@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de """ from __future__ import annotations +from datetime import date from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query @@ -12,7 +13,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from app.admin.deps import AdminDb, get_current_admin from app.admin.repositories import queries from app.admin.schemas.common import CursorPage -from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem +from app.admin.schemas.comparison import ( + AdminComparisonDetail, + AdminComparisonListItem, + AdminComparisonSummary, +) router = APIRouter( prefix="/admin/api/comparison-records", @@ -34,12 +39,15 @@ def list_comparison_records( business_type: Annotated[str | None, Query()] = None, store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminComparisonListItem]: items, next_cursor, total = queries.list_comparison_records( db, user_id=user_id, phone=phone, status=status, business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, limit=limit, cursor=cursor, ) return CursorPage( @@ -49,6 +57,29 @@ def list_comparison_records( ) +@router.get( + "/summary", + response_model=AdminComparisonSummary, + summary="比价记录概览聚合", +) +def comparison_records_summary( + db: AdminDb, + user_id: Annotated[int | None, Query()] = None, + phone: Annotated[str | None, Query(description="手机号前缀")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + business_type: Annotated[str | None, Query()] = None, + store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, + product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, +) -> AdminComparisonSummary: + return AdminComparisonSummary(**queries.comparison_records_summary( + db, user_id=user_id, phone=phone, status=status, + business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, + )) + + @router.get( "/{record_id}", response_model=AdminComparisonDetail, diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index ec1d3aa..13c49ff 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -47,6 +47,27 @@ class AdminComparisonListItem(BaseModel): created_at: datetime +class AdminComparisonSummary(BaseModel): + """比价记录页概览;主耗时指标仅统计 status=success。""" + + started: int + completed: int + success: int + success_rate: float | None = None + avg_token_cost: float | None = None + lower_price_rate: float | None = None + avg_duration_ms: int | None = None + p5_duration_ms: int | None = None + p50_duration_ms: int | None = None + p95_duration_ms: int | None = None + p99_duration_ms: int | None = None + cancelled: int + cancelled_rate: float | None = None + cancelled_p5_ms: int | None = None + cancelled_p50_ms: int | None = None + cancelled_p95_ms: int | None = None + + class AdminComparisonDetail(AdminComparisonListItem): """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" 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() diff --git a/tests/test_comparison_admin_summary.py b/tests/test_comparison_admin_summary.py new file mode 100644 index 0000000..5006fd6 --- /dev/null +++ b/tests/test_comparison_admin_summary.py @@ -0,0 +1,65 @@ +"""比价记录页后端分页与概览聚合。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +import pytest + +from app.admin.repositories import queries +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord + + +def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: + db = SessionLocal() + try: + rows = [ + ("summary-success-a", "success", 1000, 1.0, 100), + ("summary-success-b", "success", 3000, 2.0, 0), + ("summary-failed", "failed", 100_000, 3.0, 0), + ("summary-cancelled", "cancelled", 5000, 4.0, 0), + ] + for trace_id, status, total_ms, cost, saved in rows: + db.add(ComparisonRecord( + trace_id=trace_id, + status=status, + total_ms=total_ms, + llm_cost_yuan=cost, + saved_amount_cents=saved, + created_at=datetime(2038, 1, 15, 12, tzinfo=UTC), + )) + db.add(ComparisonRecord( + trace_id="summary-outside-day", + status="success", + total_ms=999_999, + created_at=datetime(2038, 1, 16, 16, tzinfo=UTC), + )) + db.flush() + + summary = queries.comparison_records_summary( + db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) + ) + + assert summary["started"] == 4 + assert summary["completed"] == 3 + assert summary["success"] == 2 + assert summary["success_rate"] == pytest.approx(2 / 3) + assert summary["avg_token_cost"] == pytest.approx(2.5) + assert summary["lower_price_rate"] == 0.5 + assert summary["avg_duration_ms"] == 2000 + assert summary["p5_duration_ms"] == 1100 + assert summary["p50_duration_ms"] == 2000 + assert summary["p95_duration_ms"] == 2900 + assert summary["p99_duration_ms"] == 2980 + assert summary["cancelled"] == 1 + assert summary["cancelled_rate"] == 0.25 + assert summary["cancelled_p50_ms"] == 5000 + + items, _next_cursor, total = queries.list_comparison_records( + db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20 + ) + assert total == 4 + assert {item.trace_id for item in items} == {row[0] for row in rows} + finally: + db.rollback() + db.close()