Compare commits

...

2 Commits

Author SHA1 Message Date
linkeyu 57731f2e8d fix(admin): 区分领券广告收益未填充与零收益 (#148)
## 修改内容
- 无 eCPM 填充记录时,领券数据接口返回 null
- 已填充但收益为 0 时保留 0.0
- 补充未填充与零收益的回归测试

## 验证
- 后端定向测试:7 passed
- Ruff 检查通过
- 线上近 30 天 740 条领券记录中,647 条无对应 eCPM 填充记录

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #148
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 10:18:34 +08:00
unknown 5cd1c63d8d feat(admin): aggregate dashboard comparison metrics 2026-07-20 20:02:58 +08:00
6 changed files with 194 additions and 15 deletions
+8 -3
View File
@@ -86,7 +86,12 @@ def _success_rates(rows: list) -> dict:
} }
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: def _session_to_row(
r,
phone: str | None = None,
nickname: str | None = None,
ad_revenue_yuan: float | None = None,
) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return { return {
"id": r.id, "id": r.id,
@@ -252,7 +257,7 @@ def coupon_data_report(
items = [] items = []
for r in page: for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id)))
return { return {
"summary": summary, "summary": summary,
@@ -276,7 +281,7 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
).scalar_one() ).scalar_one()
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
return { return {
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows], "items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id)) for r in rows],
"total": int(total), "total": int(total),
} }
+102 -9
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from collections import Counter from collections import Counter
from datetime import date, datetime, time, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -109,6 +109,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
return [date_from + timedelta(days=i) for i in range(days + 1)] return [date_from + timedelta(days=i) for i in range(days + 1)]
def _duration_percentile(sorted_values: list[int], q: float) -> int | None:
"""Linear-interpolated percentile with the same half-up rounding as Math.round."""
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)
fraction = Decimal(str(index - lower))
value = (
Decimal(sorted_values[lower]) * (Decimal(1) - fraction)
+ Decimal(sorted_values[upper]) * fraction
)
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _id_set(db: Session, stmt) -> set[int]: def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None} return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
@@ -242,16 +259,46 @@ def dashboard_overview(
ComparisonRecord.created_at >= start_local, ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local, ComparisonRecord.created_at < end_local,
) )
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds) period_comparison_stats = db.execute(
period_comparison_success = _count( select(
ComparisonRecord, func.count(ComparisonRecord.id),
*period_comparison_conds, func.coalesce(
ComparisonRecord.status == "success", func.sum(
case(
(ComparisonRecord.status.in_(("success", "failed")), 1),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case((ComparisonRecord.status == "cancelled", 1), else_=0)
),
0,
),
func.coalesce(
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
0,
),
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
).where(*period_comparison_conds)
).one()
period_comparison_total = int(period_comparison_stats[0])
period_comparison_completed = int(period_comparison_stats[1])
period_comparison_cancelled = int(period_comparison_stats[2])
period_comparison_success = int(period_comparison_stats[3])
period_comparison_token_cost_yuan = float(period_comparison_stats[4])
period_comparison_success_denominator = (
period_comparison_total - period_comparison_cancelled
) )
period_comparison_success_rate = ( period_comparison_success_rate = (
round(period_comparison_success / period_comparison_total, 4) round(
if period_comparison_total period_comparison_success / period_comparison_success_denominator,
else 0.0 4,
)
if period_comparison_success_denominator > 0
else None
) )
period_saved_positive_count = _count( period_saved_positive_count = _count(
ComparisonRecord, ComparisonRecord,
@@ -282,6 +329,47 @@ def dashboard_overview(
if period_avg_duration_ms is not None if period_avg_duration_ms is not None
else None else None
) )
completed_duration_conds = (
*period_comparison_conds,
ComparisonRecord.status.in_(("success", "failed")),
ComparisonRecord.total_ms.is_not(None),
)
if db.bind is not None and db.bind.dialect.name == "postgresql":
period_median_duration_ms, period_p95_duration_ms = db.execute(
select(
func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms),
func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms),
).where(*completed_duration_conds)
).one()
period_median_duration_ms = (
int(
Decimal(str(period_median_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_median_duration_ms is not None
else None
)
period_p95_duration_ms = (
int(
Decimal(str(period_p95_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_p95_duration_ms is not None
else None
)
else:
# SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。
completed_durations = list(
db.execute(
select(ComparisonRecord.total_ms)
.where(*completed_duration_conds)
.order_by(ComparisonRecord.total_ms)
).scalars()
)
period_median_duration_ms = _duration_percentile(completed_durations, 0.5)
period_p95_duration_ms = _duration_percentile(completed_durations, 0.95)
ordered_exists = ( ordered_exists = (
select(SavingsRecord.id) select(SavingsRecord.id)
@@ -622,11 +710,16 @@ def dashboard_overview(
}, },
"comparison": { "comparison": {
"total": period_comparison_total, "total": period_comparison_total,
"completed": period_comparison_completed,
"cancelled": period_comparison_cancelled,
"success": period_comparison_success, "success": period_comparison_success,
"success_rate": period_comparison_success_rate, "success_rate": period_comparison_success_rate,
"ordered": period_ordered_count, "ordered": period_ordered_count,
"average_duration_ms": period_avg_duration_ms, "average_duration_ms": period_avg_duration_ms,
"median_duration_ms": period_median_duration_ms,
"p95_duration_ms": period_p95_duration_ms,
"average_saved_cents": period_avg_saved_cents, "average_saved_cents": period_avg_saved_cents,
"token_cost_total_yuan": period_comparison_token_cost_yuan,
}, },
"coupon": { "coupon": {
"started": coupon_started, "started": coupon_started,
+3 -2
View File
@@ -70,8 +70,9 @@ class CouponDataRow(BaseModel):
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None claimed_count: int | None = None
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
ad_revenue_yuan: float = Field( ad_revenue_yuan: float | None = Field(
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" None,
description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record;无填充记录为 null",
) )
+6 -1
View File
@@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel):
class DashboardPeriodComparison(BaseModel): class DashboardPeriodComparison(BaseModel):
total: int total: int
completed: int
cancelled: int
success: int success: int
success_rate: float success_rate: float | None = None
ordered: int ordered: int
average_duration_ms: int | None = None average_duration_ms: int | None = None
median_duration_ms: int | None = None
p95_duration_ms: int | None = None
average_saved_cents: int | None = None average_saved_cents: int | None = None
token_cost_total_yuan: float = 0.0
class DashboardPeriodCoupon(BaseModel): class DashboardPeriodCoupon(BaseModel):
+46
View File
@@ -1,12 +1,15 @@
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。""" """Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.admin.main import admin_app from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo from app.admin.repositories import admin_user as admin_repo
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback from app.models.feedback import Feedback
from app.models.wallet import CashTransaction, WithdrawOrder from app.models.wallet import CashTransaction, WithdrawOrder
from app.repositories import user as user_repo from app.repositories import user as user_repo
@@ -69,6 +72,49 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert "jd_order_count" in data["cps"] assert "jd_order_count" in data["cps"]
def test_dashboard_period_comparison_is_aggregated_by_backend(
admin_client: TestClient, admin_token: str
) -> None:
created_at = datetime(2037, 1, 15, 12)
rows = [
("dashboard-aggregate-success", "success", 101, 0.1),
("dashboard-aggregate-failed", "failed", 200, 0.2),
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
("dashboard-aggregate-running", "running", 400, 0.4),
]
db = SessionLocal()
try:
for trace_id, status, total_ms, llm_cost_yuan in rows:
db.add(
ComparisonRecord(
trace_id=trace_id,
status=status,
total_ms=total_ms,
llm_cost_yuan=llm_cost_yuan,
created_at=created_at,
)
)
db.commit()
finally:
db.close()
response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2037-01-15", "date_to": "2037-01-15"},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
comparison = response.json()["period"]["comparison"]
assert comparison["total"] == 4
assert comparison["completed"] == 2
assert comparison["cancelled"] == 1
assert comparison["success"] == 1
assert comparison["success_rate"] == 0.3333
assert comparison["median_duration_ms"] == 151
assert comparison["p95_duration_ms"] == 195
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None: def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
uid = _seed_user_with_data("13800000002") uid = _seed_user_with_data("13800000002")
r = admin_client.get("/admin/api/users", headers=_auth(admin_token)) r = admin_client.get("/admin/api/users", headers=_auth(admin_token))
+29
View File
@@ -43,6 +43,35 @@ def test_coupon_data_report_includes_ad_revenue() -> None:
db.close() db.close()
def test_coupon_data_report_distinguishes_unfilled_from_zero_revenue() -> None:
"""无 eCPM 记录返回 None;已填充但 eCPM=0 返回 0.0。"""
db = SessionLocal()
try:
db.add_all([
CouponSession(
trace_id="rev-cp-unfilled", device_id="d-unfilled", status="completed", app_env="prod",
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
started_at=datetime(2020, 1, 3, tzinfo=UTC), started_date=date(2020, 1, 3),
),
CouponSession(
trace_id="rev-cp-zero", device_id="d-zero", status="completed", app_env="prod",
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
started_at=datetime(2020, 1, 3, tzinfo=UTC), started_date=date(2020, 1, 3),
),
_ecpm("rev-cp-zero", "0", "cp-sess-zero", "coupon"),
])
db.flush()
res = coupon_data_report(db, date_from="2020-01-03", date_to="2020-01-03", app_env="prod")
rows = {row["trace_id"]: row for row in res["items"]}
assert rows["rev-cp-unfilled"]["ad_revenue_yuan"] is None
assert rows["rev-cp-zero"]["ad_revenue_yuan"] == 0.0
finally:
db.rollback()
db.close()
def test_comparison_list_includes_ad_revenue() -> None: def test_comparison_list_includes_ad_revenue() -> None:
"""比价记录列表项带本次广告收益;200 分 → 0.002 元。 """比价记录列表项带本次广告收益;200 分 → 0.002 元。