Files
shaguabijia-app-server/app/schemas/compare_record.py
T
2026-06-28 10:32:36 +08:00

226 lines
11 KiB
Python

"""比价记录上报 / 读取 schemas。
约定同 welfare:字段 snake_case、金额存整数(分)、时间 ISO 8601。
上报请求(ComparisonRecordIn)的字段刻意对齐 pricebot 协议
(docs/main/02_api_protocol.md 的 calibration + done.params.comparison_results),
让客户端把 Phase1 的 calibration 和 done 帧的 params 字段**零翻译**直接映射上来,
server 端负责拆成结构化列(best_*/saved/is_source_best 由 comparison_results 派生)。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ===== 上报请求 =====
class ComparisonItemIn(BaseModel):
"""下单菜品(来自 calibration.items)。"""
name: str
qty: int = 1
# specs 仅供比价记录展示(admin-web 详情 / app 记录页都按字符串数组渲染并 join)。
# pricebot(2026-06-27 嵌套规格统一)起 calibration 的 specs 可能是规格对象
# [{name, qty, sub_specs}] 而非字符串 → 下面的 before-validator 统一拍平成可读字符串,
# 兼容新旧两种输入、保持 list[str] 契约不变(下游零改动)。
# ⚠️ 两个坑都踩过, 必须"拍平"而非别的: ① 直接声明 list[str] 不拍平 → 对象 specs 整条
# 422 被拒、不入库(同下方 platform_results list→dict 同类事故); ② 仅放宽成裸 list 又会让
# 下游 join 出 "[object Object]"/对象 toString 的乱码。
specs: list[str] | None = None
@field_validator("specs", mode="before")
@classmethod
def _flatten_specs(cls, v: object) -> object:
"""pricebot 规格对象 [{name, qty, sub_specs}] → 可读字符串数组; 字符串元素原样保留;
非 list 原样交还(让 pydantic 照常报类型错)。嵌套规格拼成 '主项(子1,子2)'"""
if not isinstance(v, list):
return v
out: list[str] = []
for it in v:
if isinstance(it, str):
s = it
elif isinstance(it, dict):
name = str(it.get("name") or "").strip()
subs = [str(x).strip() for x in (it.get("sub_specs") or []) if str(x).strip()]
s = f"{name}({','.join(subs)})" if name and subs else (name or ",".join(subs))
else:
continue
if s:
out.append(s)
return out
class AppliedCouponIn(BaseModel):
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
name: str
amount: float
class ComparisonResultIn(BaseModel):
"""逐平台对比项(来自 done.params.comparison_results)。price 单位:元。"""
platform_id: str | None = None
platform_name: str | None = None
package: str | None = None
price: float | None = None
is_source: bool = False
rank: int | None = None
# 该平台本单用红包省的**纯红包优惠额**(元, 不含配送费/代金券/满减)。None=没用/没抠到。
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
# 各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠, 当前仅淘宝源)。见 pricebot 侧 比价红包额留痕-实现方案.md。
coupon_saved: float | None = None
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
coupon_name: str | None = None
# 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
# 逐平台状态(客户端上报前把 done.params 的 comparison_results + platform_results.status 合并写入):
# success / below_minimum / store_closed / store_not_found / items_not_found / unsupported / failed。
# 「比价记录」页据此对无价平台显示对应文案(未满起送价/门店打烊/店家未入驻/比价失败)。
# 必须显式声明: 落库走 model_dump(), 不声明会被 pydantic 静默丢弃 → 记录页拿不到 status。
status: str | None = None
# 门店打烊原因(price 为 None 时带): 与 status="store_closed" 等价的更早信号, 一并落库供前端兜底判打烊。
store_closed: str | None = None
class ComparisonRecordIn(BaseModel):
# 幂等键:同一用户同一 trace_id 重复上报只保留一条(覆盖)
trace_id: str = Field(..., min_length=1, description="pricebot 侧 trace_id")
business_type: str = Field("food", description="food / ecom / coupon")
device_id: str | None = None
# 源平台(来自 calibration)
store_name: str | None = Field(None, description="店铺名(外卖)")
source_platform_id: str | None = None
source_platform_name: str | None = None
source_package: str | None = None
source_price: float | None = Field(None, description="源平台到手价(元)")
# 明细
items: list[ComparisonItemIn] = Field(default_factory=list)
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
# 按平台 id 索引的 dict 而非 list,只作 debug 展示)。
# ⚠️ 早先误声明成 list → 每条成功记录 422 被拒、记录页只剩失败/终止
# (2026-06-17 引入 platform_results 上报后复现);改 dict 后修复。
platform_results: dict = Field(default_factory=dict)
skipped_dish_count: int | None = None
skipped_dish_names: list[str] = Field(default_factory=list)
total_dish_count: int | None = None
information: str | None = Field(None, description="done 帧文案,留存备查")
# 不传则服务端按 comparison_results 派生(有非源有效价=success,否则 failed)
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达")
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
device_model: str | None = None
device_manufacturer: str | None = None
rom_vendor: str | None = Field(None, description="vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung")
rom_name: str | None = Field(None, description="OriginOS/ColorOS/HyperOS/MIUI/HarmonyOS...")
rom_version: int | None = None
android_version: str | None = None
android_sdk: int | None = None
app_version: str | None = None
app_version_code: int | None = None
source_app_version: str | None = Field(None, description="被操控的源平台 App 版本")
longitude: float | None = None
latitude: float | None = None
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
step_count: int | None = Field(None, description="agent loop 总步数")
# ===== 读取出参 =====
class ComparisonRecordOut(BaseModel):
"""列表项:结构化概要字段(不含 raw_payload,减小列表 payload)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_type: str
trace_id: str
# 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。
trace_url: str | None = None
source_platform_id: str | None = None
source_platform_name: str | None = None
source_package: str | None = None
source_price_cents: int | None = None
best_platform_id: str | None = None
best_platform_name: str | None = None
best_price_cents: int | None = None
best_deeplink: str | None = None
saved_amount_cents: int | None = None
is_source_best: bool | None = None
store_name: str | None = None
total_dish_count: int | None = None
skipped_dish_count: int | None = None
status: str
information: str | None = None
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
# 「本次比价看广告赚的金币」(feed_ad_reward,按 trace_id 聚合 granted 金币;非 DB 列,
# list_records/get_record 动态挂在 ORM 实例上,from_attributes 读出)。0=没赚到 → 记录页不显示金币标。
ad_coins_earned: int = 0
created_at: datetime
class ComparisonRecordDetailOut(ComparisonRecordOut):
"""详情:在概要之上额外带 raw_payload 全量。"""
raw_payload: dict | None = None
class ComparisonRecordPage(BaseModel):
items: list[ComparisonRecordOut]
next_cursor: int | None = Field(None, description="下一页游标(末条 id);为空表示到底")
class ComparisonRecordCreatedOut(BaseModel):
id: int = Field(..., description="写入(或已存在)的记录 id")
class CompareStatsOut(BaseModel):
"""「我的」页省钱战绩卡(比价口径)聚合。"""
compare_count: int = Field(..., description="累计完成比价次数(status='success' 记录数)")
discovered_saved_cents: int = Field(..., description="累计发现可省额(分;各成功比价 源价−最低价 之和)")
# ===== 比价战绩里程碑(福利页「记录比价战绩」)=====
class MilestoneStateOut(BaseModel):
milestone: int = Field(..., description="档位序号 1-based(= 解锁所需的成功比价次数)")
coin: int = Field(..., description="该档应发金币额(产品规则值);当前领取暂不真发, 见 compare-milestone-claim 文档")
state: str = Field(..., description="claimed(已领) / active(可领) / locked(未解锁)")
class MilestoneStatusOut(BaseModel):
success_count: int = Field(..., description="累计成功比价次数(解锁进度)")
claimable_count: int = Field(..., description="当前可领(active)档数")
milestones: list[MilestoneStateOut]
class MilestoneClaimResultOut(BaseModel):
milestone: int = Field(..., description="本次领取的档位序号")
coin_awarded: int = Field(..., description="本次发放金币")
coin_balance: int = Field(..., description="领奖后金币余额")