From 5cd1c63d8de2fee83d3bfc260319b22f360f8ad1 Mon Sep 17 00:00:00 2001 From: unknown <798648091@qq.com> Date: Mon, 20 Jul 2026 20:01:38 +0800 Subject: [PATCH] feat(admin): aggregate dashboard comparison metrics --- app/admin/repositories/stats.py | 111 +++++++++++++++++++++++++++++--- app/admin/schemas/dashboard.py | 7 +- tests/test_admin_read.py | 46 +++++++++++++ 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 260c159..fbc3c7c 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections import Counter 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.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)] +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]: 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 < end_local, ) - period_comparison_total = _count(ComparisonRecord, *period_comparison_conds) - period_comparison_success = _count( - ComparisonRecord, - *period_comparison_conds, - ComparisonRecord.status == "success", + period_comparison_stats = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce( + 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 = ( - round(period_comparison_success / period_comparison_total, 4) - if period_comparison_total - else 0.0 + round( + period_comparison_success / period_comparison_success_denominator, + 4, + ) + if period_comparison_success_denominator > 0 + else None ) period_saved_positive_count = _count( ComparisonRecord, @@ -282,6 +329,47 @@ def dashboard_overview( if period_avg_duration_ms is not 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 = ( select(SavingsRecord.id) @@ -622,11 +710,16 @@ def dashboard_overview( }, "comparison": { "total": period_comparison_total, + "completed": period_comparison_completed, + "cancelled": period_comparison_cancelled, "success": period_comparison_success, "success_rate": period_comparison_success_rate, "ordered": period_ordered_count, "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, + "token_cost_total_yuan": period_comparison_token_cost_yuan, }, "coupon": { "started": coupon_started, diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index b3e008a..bb8a281 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel): class DashboardPeriodComparison(BaseModel): total: int + completed: int + cancelled: int success: int - success_rate: float + success_rate: float | None = None ordered: int average_duration_ms: int | None = None + median_duration_ms: int | None = None + p95_duration_ms: int | None = None average_saved_cents: int | None = None + token_cost_total_yuan: float = 0.0 class DashboardPeriodCoupon(BaseModel): diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 5f5313f..75145e4 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -1,12 +1,15 @@ """Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。""" from __future__ import annotations +from datetime import datetime + import pytest from fastapi.testclient import TestClient from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.wallet import CashTransaction, WithdrawOrder 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"] +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: uid = _seed_user_with_data("13800000002") r = admin_client.get("/admin/api/users", headers=_auth(admin_token))