Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03129e059f | |||
| bc2ed5de56 |
@@ -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)
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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。
|
||||
|
||||
---
|
||||
|
||||
*本文档为跨端口径参考,非契约。字段/行号以三仓实际代码为准。*
|
||||
@@ -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
@@ -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"] == "未找到店"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user