feat(admin): 优化比价记录概览统计与加载 (#152)
## 变更内容 - 新增比价记录概览聚合接口,主耗时均值及 P5/P50/P95/P99 仅统计 success - 比价列表支持按北京自然日过滤,概览和列表复用同一筛选口径 - 后端聚合成功率、成本、低价率及中途退出指标 - 修复单条样本分位数错误返回 0 的边界问题 - 增加成功/失败/退出及日期分页回归测试 ## 验证 - Python compileall - SQLite 聚合、日期边界和分页检查 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: #152 Co-authored-by: linkeyu <linkeyu@wonderable.ai> Co-committed-by: linkeyu <linkeyu@wonderable.ai>
This commit was merged in pull request #152.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)。"""
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user