Compare commits

..

10 Commits

Author SHA1 Message Date
guke a97faeb179 Merge branch 'main' into feat/admin-comparison-outcome-display 2026-08-04 18:45:31 +08:00
guke f7f5a36250 docs: 补 admin 记录页技术完成率口径说明
admin 比价页/概览/大盘把外部缺失(店/菜/打烊/不配送/不支持/未满起送)记为成功、
仅纯技术故障算失败,刻意宽于 C 端;故 admin 成功率≠C端口径,非 bug。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:50:44 +08:00
guke 8498144703 feat(admin): 比价记录「状态」筛选对齐 admin 口径 + 清理 #209 死常量
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:39:16 +08:00
guke bc2ed5de56 feat(compare): 新增只读当日比价额度查询 GET /compare/quota (#216)
## 概述
新增只读接口 `GET /api/v1/compare/quota`,供客户端「跳外卖 App」型比价入口在点击时**前置查询当日比价是否已达上限**(100 次/日),超限就地提示、不进入比价流程。

配套客户端 PR:比价/领券异常提示统一 + 4 入口上限拦截(shaguabijia-app-android 同名分支)。

## 改动
- `app/repositories/comparison.py`:新增只读 `get_daily_compare_used(db, user_id, reset_at)` —— 按 user_id + 北京时间自然日 COUNT,**窗口计算逐行复刻写路径 `reserve_daily_start`**,保证前置查询与真发起的 429 gate 口径不漂移。
- `app/schemas/compare_record.py`:新增 `CompareQuotaOut(exhausted, used, limit)`。
- `app/api/v1/compare_record.py`:新增 `GET /quota` 端点,硬鉴权 `CurrentUser`、只读不预占;`limit_policy.resolve` 传 `phone + device_id`(与 `/compare/start` 一致,命中 device 白名单)。

## 测试
- `pytest tests/test_compare_daily_limit.py`:**8 passed**(4 既有 + 4 新增,含 device 白名单 parity 测试,锁定 `/quota` 与 `/start` 口径一致)。

## 合并 / 部署注意 ⚠️
- 本 PR 应**先于客户端 PR 合并 + 部署**(客户端点击前置拦截依赖此接口;未部署时客户端 fail-open 放行)。
- 只读、无副作用、不改写路径逻辑,风险低。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #216
2026-08-04 16:38:46 +08:00
guke b703bec7e4 feat(admin): 数据大盘比价成功率改按 admin 口径(对齐概览)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:33:26 +08:00
guke abd6329035 feat(admin): 概览成功率/耗时统计改按 admin 口径(外部缺失记为成功)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:21:24 +08:00
guke 5a66c302cb fix(admin): 修 queries import 排序 + 补比价详情 admin 字段断言
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:01:24 +08:00
guke 09b9381d03 feat(admin): 比价记录列表/详情下发 admin_status + outcome_hint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:48:55 +08:00
guke 7419f35f4b fix(admin): 口径模块 SQL 侧 nullif 对齐空串,消除 Python/SQL 分歧
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:44:58 +08:00
guke 9de73152ec feat(admin): 比价记录展示口径共享模块(外部缺失判为成功)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:35:32 +08:00
12 changed files with 652 additions and 123 deletions
@@ -0,0 +1,61 @@
"""admin 比价记录展示口径:把「流程跑完、外部原因致结果缺失」的记录判为成功。
#209 / C 端落库把 store_not_found / items_not_found / store_closed / no_delivery /
unsupported 归一化成 status='failed';admin 排查视角改按记录级原始业务结局
raw_payload.record_status)重判——这些「外部缺失」算成功(附缺失提示),
只有纯技术故障才是失败。仅 admin 用,不碰 C 端 / #209 落库。
"""
from __future__ import annotations
from sqlalchemy import func
from app.models.comparison import ComparisonRecord
# 记录级原始业务结局里算「成功(流程跑完)」的集合;其余(failed / 未知)才是技术故障。
ADMIN_SUCCESS_OUTCOMES = frozenset({
"success", "below_minimum", "store_closed",
"store_not_found", "items_not_found", "no_delivery", "unsupported",
})
# 有缺失的成功 → 感叹号 hover 提示;success 本身无提示。
OUTCOME_HINTS = {
"below_minimum": "未满起送",
"store_closed": "门店打烊",
"store_not_found": "未找到店",
"items_not_found": "未找到菜",
"no_delivery": "单点不配送",
"unsupported": "平台·场景不支持",
}
def derive_admin_outcome(raw_payload: dict | None, status: str) -> tuple[str, str | None]:
"""(admin_status, outcome_hint)。列表 Python 层派生(raw_payload 已随 ORM 加载)。"""
if status in ("cancelled", "running"):
return status, None
raw = raw_payload or {}
# 原始结局:优先 raw.record_status,其次 raw.status,兜底 status 列
# (兼容迁移未覆盖、细分值残留在 status 列的老记录;与下方 SQL 口径一致)。
original = raw.get("record_status") or raw.get("status") or status
if original in ADMIN_SUCCESS_OUTCOMES:
return "success", OUTCOME_HINTS.get(original)
return "failed", None
def _original_expr():
"""SQL:原始结局 = coalesce(nullif(raw.record_status,''), nullif(raw.status,''), status 列)。
nullif('') 让空串与 Python `or` 口径一致地跳过(as_string 跨方言,#209 迁移已验证)。"""
return func.coalesce(
func.nullif(ComparisonRecord.raw_payload["record_status"].as_string(), ""),
func.nullif(ComparisonRecord.raw_payload["status"].as_string(), ""),
ComparisonRecord.status,
)
def admin_success_sql():
"""SQL 层 admin 成功判定(概览 / 大盘的 case / where 共用)。
排除 cancelled / running(生命周期态,不看结局);其余按原始结局 ∈ S。
coalesce 兜底 status 列 → original 永非 NULL、且兼容 status 列残留的细分值。
"""
return ComparisonRecord.status.notin_(("cancelled", "running")) & _original_expr().in_(
tuple(ADMIN_SUCCESS_OUTCOMES)
)
+37 -54
View File
@@ -5,13 +5,14 @@
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from datetime import UTC, date, datetime, time, timedelta
from decimal import ROUND_HALF_UP, Decimal
from zoneinfo import ZoneInfo
from sqlalchemy import Select, and_, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome
from app.core import rewards
from app.core.config import settings
from app.models.ad_ecpm import AdEcpmRecord
@@ -39,33 +40,15 @@ from app.repositories import activity, ad_ecpm
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
# comparison_record historically persisted a few granular business outcomes as
# top-level statuses. Admin filters and metrics expose lifecycle buckets while
# retaining the raw values until the data migration has run everywhere.
_COMPARISON_STATUS_ALIASES = {
"success": ("success", "below_minimum"),
"failed": (
"failed",
"store_closed",
"store_not_found",
"items_not_found",
"no_delivery",
"unsupported",
),
"cancelled": ("cancelled",),
"running": ("running",),
}
_COMPARISON_SUCCESS_STATUSES = _COMPARISON_STATUS_ALIASES["success"]
_COMPARISON_FAILED_STATUSES = _COMPARISON_STATUS_ALIASES["failed"]
_COMPARISON_COMPLETED_STATUSES = (
*_COMPARISON_SUCCESS_STATUSES,
*_COMPARISON_FAILED_STATUSES,
)
def _comparison_status_condition(status: str):
values = _COMPARISON_STATUS_ALIASES.get(status, (status,))
return ComparisonRecord.status.in_(values)
"""列表/概览「状态」筛选:success/failed 按 admin 口径(与显示/统计一致);
cancelled/running 按 status 列生命周期。"""
if status == "success":
return admin_success_sql()
if status == "failed":
return ~admin_success_sql() & ComparisonRecord.status.notin_(("cancelled", "running"))
return ComparisonRecord.status == status
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
_FEED_SCENE_LABEL = {
@@ -371,10 +354,10 @@ def _comparison_conditions(
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)
start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(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)
end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(UTC)
conditions.append(ComparisonRecord.created_at < end_utc)
return conditions
@@ -414,6 +397,7 @@ def list_comparison_records(
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
for it in items:
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
it.admin_status, it.outcome_hint = derive_admin_outcome(it.raw_payload, it.status)
return items, next_cursor, total
@@ -437,8 +421,8 @@ def _round_duration_ms(value) -> int | None:
return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]):
"""PostgreSQL 耗时聚合语句;每种状态只返回一行。"""
def _comparison_duration_aggregate_stmt(conditions: list, status_filter, quantiles: tuple[float, ...]):
"""PostgreSQL 耗时聚合语句;status_filter 为已构造的口径条件表达式,每口径只返回一行。"""
return select(
func.avg(ComparisonRecord.total_ms),
*(
@@ -447,7 +431,7 @@ def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles
),
).where(
*conditions,
_comparison_status_condition(status),
status_filter,
ComparisonRecord.total_ms.is_not(None),
)
@@ -456,13 +440,13 @@ def _comparison_duration_aggregates(
db: Session,
*,
conditions: list,
status: str,
status_filter,
quantiles: tuple[float, ...],
) -> list[int | None]:
"""返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。"""
if db.bind is not None and db.bind.dialect.name == "postgresql":
row = db.execute(
_comparison_duration_aggregate_stmt(conditions, status, quantiles)
_comparison_duration_aggregate_stmt(conditions, status_filter, quantiles)
).one()
return [_round_duration_ms(value) for value in row]
@@ -472,7 +456,7 @@ def _comparison_duration_aggregates(
select(ComparisonRecord.total_ms)
.where(
*conditions,
_comparison_status_condition(status),
status_filter,
ComparisonRecord.total_ms.is_not(None),
)
.order_by(ComparisonRecord.total_ms)
@@ -499,16 +483,14 @@ def comparison_records_summary(
user_id=user_id, phone=phone, status=status, business_type=business_type,
store=store, product=product, date_from=date_from, date_to=date_to,
)
_success = admin_success_sql()
row = db.execute(
select(
func.count(ComparisonRecord.id),
func.sum(case((ComparisonRecord.status.in_(_COMPARISON_COMPLETED_STATUSES), 1), else_=0)),
func.sum(case((ComparisonRecord.status.in_(_COMPARISON_SUCCESS_STATUSES), 1), else_=0)),
func.sum(case((_success | (ComparisonRecord.status == "failed"), 1), else_=0)),
func.sum(case((_success, 1), else_=0)),
func.avg(ComparisonRecord.llm_cost_yuan),
func.sum(case((
ComparisonRecord.status.in_(_COMPARISON_SUCCESS_STATUSES)
& (ComparisonRecord.saved_amount_cents > 0), 1
), else_=0)),
func.sum(case((_success & (ComparisonRecord.saved_amount_cents > 0), 1), else_=0)),
func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)),
).where(*conditions)
).one()
@@ -520,13 +502,13 @@ def comparison_records_summary(
success_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status="success",
status_filter=admin_success_sql(),
quantiles=(0.05, 0.5, 0.95, 0.99),
)
cancelled_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status="cancelled",
status_filter=(ComparisonRecord.status == "cancelled"),
quantiles=(0.05, 0.5, 0.95),
)
success_rate_denominator = started - cancelled
@@ -551,12 +533,13 @@ def comparison_records_summary(
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname + admin 口径瞬态)。"""
rec = db.get(ComparisonRecord, record_id)
if rec is not None:
_attach_user_info(db, [rec])
_attach_comparison_order_status(db, [rec])
_attach_comparison_device_details([rec])
rec.admin_status, rec.outcome_hint = derive_admin_outcome(rec.raw_payload, rec.status)
return rec
@@ -585,8 +568,8 @@ def _heartbeat_seconds_ago(last: datetime | None) -> int | None:
if last is None:
return None
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return int((datetime.now(timezone.utc) - last).total_seconds())
last = last.replace(tzinfo=UTC)
return int((datetime.now(UTC) - last).total_seconds())
def _device_model_from_id(device_id: str) -> str:
@@ -639,7 +622,7 @@ def _attach_device_user_info(db: Session, devices: list[DeviceLiveness]) -> None
def _liveness_cutoff() -> datetime:
"""掉线判定分界:此刻 - HEARTBEAT_TIMEOUT_MINUTES。心跳早于它 = 掉线(同 list_overdue 口径)。"""
timeout_min = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
return datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
return datetime.now(UTC) - timedelta(minutes=timeout_min)
def list_device_liveness(
@@ -815,11 +798,11 @@ def list_all_withdraw_orders(
stmt = stmt.where(date_col <= _as_utc(date_to))
# tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
.astimezone(UTC)
)
if quick_filter == "abnormal":
stmt = stmt.where(
@@ -866,8 +849,8 @@ def _as_utc(value: datetime) -> datetime:
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
无时区入参按 UTC 解释。"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def withdraw_list_enrichment(
@@ -1032,7 +1015,7 @@ def withdraw_summary(db: Session, *, source: str | None = None) -> dict:
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
.astimezone(UTC)
)
def _today_count(status: str) -> int:
@@ -1207,8 +1190,8 @@ def withdraw_risk_flags(
if user and user.status != "active":
flags.append(f"账号状态:{user.status}")
if user and user.created_at:
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
created_at = user.created_at.replace(tzinfo=UTC) if user.created_at.tzinfo is None else user.created_at
if datetime.now(UTC) - created_at < timedelta(hours=24):
flags.append("新注册用户")
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
@@ -1419,7 +1402,7 @@ def _cn_wall_to_utc(dt: datetime) -> datetime:
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(UTC).replace(tzinfo=None)
def _coin_record_sort_key(row: dict) -> datetime:
+4 -2
View File
@@ -12,6 +12,7 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.comparison_outcome import admin_success_sql
from app.admin.repositories.coupon_data import _percentile
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
@@ -257,13 +258,14 @@ def dashboard_overview(
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
_period_success = admin_success_sql()
period_comparison_stats = db.execute(
select(
func.count(ComparisonRecord.id),
func.coalesce(
func.sum(
case(
(ComparisonRecord.status.in_(("success", "failed")), 1),
(_period_success | (ComparisonRecord.status == "failed"), 1),
else_=0,
)
),
@@ -276,7 +278,7 @@ def dashboard_overview(
0,
),
func.coalesce(
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
func.sum(case((_period_success, 1), else_=0)),
0,
),
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
+4
View File
@@ -22,6 +22,10 @@ class AdminComparisonListItem(BaseModel):
# admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled)
trace_url: str | None = None
status: str # success / failed / cancelled / running;旧细分值由前端兼容映射
# admin 展示口径(见 repositories/comparison_outcome):成功含「跑完但外部缺失」,
# 纯技术故障才 failed;outcome_hint 非空=有缺失,前端标感叹号。
admin_status: str = "success"
outcome_hint: str | None = None
information: str | None = None
store_name: str | None = None
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
+20
View File
@@ -21,6 +21,7 @@ from app.core.trace_ids import new_trace_id
from app.repositories import comparison as crud_compare
from app.repositories import risk as risk_repo
from app.schemas.compare_record import (
CompareQuotaOut,
CompareStartReserveIn,
CompareStartReserveOut,
CompareStatsOut,
@@ -155,6 +156,25 @@ def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut:
return CompareStatsOut(compare_count=count, discovered_saved_cents=saved)
@router.get(
"/quota",
response_model=CompareQuotaOut,
summary="查询今天的比价次数配额(只读,不预占)",
)
def get_compare_quota(
user: CurrentUser,
db: DbSession,
device_id: str | None = Query(default=None),
) -> CompareQuotaOut:
"""按登录用户查今日比价配额,口径与 /compare/start 同源。
used 按 user_id 计数;limit/reset_at 按 phone+device 解析(与 /start 一致,device 白名单能命中)。
exhausted=true → 已达今日上限。客户端①④入口点击时前置查此,超限就地 toast 不跳转。"""
policy = limit_policy.resolve(db, "compare.start.daily", phone=user.phone, device=device_id)
used = crud_compare.get_daily_compare_used(db, user.id, reset_at=policy.reset_at)
exhausted = policy.limit is not None and used >= policy.limit
return CompareQuotaOut(exhausted=exhausted, used=used, limit=policy.limit)
@router.get(
"/records",
response_model=ComparisonRecordPage,
+27
View File
@@ -548,6 +548,33 @@ def reserve_daily_start(
return rec, int(used) + 1
def get_daily_compare_used(
db: Session,
user_id: int,
reset_at: datetime | None = None,
) -> int:
"""今日(北京时间自然日)该用户已发起的比价次数。只读,不改任何数据。
口径必须与 reserve_daily_start 完全一致(同 day_start/day_end/reset_at)。"""
current = datetime.now(CN_TZ)
if current.tzinfo is not None:
current = current.astimezone(CN_TZ).replace(tzinfo=None)
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
if reset_at is not None:
reset_start = reset_at
if reset_start.tzinfo is not None:
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
day_start = max(day_start, reset_start)
day_end = day_start + timedelta(days=1)
used = db.scalar(
select(func.count(ComparisonRecord.id)).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.created_at >= day_start,
ComparisonRecord.created_at < day_end,
)
) or 0
return int(used)
def harvest_running(
db: Session,
*,
+8
View File
@@ -260,3 +260,11 @@ class MilestoneClaimResultOut(BaseModel):
milestone: int = Field(..., description="本次领取的档位序号")
coin_awarded: int = Field(..., description="本次发放金币")
coin_balance: int = Field(..., description="领奖后金币余额")
class CompareQuotaOut(BaseModel):
"""今天的比价次数配额状态(只读)。"""
exhausted: bool = Field(..., description="是否已达今日上限,无法再比价")
used: int = Field(..., description="今天已用次数")
limit: int | None = Field(..., description="今天的配额上限(None=无限制)")
@@ -0,0 +1,156 @@
# 比价结果卡片 · 状态口径与交互参考
> **定位**:把「比价结果页每张平台卡片」的 11 种展示分类,逐一映射到后端状态与判定字段,供 **App 端做互动/文案调整时参考**。
> **适用**:外卖比价结果页(`CompResultScreen`)。
> **数据源**:基于三仓代码梳理(pricebot 判定 → app-server 透传/落库 → android 映射/渲染)。
> **整理日期**:2026-07-31。代码行号会漂移,改动后以实际代码为准。
---
## 0. 快速须知(三个容易踩的坑)
1. **`ok` 不是 `success`**:逐平台卡片的成功态字符串是 **`ok`**(端侧 `rowToUi``row.status == "ok"`);`success/failed/cancelled/running` 是**记录级** `ComparisonRecord.status`(admin 后台用)。两层状态串不同,别混。
2. **未知状态一律兜底成 `Failed`#9)**:端侧映射用 `else -> CompareResultStatus.Failed`,所以后端 `unsupported``failed`、以及**任何端侧没显式处理的新状态**都会显示成 #9。pricebot 以后加状态,端侧不同步就会「消失」进 #9
3. **状态判定分三层来源**(见 §2):不是所有卡都由后端 status 决定,#10/#11 完全是端侧本地判定,后端零感知。
---
## 1. 主对照表:11 类卡片 ↔ 后端状态
映射函数:`CompareProgressRepository.kt` — 新路径 `rowToUi:339-375`、老路径 `summaryToUi:509-544`、端侧补齐 `onComparisonResultsReady:266-315`
| # | 分类 | Android 枚举 | 后端逐平台 `status` | `Found` 内细分依据 | 状态来源 | 当前交互 |
|---|---|---|---|---|---|---|
| 1 | 全网最低赢家 | `Found` | `ok`(有价) | `is_best && !has_dish_diff` → isLowest | pricebot | 去购买(黄) |
| 2 | 没有可用优惠(原选择) | `Found` | `ok` | `role=source`/is_user_original + 无可用优惠 | pricebot | 查看(灰) |
| 3 | 其他成功 | `Found` | `ok` | `role=target`,非 best | pricebot | 查看(灰) |
| 4 | 少菜 / 部分缺 | `Found` + 提示条 | `ok` | `skipped_dish_names` 非空 / `skipped_dish_count>0` | pricebot | 随主卡 |
| 5 | 未满起送 | `BelowMinimum` | `below_minimum` | — | pricebot | 查看(灰) |
| 6 | 门店打烊 | `StoreClosed` | `store_closed` | — | pricebot | 查看(灰) |
| 7 | 没有您点的商品 | `ItemsNotFound` | `items_not_found` | — | pricebot | 查看(灰) |
| 8 | 无对应商家 | `StoreNotFound` | `store_not_found` | — | pricebot **或端侧补齐** | 查看(灰) |
| 9 | 比价失败兜底 | `Failed` | `failed`/`unsupported`/**任何未知值**`else` | — | pricebot **或端侧**(整场没跑成) | 无 |
| 10 | 未安装 | `NotInstalled` | **无**(后端零感知) | 端侧:该平台没装(`getPackageInfo` | **端侧本地** | 去安装 |
| 11 | 未选择 / 本次未比 | `NotComparedThisTime` | **无**(后端零感知) | 端侧:`selectedPlatformIds` 没勾这家 | **端侧本地** | 无(设计要「重试」,未接) |
| 表外 | 单点不配送 | `NoDelivery` | `no_delivery` | — | pricebot | 查看(灰) |
> **表外提醒**`no_delivery`(单点不配送/需搭配主食)在代码里是独立状态(复用未起送灰框样式),但**不在原 11 类分类表内**——做分类时需给它安个位置或明确并入 #5/#7。
---
## 2. 状态来源三分层(做互动最该记住的)
**A. pricebot 逐平台 `status` 直接决定(5/6/7/8/9 + no_delivery**
端侧只做「字符串 → 枚举」映射,互动依附后端判定。
**B. `ok` 成功态再靠 `platforms[]` 字段细分(1/2/3/4**
同一个 `Found`,靠 `is_best``has_dish_diff``role``skipped_dish_names` 分出四种。这些字段都是 pricebot 在 `platforms[]` 下发的。
**C. 纯端侧本地判定,后端完全无感知(10/11,以及 8 的补齐分支)**
依据:**装机态**`getPackageInfo`+ **勾选态**`selectedPlatformIds`+ **整场是否跑成**`failureReason`)。
`onComparisonResultsReady:266-315`:走了选平台弹窗没勾 → #11;装了 App 没找到店 → #8;都没装 → #10`marketPackage` 指向子 App 包名,引导下载子 App)。
---
## 3. 菜品差异口径(有相似菜品 / 少菜 / 规格近似)
「有相似菜品」**不是独立卡**,而是 `status=Found`(有价)成功卡上、因「菜品对不齐」触发的黄色提醒模块 `MismatchBox`
### 3.1 开关:`hasDishDiff`
定义在 `CompareMockResults.kt:101-109``status=Found` 且满足**任一**即为 true
- `items` 里有 `similar=true` 的菜(近似替代)
- `approxDishNames` 非空(规格近似)
- `skippedDishNames` 非空(完全缺失)
- `skippedDishCount > 0`(只有数量)
**权威值优先取后端** `platforms[].has_dish_diff`(端侧 `dishDiffOverride``CompareMockResults.kt:91-93`),老路径才端侧本地算。与后端 `is_best` 竞选同判据。
### 3.2 `MismatchBox` 的三种行(`CompRowCard.kt:236-250` 触发 / `460-516` 渲染)
| 展示文案 | 触发字段 | 语义 |
|---|---|---|
| **本平台没有「orig」 / 已换成近似「name」** | `items``similar=true && orig!=null`similarPairs | **已发生近似替换**(有 orig→name 替换对) |
| 规格近似「X」,请下单前核对 | `approxDishNames` | 已近似,但提示核对 |
| 本平台没有「X」,也无相似菜 | `skippedDishNames` | 完全缺失、无替代 |
| 本平台缺少 N 个菜品,已按可购买商品计价 | `skippedDishCount>0` 且无菜名 | 只有数量时的兜底 |
> **易混近亲**`complexSpecDishNames``CompareMockResults.kt:86-88`)走的是**另一个浅黄条** `ComplexSpecNotice`,文案「规格较复杂,请核对」,语义是"需人工核对规格、**不改价格有效性**",与"已换成近似"不是一回事。
### 3.3 连锁后果(为什么降级成"查看")
`has_dish_diff=true``isLowest = is_best && !has_dish_diff` 必为 false → **踢出「全网最低」评定**,排到"仅供参考"分组,不给绶带/省¥红章/去购买黄按钮,只能灰"查看"。
### 3.4 边界
- **vs #4「完全缺失」**:同一个 `MismatchBox` 的不同行,**可并存**(一个平台既有近似替换又有完全缺失)。它们是同一张成功卡里分段列出,不是两张卡。
- **vs #7「该店没有您点的商品(items_not_found)」**:分水岭是**有没有比出价**。能凑出单、有价 → `Found` + MismatchBox;整单找不到菜、无价 → `items_not_found` 灰框无价卡。
- **vs #1 赢家**:互斥。
---
## 4. 交互调整参考要点
1. **文案/提示权已在前端**:卡片副文案由端侧 `fullReasonForStatus(status)` 按状态本地生成(`CompareProgressRepository.kt:51-59`,注释明写"标题/文案决策收回前端")。后端逐平台原因字段 `notFoundReason`= `platform_results[].reason`**线上常空**,端侧兜底。→ **改提示文案、加互动引导、加动效,纯端侧就能做,不用改 pricebot。**
2. **可空字段决定互动形态**`price` 可空(6/7/8/9/10/11 无价 → `¥??` 或隐藏);`store_name` 可空(退化平台名)。`LOCATED_STATUSES``CompareProgressRepository.kt:46-47``ok/below_minimum/no_delivery/store_closed/items_not_found`)决定 header 用店名还是平台名。做点击/跳转互动前要判空。
3. **#10/#11 端侧完全自主**:引导安装、引导勾选、重试这类互动无需后端配合,随便调。
4. **区分"真没店"vs"没跑成"**`failureReason` 非空(网络/上游 502)时,补齐卡统一兜底文案而非"未找到店"(`CompareProgressRepository.kt:104-107`,避免谎报)。"重试整场"的互动应挂这个场景,而不是 #8
5. **带动态数字的文案要后端补字段**:如"还差 ¥8 起送"——`platforms[]` 目前不下发起送差额,想要这种提示得让 pricebot 补字段。
6. **#4 少菜 / 有相似菜品是成功卡上的模块,不是独立卡**:它们的互动依附主卡(1/2/3)。
---
## 5. 关键代码位置索引
### Android`shaguabijia-app-android`
| 关注点 | 文件:行 |
|---|---|
| 后端 status → 枚举映射(新/老路径) | `CompareProgressRepository.kt:339-375` / `509-544` |
| 端侧文案本地生成 | `CompareProgressRepository.kt:51-59``fullReasonForStatus` |
| header 店名/平台名切换 | `CompareProgressRepository.kt:46-47``LOCATED_STATUSES` |
| 端侧补齐 #8/#10/#11(装机+勾选+failureReason | `CompareProgressRepository.kt:266-315` |
| 卡片 when 主分支 | `CompRowCard.kt:83-128` |
| `MismatchBox`(菜品差异条) | `CompRowCard.kt:236-250`(触发)/ `460-516`(渲染) |
| 未安装卡 / 本次未比卡 | `CompRowCard.kt:596-697` / `720-790` |
| 按钮(去购买/查看/重试) | `CompRowCard.kt:1148-1180` / `1210` / `1214`;枚举 `GrayCardCta:132` |
| 数据模型 `CompareResult` / `hasDishDiff` / `CompareDish` | `CompareMockResults.kt:34-110` / `101-109` / `116` |
| 网络 DTO 解析 | `Protocol.kt:530-549` / `576-665` |
| 生产结果页 / VM | `CompResultScreen.kt` / `CompResultViewModel.kt` |
### 后端(`shaguabijia-app-server`
| 关注点 | 文件:行 |
|---|---|
| 逐平台 `status` 枚举 | `app/schemas/compare_record.py:81-85` |
| 记录模型 / `status` / `skipped_dish_names` / `raw_payload` | `app/models/comparison.py:39-169` / `:101` / `:121` / `:123` |
| 落库与状态派生(running/done/abort | `app/repositories/comparison.py``harvest_*``_derive*` |
| `has_dish_diff` 兜底判 is_best | `app/repositories/comparison.py:379` |
| 记录级失败原因派生 | `app/repositories/comparison.py:118-127``_derive_fail_display` |
| admin 读取接口 | `app/admin/routers/comparison.py` / `app/admin/repositories/queries.py` |
---
## 6. 当前实现现状与「目标设计」的差异(待办)
11 类骨架**都已实现、生产页 `CompResultScreen` 真实可达**(非 mock)。但对照原 11 类目标设计,仍有以下差异:
| 项 | 现状 | 差异 / 待办 |
|---|---|---|
| #11「重试」按钮 | `NotComparedCard` 无任何 CTA`GrayCardCta.Retry`+`RetryCta` 定义了但**没挂到任何卡**(死代码) | 唯一**功能级缺口**:要么接上,要么把设计改成"无按钮" |
| 各类文案措辞 | 偏功能陈述(如"该店当前已打烊,暂时无法比价") | 与目标口语+emoji"…打烊休息啦😴…")不一致;改端侧 `fullReasonForStatus` 即可(后端 `reason` 常空、无需动 pricebot |
| #1 优惠区 | 已改**聚合**"已自动帮您应用优惠,共减 ¥X" | 与"优惠 tags(逐项标签)"不同(产品已决策聚合) |
| #4 少菜 | 成功卡上的 `MismatchBox` 提示条 | 非独立卡(按"原生为准"这多为有意) |
| `no_delivery` | 代码有独立状态 | 原 11 类表未含,需补位或并入 |
---
## 7. admin 记录页口径(与 C 端不同,勿混)
admin 比价记录页 / 概览 / 大盘用**技术完成率**口径:`below_minimum / store_closed /
store_not_found / items_not_found / no_delivery / unsupported`(流程跑完、只是外部原因致结果缺失)
**记为成功**(前端标绿「成功」+ 感叹号,hover 显示缺失原因),只有纯技术故障 `failed` 才算失败。
派生见 `app/admin/repositories/comparison_outcome.py`(原始结局取 `raw_payload.record_status`
`admin_success_sql()` 供概览/大盘/列表筛选共用,`derive_admin_outcome()` 供列表逐行下发 `outcome_hint`)。
这**刻意宽于** C 端 / #209 落库口径(那边 not_found 类归 `failed`)——admin 关心「系统有没有跑成」,
C 端关心「有没有省到钱」。故 **admin 成功率 ≠ C 端 / 首页轮播口径**,对不上是设计使然、非 bug。
---
*本文档为跨端口径参考,非契约。字段/行号以三仓实际代码为准。*
+62
View File
@@ -0,0 +1,62 @@
"""admin 展示口径派生单测:记录级原始结局 → (admin_status, outcome_hint)。"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
@pytest.mark.parametrize(
("raw_payload", "status", "expected"),
[
# 真实形态:status 已 normalize,细分在 raw_payload.record_status
({"record_status": "success"}, "success", ("success", None)),
({"record_status": "below_minimum"}, "success", ("success", "未满起送")),
({"record_status": "store_closed"}, "failed", ("success", "门店打烊")),
({"record_status": "store_not_found"}, "failed", ("success", "未找到店")),
({"record_status": "items_not_found"}, "failed", ("success", "未找到菜")),
({"record_status": "no_delivery"}, "failed", ("success", "单点不配送")),
({"record_status": "unsupported"}, "failed", ("success", "平台·场景不支持")),
({"record_status": "failed"}, "failed", ("failed", None)), # 纯技术故障
# 空字符串视作缺失,兜到下一级(与 SQL nullif 对齐)
({"record_status": "", "status": "store_closed"}, "failed", ("success", "门店打烊")),
# POST 路径:细分在 raw_payload.status
({"status": "store_not_found"}, "failed", ("success", "未找到店")),
# 兜底 status 列:raw_payload 缺失(极老记录)或残留细分值
(None, "success", ("success", None)),
(None, "failed", ("failed", None)),
(None, "store_closed", ("success", "门店打烊")), # 迁移未覆盖的残留
# 生命周期态优先,不看结局
({}, "cancelled", ("cancelled", None)),
({"record_status": "success"}, "running", ("running", None)),
],
)
def test_derive_admin_outcome(raw_payload, status, expected):
assert derive_admin_outcome(raw_payload, status) == expected
def test_admin_success_sql_matches_python_on_empty_string() -> None:
"""record_status 为空串时,SQL 侧(nullif)与 Python 侧(or)都应兜到 status 列结局、判为成功。"""
db = SessionLocal()
try:
rec = ComparisonRecord(
trace_id="outcome-empty-record-status",
status="failed",
raw_payload={"record_status": "", "status": "store_closed"},
)
db.add(rec)
db.flush()
matched = db.execute(
select(ComparisonRecord.id).where(
ComparisonRecord.trace_id == "outcome-empty-record-status",
admin_success_sql(),
)
).scalar_one_or_none()
assert matched is not None # SQL 侧判成功
assert derive_admin_outcome(rec.raw_payload, rec.status) == ("success", "门店打烊") # Python 侧一致
finally:
db.rollback()
db.close()
+69 -12
View File
@@ -82,21 +82,25 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
admin_client: TestClient, admin_token: str
) -> None:
created_at = datetime(2037, 1, 15, 12)
# (trace_id, status 列, record_status, total_ms, cost)
rows = [
("dashboard-aggregate-success", "success", 101, 0.1),
("dashboard-aggregate-failed", "failed", 200, 0.2),
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
("dashboard-aggregate-running", "running", 400, 0.4),
("dashboard-success", "success", "success", 101, 0.1),
("dashboard-below-min", "success", "below_minimum", 120, 0.2),
("dashboard-store-not-found", "failed", "store_not_found", 130, 0.0),
("dashboard-failed", "failed", "failed", 200, 0.3),
("dashboard-cancelled", "cancelled", None, 300, 0.4),
("dashboard-running", "running", None, 400, 0.5),
]
db = SessionLocal()
try:
for trace_id, status, total_ms, llm_cost_yuan in rows:
for trace_id, status, record_status, total_ms, llm_cost_yuan in rows:
db.add(
ComparisonRecord(
trace_id=trace_id,
status=status,
total_ms=total_ms,
llm_cost_yuan=llm_cost_yuan,
raw_payload={"record_status": record_status} if record_status else None,
created_at=created_at,
)
)
@@ -111,14 +115,15 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
)
assert response.status_code == 200, response.text
comparison = response.json()["period"]["comparison"]
assert comparison["total"] == 4
assert comparison["completed"] == 2
assert comparison["total"] == 6
# admin 成功 = success + below_minimum + store_not_found = 3
assert comparison["success"] == 3
# completed = 3 成功 + 1 纯 failed = 4
assert comparison["completed"] == 4
assert comparison["cancelled"] == 1
assert comparison["success"] == 1
assert comparison["success_rate"] == 0.3333
assert comparison["median_duration_ms"] == 151
assert comparison["p95_duration_ms"] == 195
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
# 分母 = total - cancelled = 5
assert comparison["success_rate"] == 0.6
assert comparison["token_cost_total_yuan"] == pytest.approx(1.5)
def test_dashboard_coupon_success_rate_excludes_abandoned_sessions(
@@ -1202,3 +1207,55 @@ def test_period_signin_boost_moves_to_reward_video_without_double_count(
assert coins["reward_video_coin_total"] == 300
assert coins["regular_task_coin_total"] == 50
assert coins["signin_boost_coin_total"] == 200
def test_comparison_records_expose_admin_outcome(
admin_client: TestClient, admin_token: str
) -> None:
"""列表 / 详情下发 admin_status + outcome_hint:外部缺失=成功⚠,纯故障=失败。"""
db = SessionLocal()
try:
user = user_repo.upsert_user_for_login(
db, phone="13800009051", register_channel="sms"
)
# 未找到店:status 已 normalize 成 failed,细分在 raw_payload.record_status
gap_rec = ComparisonRecord(
user_id=user.id, trace_id="admin-outcome-store-not-found",
status="failed", store_name="缺店排查店ZZZ",
raw_payload={"record_status": "store_not_found"},
)
db.add(gap_rec)
# 纯技术故障
db.add(ComparisonRecord(
user_id=user.id, trace_id="admin-outcome-tech-failed",
status="failed", store_name="缺店排查店ZZZ",
raw_payload={"record_status": "failed"},
))
db.commit()
uid = user.id
gap_id = gap_rec.id
finally:
db.close()
resp = admin_client.get(
"/admin/api/comparison-records",
params={"user_id": uid, "store": "缺店排查店ZZZ"},
headers=_auth(admin_token),
)
assert resp.status_code == 200, resp.text
by_trace = {it["trace_id"]: it for it in resp.json()["items"]}
gap = by_trace["admin-outcome-store-not-found"]
assert gap["admin_status"] == "success"
assert gap["outcome_hint"] == "未找到店"
tech = by_trace["admin-outcome-tech-failed"]
assert tech["admin_status"] == "failed"
assert tech["outcome_hint"] is None
detail = admin_client.get(
f"/admin/api/comparison-records/{gap_id}", headers=_auth(admin_token)
)
assert detail.status_code == 200, detail.text
assert detail.json()["admin_status"] == "success"
assert detail.json()["outcome_hint"] == "未找到店"
+125 -1
View File
@@ -1,14 +1,16 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from app.core.limit_policy import MODE_UNLIMITED
from app.core.rewards import CN_TZ
from app.core.security import decode_token
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.limit_policy import LimitPolicyOverride
def _login(client) -> tuple[str, int]:
@@ -145,3 +147,125 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
ComparisonRecord.trace_id == rejected_trace
)
) == 0
def test_compare_quota_fresh_user(client) -> None:
"""新用户今天没有比价记录 → exhausted=False, used=0, limit=100。"""
token, _user_id = _login(client)
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
def test_compare_quota_exhausted(client) -> None:
"""今日已有 100 条记录 → exhausted=True, used=100, limit=100。"""
token, user_id = _login(client)
now = datetime.now(CN_TZ).replace(tzinfo=None)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-exhausted-{user_id}-{i}",
status="failed",
created_at=now,
)
for i in range(100)
]
)
db.commit()
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": True, "used": 100, "limit": 100}
def test_compare_quota_yesterday_rows_not_counted(client) -> None:
"""昨天的记录不计入今日配额 → exhausted=False, used=0。"""
token, user_id = _login(client)
yesterday = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(days=1)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-yesterday-window-{user_id}-{i}",
status="success",
created_at=yesterday,
)
for i in range(100)
]
)
db.commit()
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
def test_compare_quota_device_whitelist_parity(client) -> None:
"""device 白名单下 /quota?device_id=X 与 /start 的 policy 完全一致。
场景:
- 设备 whitelisted-device-001 有 unlimited 覆盖 → /quota?device_id= 应报
exhausted=False, limit=null,哪怕同用户今天已发起 ≥100 次。
- 不带 device_id(或带非白名单设备) → 同用户走全局 100 上限,exhausted=True。
"""
token, user_id = _login(client)
device_id = f"whitelisted-device-{user_id}"
# 种 100 条今日记录:此时不带白名单设备 /quota 应报 exhausted=True
now = datetime.now(CN_TZ).replace(tzinfo=None)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-parity-{user_id}-{i}",
status="failed",
created_at=now,
)
for i in range(100)
]
)
# 种 device 白名单覆盖:unlimited、无失效时间(永久白名单用 expires_at=None)
# 注意:validate_override 要求 unlimited+有 expires_at,但这里直接写 ORM 跳过
# 该验证——测试意图是覆盖"设备白名单已存在"的生产状态,expires_at=None 代表永久。
db.add(
LimitPolicyOverride(
subject_type="device",
subject_value=device_id,
rule_code="compare.start.daily",
mode=MODE_UNLIMITED,
enabled=True,
expires_at=None,
)
)
db.commit()
# 带白名单 device_id → unlimited,不受 100 条记录限制
resp_with_device = client.get(
f"/api/v1/compare/quota?device_id={device_id}",
headers=_headers(token),
)
assert resp_with_device.status_code == 200, resp_with_device.text
body_with = resp_with_device.json()
assert body_with["exhausted"] is False, f"whitelisted device should not be exhausted: {body_with}"
assert body_with["limit"] is None, f"whitelisted device should have null limit: {body_with}"
assert body_with["used"] == 100
# 不带 device_id → 走全局 100 上限,已有 100 条 → exhausted=True
resp_no_device = client.get("/api/v1/compare/quota", headers=_headers(token))
assert resp_no_device.status_code == 200, resp_no_device.text
body_no = resp_no_device.json()
assert body_no["exhausted"] is True, f"without device should be exhausted: {body_no}"
assert body_no["limit"] == 100
assert body_no["used"] == 100
# 带非白名单 device_id → 同样走全局 100 上限
resp_other_device = client.get(
"/api/v1/compare/quota?device_id=unknown-device-xyz",
headers=_headers(token),
)
assert resp_other_device.status_code == 200, resp_other_device.text
body_other = resp_other_device.json()
assert body_other["exhausted"] is True, f"non-whitelisted device should be exhausted: {body_other}"
assert body_other["limit"] == 100
+79 -54
View File
@@ -1,4 +1,4 @@
"""比价记录页后端分页与概览聚合。"""
"""比价记录页后端概览聚合admin 口径:外部缺失记为成功)"""
from __future__ import annotations
from datetime import UTC, date, datetime
@@ -7,13 +7,14 @@ import pytest
from sqlalchemy.dialects import postgresql
from app.admin.repositories import queries
from app.admin.repositories.comparison_outcome import admin_success_sql
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None:
stmt = queries._comparison_duration_aggregate_stmt(
[], "success", (0.05, 0.5, 0.95, 0.99)
[], admin_success_sql(), (0.05, 0.5, 0.95, 0.99)
)
sql = str(
stmt.compile(
@@ -21,82 +22,106 @@ def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None:
compile_kwargs={"literal_binds": True},
)
)
assert sql.count("percentile_cont") == 4
assert "comparison_record.status IN ('success', 'below_minimum')" in sql
# 口径按原始结局 coalesce,而非直接读 status 列
assert "coalesce" in sql.lower()
def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None:
def test_summary_counts_external_gaps_as_success() -> None:
db = SessionLocal()
try:
# (trace_id, status 列[已 normalize], record_status[原始结局], total_ms, cost, saved)
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),
("summary-below-minimum", "below_minimum", 5000, None, 0),
("summary-store-closed", "store_closed", 200_000, None, 0),
("summary-running", "running", 4000, None, 0),
("sum-success", "success", "success", 1000, 1.0, 100),
("sum-below-min", "success", "below_minimum", 2000, 2.0, 0),
("sum-store-closed", "failed", "store_closed", 3000, None, 0),
("sum-store-not-found", "failed", "store_not_found", 4000, None, 0),
("sum-failed", "failed", "failed", 100_000, 3.0, 0),
("sum-cancelled", "cancelled", None, 5000, None, 0),
("sum-running", "running", None, 6000, None, 0),
]
for trace_id, status, total_ms, cost, saved in rows:
for trace_id, status, record_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,
raw_payload={"record_status": record_status} if record_status else None,
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)
)
# admin 成功 = success + below_minimum + store_closed + store_not_found = 4
assert summary["started"] == 7
assert summary["completed"] == 5
assert summary["success"] == 3
assert summary["success_rate"] == pytest.approx(3 / 6)
assert summary["avg_token_cost"] == pytest.approx(2.5)
assert summary["lower_price_rate"] == pytest.approx(1 / 3)
assert summary["avg_duration_ms"] == 3000
assert summary["p5_duration_ms"] == 1200
assert summary["p50_duration_ms"] == 3000
assert summary["p95_duration_ms"] == 4800
assert summary["p99_duration_ms"] == 4960
assert summary["success"] == 4
assert summary["completed"] == 5 # 4 成功 + 1 纯 failed
assert summary["cancelled"] == 1
assert summary["cancelled_rate"] == pytest.approx(1 / 7)
assert summary["success_rate"] == pytest.approx(4 / 6) # 分母 started - cancelled
assert summary["avg_token_cost"] == pytest.approx(2.0) # (1+2+3)/3
assert summary["lower_price_rate"] == pytest.approx(1 / 4) # 仅 sum-success saved>0
# 耗时统计集 = admin 成功的 total_ms [1000,2000,3000,4000]
assert summary["avg_duration_ms"] == 2500
assert summary["p5_duration_ms"] == 1150
assert summary["p50_duration_ms"] == 2500
assert summary["p95_duration_ms"] == 3850
assert summary["p99_duration_ms"] == 3970
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 == 7
assert {item.trace_id for item in items} == {row[0] for row in rows}
success_items, _next_cursor, success_total = queries.list_comparison_records(
db, status="success", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15)
)
assert success_total == 3
assert {item.status for item in success_items} == {"success", "below_minimum"}
failed_items, _next_cursor, failed_total = queries.list_comparison_records(
db, status="failed", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15)
)
assert failed_total == 2
assert {item.status for item in failed_items} == {"failed", "store_closed"}
running_items, _next_cursor, running_total = queries.list_comparison_records(
db, status="running", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15)
)
assert running_total == 1
assert running_items[0].status == "running"
finally:
db.rollback()
db.close()
def test_list_status_filter_uses_admin_outcome() -> None:
"""列表「状态」筛选走 admin 口径:筛成功含 6 类、筛失败仅纯技术故障;并验日期边界排除。"""
db = SessionLocal()
try:
rows = [
("flt-success", "success", "success"),
("flt-below-min", "success", "below_minimum"),
("flt-store-not-found", "failed", "store_not_found"),
("flt-failed", "failed", "failed"),
("flt-cancelled", "cancelled", None),
]
for trace_id, status, record_status in rows:
db.add(ComparisonRecord(
trace_id=trace_id,
status=status,
raw_payload={"record_status": record_status} if record_status else None,
created_at=datetime(2039, 3, 10, 12, tzinfo=UTC),
))
# 日期边界:窗口外一条 admin 成功记录(次日),应被日期筛选排除
db.add(ComparisonRecord(
trace_id="flt-out-of-window",
status="success",
raw_payload={"record_status": "success"},
created_at=datetime(2039, 3, 11, 12, tzinfo=UTC),
))
db.flush()
succ, _c1, succ_total = queries.list_comparison_records(
db, status="success", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
)
# 3 条当天 admin 成功(含 store_not_found);窗口外 flt-out-of-window 被日期排除
assert succ_total == 3
assert {it.trace_id for it in succ} == {
"flt-success", "flt-below-min", "flt-store-not-found",
}
fail, _c2, fail_total = queries.list_comparison_records(
db, status="failed", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
)
assert fail_total == 1
assert {it.trace_id for it in fail} == {"flt-failed"}
_canc, _c3, canc_total = queries.list_comparison_records(
db, status="cancelled", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
)
assert canc_total == 1
finally:
db.rollback()
db.close()