From 9286b82b6d1f79fc7eaa4c7b97ac4c1f83f1de0e Mon Sep 17 00:00:00 2001 From: linkeyu Date: Wed, 22 Jul 2026 10:42:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BC=98=E5=8C=96=E6=AF=94?= =?UTF-8?q?=E4=BB=B7=E8=AE=B0=E5=BD=95=E6=A6=82=E8=A7=88=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E4=B8=8E=E5=8A=A0=E8=BD=BD=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 新增比价记录概览聚合接口,主耗时均值及 P5/P50/P95/P99 仅统计 success - 比价列表支持按北京自然日过滤,概览和列表复用同一筛选口径 - 后端聚合成功率、成本、低价率及中途退出指标 - 修复单条样本分位数错误返回 0 的边界问题 - 增加成功/失败/退出及日期分页回归测试 ## 验证 - Python compileall - SQLite 聚合、日期边界和分页检查 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/152 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/queries.py | 152 +++++++++++++++++++++---- app/admin/routers/comparison.py | 33 +++++- app/admin/schemas/comparison.py | 21 ++++ tests/test_comparison_admin_summary.py | 65 +++++++++++ 4 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 tests/test_comparison_admin_summary.py 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/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/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()