Compare commits

..

1 Commits

Author SHA1 Message Date
unknown 501d39ca6f feat(admin): aggregate comparison records summary 2026-07-21 15:05:44 +08:00
11 changed files with 260 additions and 394 deletions
@@ -1,52 +0,0 @@
"""add composite index (user_id, created_at, id) on comparison_record
C 端「我的比价记录」列表(GET /api/v1/compare/records)是
`WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n` —— 原来只有单列 user_id 索引,
过滤完还要把该用户的**全部**记录取出来排序才能拿前 n 条,重度用户随记录数线性变慢。
本复合索引的反向扫恰好等于 (created_at DESC, id DESC),规划器直接取前 n 条、免排序。
列序 (user_id, created_at, id) 与查询一一对应,不要调整。
Revision ID: comparison_user_created_idx
Revises: merge_active_phone
Create Date: 2026-07-21
"""
from __future__ import annotations
from alembic import op
revision = "comparison_user_created_idx"
down_revision = "merge_active_phone"
branch_labels = None
depends_on = None
INDEX_NAME = "ix_comparison_user_created"
COLUMNS = ["user_id", "created_at", "id"]
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
# 线上 comparison_record 已有数据量,普通 CREATE INDEX 持表写锁会阻塞比价 harvest 写入;
# 用 CONCURRENTLY 不锁表(须脱离事务,autocommit_block 切到自动提交)。
# 同 comparison_status_created_idx 的做法。
with op.get_context().autocommit_block():
op.create_index(
INDEX_NAME, "comparison_record", COLUMNS,
unique=False, postgresql_concurrently=True,
)
else:
op.create_index(INDEX_NAME, "comparison_record", COLUMNS, unique=False)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.drop_index(
INDEX_NAME, table_name="comparison_record",
postgresql_concurrently=True,
)
else:
op.drop_index(INDEX_NAME, table_name="comparison_record")
+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)。"""
+1 -10
View File
@@ -115,22 +115,13 @@ def list_records(
db: DbSession, db: DbSession,
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"), cursor: int | None = Query(None, description="上一页末条 id"),
ordered: bool | None = Query(
None,
description="true=只看「已下单」(店名命中本人真实下单)的记录;不传=全部",
),
keyword: str | None = Query(
None,
max_length=64,
description="按店名 / 菜名模糊搜索,忽略大小写;空白串等同不传",
),
include_trace: bool = Query( include_trace: bool = Query(
False, False,
description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url", description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url",
), ),
) -> ComparisonRecordPage: ) -> ComparisonRecordPage:
items, next_cursor = crud_compare.list_records( items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor, ordered=ordered, keyword=keyword db, user.id, limit=limit, cursor=cursor
) )
outs = [ComparisonRecordOut.model_validate(it) for it in items] outs = [ComparisonRecordOut.model_validate(it) for it in items]
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。 # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。
-4
View File
@@ -45,10 +45,6 @@ class ComparisonRecord(Base):
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序; # 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。 # 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
Index("ix_comparison_status_created", "status", "created_at"), Index("ix_comparison_status_created", "status", "created_at"),
# C 端「我的比价记录」列表:WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n。
# 单列 user_id 索引只能过滤,排序仍要把该用户全部记录取出来排一遍;这条复合索引的**反向扫**
# 恰好等于 (created_at DESC, id DESC),PG 直接取前 n 条、免排序。列序不能动。
Index("ix_comparison_user_created", "user_id", "created_at", "id"),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+8 -83
View File
@@ -7,8 +7,8 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import func, or_, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, defer from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ from app.core.rewards import CN_TZ
from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_feed_reward import AdFeedRewardRecord
@@ -375,48 +375,19 @@ def harvest_abort(
return rec return rec
def _ordered_shop_name_select(user_id: int): def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
"""该用户「真实下单」(source='compare')覆盖到的店名 select,给「已下单」筛选当子查询 """该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」
口径与 [_ordered_shop_names] 完全一致,只是时机不同:那边是**拿到本页之后** candidates
反查打标;这边是**分页之前**就要过滤,拿不到 candidates,只能整段下推成子查询
没有先捞成集合再展开 IN (...) 字面量 重度用户下单过的店名可能上千,展开会撞 SQLite
的绑定变量上限,而且又变回了那个随下单量线性变慢的老写法
"""
return select(SavingsRecord.shop_name).where(
SavingsRecord.user_id == user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
)
def _like_escape(kw: str) -> str:
"""转义 LIKE 通配符(百分号 / 下划线 / 反斜杠),让用户输入只按字面量匹配(配合 escape 参数)。
不转义的话搜一个%就等于把整表拉回来
"""
return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _ordered_shop_names(db: Session, user_id: int, candidates: set[str]) -> set[str]:
"""[candidates] 里哪些店名被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。
只认 compare(归因命中后真实上报),demo 演示数据不算下单上报不带 trace_id, 只认 compare(归因命中后真实上报),demo 演示数据不算下单上报不带 trace_id,
只能按店名对齐两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店 只能按店名对齐两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店
语义=店级:同一家店比价过多次,这些记录会一并标已下单 语义=店级:同一家店比价过多次,这些记录会一并标已下单
只查**本页出现过的店名**(candidates limit ),不再把该用户全部下单店名捞回内存:
老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集空集合直接返回
(避免 IN () 非法)
""" """
if not candidates:
return set()
rows = db.execute( rows = db.execute(
select(SavingsRecord.shop_name).where( select(SavingsRecord.shop_name).where(
SavingsRecord.user_id == user_id, SavingsRecord.user_id == user_id,
SavingsRecord.source == "compare", SavingsRecord.source == "compare",
SavingsRecord.shop_name.in_(candidates), SavingsRecord.shop_name.is_not(None),
).distinct() )
).scalars().all() ).scalars().all()
return {s for s in rows if s} return {s for s in rows if s}
@@ -444,60 +415,17 @@ def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[
return {tid: int(coin) for tid, coin in rows if tid} return {tid: int(coin) for tid, coin in rows if tid}
# 列表出参(ComparisonRecordOut)根本不读、但 select(ORM) 默认会一并捞回来的重型 JSON 列:
# - raw_payload:done.params 上报体全量,**每条记录都有**(harvest 与 POST 两条写路径都落)。
# 单条几 KB~几十 KB,一页 50 条就是稳定几百 KB~几 MB 的白读 + 白反序列化。
# - llm_calls:每次 LLM 调用的 input_messages + output 全文。只有走老客户端 POST /compare/record
# 的记录才有(_backfill_llm_calls 回填;harvest 路径不落),但有的时候单条就能到 MB 级 —— 一页里
# 混进几条这种记录,整个请求就被它们拖住。
# - llm_price_snapshot:逐模型单价快照,同样只在回填时落。
# 三列全部读出来再被 pydantic 丢掉,是「比价记录/全部记录」页慢的主要来源。
# ⚠️ defer 的列一旦在别处被读到会触发**逐行**懒加载(N+1);列表这条链路(ComparisonRecordOut
# 不声明这三个字段 → 不会 getattr 到)是安全的。详情接口 get_record 不 defer,raw_payload 照常返回。
_LIST_DEFERRED = (
ComparisonRecord.raw_payload,
ComparisonRecord.llm_calls,
ComparisonRecord.llm_price_snapshot,
)
def list_records( def list_records(
db: Session, db: Session,
user_id: int, user_id: int,
*, *,
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
ordered: bool | None = None,
keyword: str | None = None,
) -> tuple[list[ComparisonRecord], int | None]: ) -> tuple[list[ComparisonRecord], int | None]:
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。""" """比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
stmt = ( stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
select(ComparisonRecord)
.where(ComparisonRecord.user_id == user_id)
.options(*(defer(col) for col in _LIST_DEFERRED))
)
if cursor is not None: if cursor is not None:
stmt = stmt.where(ComparisonRecord.id < cursor) stmt = stmt.where(ComparisonRecord.id < cursor)
# 「已下单」tab 与搜索框的过滤都下推到这里,不能留给客户端对整页结果 filter ——
# 分页之后一页里可能一条都不命中,列表看着就是空的/卡住的,得翻很多页才蹦出一条。
if ordered:
stmt = stmt.where(
ComparisonRecord.store_name.in_(_ordered_shop_name_select(user_id))
)
kw = (keyword or "").strip()
if kw:
# product_names 是写路径从 items[].name 派生的普通文本列(items 本身是 JSON,SQLite 下
# 中文被 ensure_ascii 转义,没法直接 LIKE)—— 搜「菜名」靠的就是它。
# ilike:PG 原生 ILIKE,SQLite 渲染成 lower() LIKE lower(),两边都忽略大小写。
pattern = f"%{_like_escape(kw)}%"
stmt = stmt.where(
or_(
ComparisonRecord.store_name.ilike(pattern, escape="\\"),
ComparisonRecord.product_names.ilike(pattern, escape="\\"),
)
)
# 排序与 ix_comparison_user_created(user_id, created_at, id)对齐 —— DESC/DESC 正好是该索引的
# 反向扫,PG 免排序直接取前 limit 条。改排序方向前先想清楚索引还吃不吃得上。
stmt = stmt.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()).limit(limit) stmt = stmt.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all()) items = list(db.execute(stmt).scalars().all())
@@ -505,10 +433,7 @@ def list_records(
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。 # 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。 # ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
page_shops = {it.store_name for it in items if it.store_name} ordered_shops = _ordered_shop_names(db, user_id)
# ordered=True 时上面已按同一口径(_ordered_shop_name_select)筛过,本页必然全是已下单,
# 省掉这次反查;其余情况照旧按本页店名反查 savings。
ordered_shops = page_shops if ordered else _ordered_shop_names(db, user_id, page_shops)
# 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。 # 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。
ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items]) ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items])
for it in items: for it in items:
-12
View File
@@ -25,18 +25,6 @@ server {
# (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。 # (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。
client_max_body_size 32m; client_max_body_size 32m;
# JSON 响应压缩。nginx 默认 gzip off,且就算 on 了 gzip_types 也只含 text/html、
# gzip_proxied 默认 off(反代来的响应一律不压)—— 三个默认值凑一起 = 我们所有接口都在裸奔。
# 比价记录列表这种一次 50 条、字段名 + 中文店名/菜名高度重复的 JSON,gzip 压缩比稳定在 8~10 倍
# (几百 KB → 几十 KB),弱网下省的就是首屏那几秒。
# 只压 JSON:APK 直链(/media/shaguabijia.apk)、图片本身已是压缩格式,再压纯浪费 CPU。
gzip on;
gzip_proxied any; # 反代响应也压(默认 off = 对我们这套反代等于没开)
gzip_types application/json;
gzip_min_length 1024; # 小响应压了反而更大(gzip 头开销),不值当
gzip_comp_level 5; # 5 是体积/CPU 的常用折中点,再往上收益递减
gzip_vary on; # 给 CDN/中间缓存正确按 Accept-Encoding 分桶
location / { location / {
proxy_pass http://127.0.0.1:8770; proxy_pass http://127.0.0.1:8770;
proxy_http_version 1.1; proxy_http_version 1.1;
-9
View File
@@ -10,12 +10,6 @@
|---|---|---|---|---| |---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 | | `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 | | `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
| `ordered` | bool | ❌ | null | `true`=只出「已下单」(店名命中本人真实下单)的记录;不传=不筛 |
| `keyword` | string | ❌ | null | 按店名 / 菜名模糊搜索,忽略大小写,≤64 字符;纯空白等同不传 |
| `include_trace` | bool | ❌ | false | 客户端开了本机 agent 调试模式时带 `true`,放行**本人**记录的 `trace_url` |
`ordered` / `keyword` 都在服务端过滤后再分页,客户端不要拿一页结果自己 filter ——
分页之后一页里可能一条都不命中,列表会看着像空的。
## 出参 ## 出参
响应 `200``{ items: ComparisonRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定) 响应 `200``{ items: ComparisonRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
@@ -44,9 +38,6 @@
| `items` | object[] | 下单菜品 `{name, qty, specs?}` | | `items` | object[] | 下单菜品 `{name, qty, specs?}` |
| `comparison_results` | object[] | 逐平台对比(price 单位元,已按 rank 升序) | | `comparison_results` | object[] | 逐平台对比(price 单位元,已按 rank 升序) |
| `skipped_dish_names` | string[] | 被跳过的菜名 | | `skipped_dish_names` | string[] | 被跳过的菜名 |
| `ordered` | bool | 「已下单」店级标记:店名命中本人 `source='compare'` 的下单记录即 `true`。**瞬态字段,不在表里**,每次查询现算 |
| `ad_coins_earned` | int | 本次比价看信息流广告实发的金币(按 `trace_id` 聚合)。同为瞬态字段 |
| `trace_url` | string \| null | pricebot 调试链接。未开 `debug_trace_enabled` 且未带 `include_trace=true` 时为 `null` |
| `created_at` | datetime | 时间 | | `created_at` | datetime | 时间 |
## 错误 ## 错误
-204
View File
@@ -222,210 +222,6 @@ def test_stats_compare_count_and_saved(client) -> None:
assert s2["compare_count"] == 2 # 仍 2(failed 不计) assert s2["compare_count"] == 2 # 仍 2(failed 不计)
def test_records_ordered_flag(client) -> None:
"""「已下单」店级标记:店名命中该用户 source='compare' 的下单记录才 True。
覆盖 list_records 只按**本页店名**反查 savings 的写法(原来是把该用户全部下单店名捞回内存
再取交集,随下单量线性变慢)两种写法结果必须一致,故这里按店名逐条断言
"""
token = _login(client, "13800002010")
# 两条比价记录:一条海底捞(稍后会有对应下单),一条没下过单的店
client.post("/api/v1/compare/record", json=_food_payload("ord-1"), headers=_auth(token))
other = _food_payload("ord-2")
other["store_name"] = "没下过单的店"
client.post("/api/v1/compare/record", json=other, headers=_auth(token))
# 下单前:两条都不该带「已下单」
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
assert {it["store_name"]: it["ordered"] for it in items} == {
"海底捞(朝阳店)": False,
"没下过单的店": False,
}
# 对海底捞真实下单一笔(order/report 写 source='compare' 的 savings_record)
r = client.post(
"/api/v1/order/report",
json={
"client_event_id": "evt-ordered-flag",
"platform": "美团",
"platform_package": "com.sankuai.meituan",
"pay_channel": "wechat",
"compared_price_cents": 12350,
"paid_amount_cents": 12350,
"shop_name": "海底捞(朝阳店)",
"original_price_cents": 12850,
},
headers=_auth(token),
)
assert r.status_code == 200, r.text
# 下单后:只有同店名那条翻成 True,另一条不受影响
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
assert {it["store_name"]: it["ordered"] for it in items} == {
"海底捞(朝阳店)": True,
"没下过单的店": False,
}
# 别人的下单不该影响本人标记(_ordered_shop_names 按 user_id 过滤)
token_b = _login(client, "13800002011")
client.post("/api/v1/compare/record", json=_food_payload("ord-b"), headers=_auth(token_b))
items_b = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"]
assert [it["ordered"] for it in items_b] == [False]
def test_records_list_omits_raw_payload(client) -> None:
"""列表出参不含 raw_payload(仓库层 defer 掉了重型 JSON 列);详情接口照常返回。
defer 的列一旦被 ORM 实例读到会触发逐行懒加载(N+1),而列表 schema 本就不该带 raw_payload
这条同时守住列表不泄露上报体全量没人不小心把它加回出参
"""
token = _login(client, "13800002012")
rid = client.post(
"/api/v1/compare/record", json=_food_payload("no-raw"), headers=_auth(token)
).json()["id"]
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
assert len(items) == 1
assert "raw_payload" not in items[0]
# 概要字段照常齐全(defer 没误伤列表要用的列)
assert items[0]["store_name"] == "海底捞(朝阳店)"
assert items[0]["best_platform_id"] == "meituan"
assert items[0]["comparison_results"] and items[0]["items"]
# 详情不 defer:raw_payload 全量还在
d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json()
assert d["raw_payload"]["trace_id"] == "no-raw"
def test_records_ordered_filter(client) -> None:
"""ordered=true 只出「已下单」的记录,且过滤结果自身能翻页。
这个筛选必须在服务端做:客户端早先是对已经拉回来的那一页 filter,分页之后一页里
很可能一条已下单都没有 已下单tab 就会看着像空的,得手动翻很多页才蹦出一条
"""
token = _login(client, "13800002013")
# 3 条「下过单的店」+ 2 条没下过单的店,交错写入,确保过滤不是靠顺序碰巧对上
for i in range(3):
p = _food_payload(f"of-ordered-{i}")
p["store_name"] = "下过单的店"
client.post("/api/v1/compare/record", json=p, headers=_auth(token))
if i < 2:
q = _food_payload(f"of-plain-{i}")
q["store_name"] = "没下过单的店"
client.post("/api/v1/compare/record", json=q, headers=_auth(token))
client.post(
"/api/v1/order/report",
json={
"client_event_id": "evt-ordered-filter",
"platform": "美团",
"platform_package": "com.sankuai.meituan",
"pay_channel": "wechat",
"compared_price_cents": 12350,
"paid_amount_cents": 12350,
"shop_name": "下过单的店",
"original_price_cents": 12850,
},
headers=_auth(token),
)
# 不传 ordered:5 条全出(「全部记录」tab 口径不变)
assert len(client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]) == 5
# ordered=true:只出那 3 条,且每条都自带 ordered=True
page = client.get("/api/v1/compare/records?ordered=true", headers=_auth(token)).json()
assert [it["store_name"] for it in page["items"]] == ["下过单的店"] * 3
assert all(it["ordered"] for it in page["items"])
assert page["next_cursor"] is None
# 游标只在「已下单」集合内走 —— 不会把没下单的记录算进一页的 limit 里
p1 = client.get(
"/api/v1/compare/records?ordered=true&limit=2", headers=_auth(token)
).json()
assert len(p1["items"]) == 2
assert p1["next_cursor"] is not None
p2 = client.get(
f"/api/v1/compare/records?ordered=true&limit=2&cursor={p1['next_cursor']}",
headers=_auth(token),
).json()
assert [it["store_name"] for it in p2["items"]] == ["下过单的店"]
# 两页不重叠,合起来正好 3 条
assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3
# 别人的下单不该让本人记录进「已下单」
token_b = _login(client, "13800002014")
pb = _food_payload("of-b")
pb["store_name"] = "下过单的店"
client.post("/api/v1/compare/record", json=pb, headers=_auth(token_b))
assert client.get(
"/api/v1/compare/records?ordered=true", headers=_auth(token_b)
).json()["items"] == []
def test_records_keyword_search(client) -> None:
"""keyword 按店名 / 菜名模糊搜(忽略大小写),LIKE 通配符只当字面量;搜索结果也能翻页。
菜名走写路径派生的 product_names 文本列 items JSON,SQLite 下中文被 ensure_ascii
转义,直接 LIKE 搜不到
"""
token = _login(client, "13800002015")
b = _food_payload("kw-b")
b["store_name"] = "Pizza Hut"
b["items"] = [{"name": "榴莲比萨", "qty": 1}]
client.post("/api/v1/compare/record", json=b, headers=_auth(token))
c = _food_payload("kw-c")
c["store_name"] = "100%纯牛肉汉堡"
c["items"] = [{"name": "双层牛肉堡", "qty": 1}]
client.post("/api/v1/compare/record", json=c, headers=_auth(token))
# 默认 payload 的店名是「海底捞(朝阳店)」
client.post("/api/v1/compare/record", json=_food_payload("kw-a"), headers=_auth(token))
def _search(kw: str, **extra) -> list[str]:
r = client.get(
"/api/v1/compare/records",
params={"keyword": kw, **extra},
headers=_auth(token),
)
assert r.status_code == 200, r.text
return [it["store_name"] for it in r.json()["items"]]
assert _search("海底捞") == ["海底捞(朝阳店)"] # 店名命中
assert _search("榴莲") == ["Pizza Hut"] # 菜名命中(product_names)
assert _search("pizza") == ["Pizza Hut"] # 忽略大小写
assert _search("PIZZA") == ["Pizza Hut"]
assert _search("不存在的店") == [] # 没命中就是空
# 通配符只当普通字符:搜 % 不该把整表拉回来,搜 _ 也不该匹配任意单字符
assert _search("%") == ["100%纯牛肉汉堡"]
assert _search("_") == []
# 纯空白等同不传 → 不过滤
assert len(_search(" ")) == 3
# 搜索结果自身可翻页
for i in range(3):
p = _food_payload(f"kw-page-{i}")
p["store_name"] = "连锁烤鱼店"
client.post("/api/v1/compare/record", json=p, headers=_auth(token))
p1 = client.get(
"/api/v1/compare/records",
params={"keyword": "烤鱼", "limit": 2},
headers=_auth(token),
).json()
assert len(p1["items"]) == 2
assert p1["next_cursor"] is not None
p2 = client.get(
"/api/v1/compare/records",
params={"keyword": "烤鱼", "limit": 2, "cursor": p1["next_cursor"]},
headers=_auth(token),
).json()
assert [it["store_name"] for it in p2["items"]] == ["连锁烤鱼店"]
assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3
def test_requires_auth(client) -> None: def test_requires_auth(client) -> None:
"""不带 token 统一 401。""" """不带 token 统一 401。"""
assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401 assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401
+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()