Compare commits

...

1 Commits

Author SHA1 Message Date
unknown 501d39ca6f feat(admin): aggregate comparison records summary 2026-07-21 15:05:44 +08:00
4 changed files with 251 additions and 20 deletions
+133 -19
View File
@@ -5,7 +5,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select 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 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( def list_comparison_records(
db: Session, db: Session,
*, *,
@@ -218,30 +257,19 @@ def list_comparison_records(
business_type: str | None = None, business_type: str | None = None,
store: str | None = None, store: str | None = None,
product: str | None = None, product: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None, int]: ) -> tuple[list[ComparisonRecord], int | None, int]:
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。 store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
join User 取 phone/nickname 瞬态挂记录上。""" join User 取 phone/nickname 瞬态挂记录上。"""
stmt = select(ComparisonRecord) conditions = _comparison_conditions(
if user_id is not None: user_id=user_id, phone=phone, status=status, business_type=business_type,
stmt = stmt.where(ComparisonRecord.user_id == user_id) store=store, product=product, date_from=date_from, date_to=date_to,
if phone: )
stmt = stmt.where( stmt = select(ComparisonRecord).where(*conditions)
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}%"))
items, next_cursor, total = offset_paginate( items, next_cursor, total = offset_paginate(
db, stmt, db, stmt,
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
@@ -256,6 +284,92 @@ def list_comparison_records(
return items, next_cursor, total 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: def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
rec = db.get(ComparisonRecord, record_id) rec = db.get(ComparisonRecord, record_id)
+32 -1
View File
@@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query 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.deps import AdminDb, get_current_admin
from app.admin.repositories import queries from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage 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( router = APIRouter(
prefix="/admin/api/comparison-records", prefix="/admin/api/comparison-records",
@@ -34,12 +39,15 @@ def list_comparison_records(
business_type: Annotated[str | None, Query()] = None, business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: 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, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminComparisonListItem]: ) -> CursorPage[AdminComparisonListItem]:
items, next_cursor, total = queries.list_comparison_records( items, next_cursor, total = queries.list_comparison_records(
db, user_id=user_id, phone=phone, status=status, db, user_id=user_id, phone=phone, status=status,
business_type=business_type, store=store, product=product, business_type=business_type, store=store, product=product,
date_from=date_from, date_to=date_to,
limit=limit, cursor=cursor, limit=limit, cursor=cursor,
) )
return CursorPage( 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( @router.get(
"/{record_id}", "/{record_id}",
response_model=AdminComparisonDetail, response_model=AdminComparisonDetail,
+21
View File
@@ -47,6 +47,27 @@ class AdminComparisonListItem(BaseModel):
created_at: datetime 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): class AdminComparisonDetail(AdminComparisonListItem):
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
+65
View File
@@ -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()