Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 501d39ca6f |
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)。"""
|
||||||
|
|
||||||
|
|||||||
@@ -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