Files
shaguabijia-app-server/docs/database/comparison_record.md
T
guke 699e0a5079 feat(compare): 比价记录 LLM token 成本落库与展示(按当时价冻结)
- comparison_record 加 llm_cost_yuan(元/float)+ llm_price_snapshot(JSON)两列
- _backfill_llm_calls 回填时按 app_config 当时单价逐模型算成本、冻结成本+快照到记录
- app_config 新增 llm_token_price 配置(per_model + default 兜底,运营在系统配置页可改)
- services/llm_cost.py:compute_llm_cost 纯函数(按 model 分桶、error/无 usage 跳过、
  脏价格当 unpriced 不抛异常以免连累 token 回填)+ get_llm_prices reader
- admin schema 暴露成本:列表项带 llm_cost_yuan,详情另带价格快照
- tests/test_llm_cost.py(10 测试);scripts/seed_mock_llm_cost.py(mock seeder)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:53:18 +08:00

8.8 KiB

comparison_record — 比价记录(每次比价完整明细)

模型 app/models/comparison.py · 仓库 app/repositories/comparison.py · 接口 compare-record-report / compare-records / compare-record-detail · ← 索引 · 总览

每完成一次比价(外卖/电商/领券)记一行。写入以 app-server 后端 harvest 为主(2026-07 起):比价透传壳 compare.py 在帧0(pricebot 出 trace_id)即建 running 行,随 done / trace/finalize 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走带 JWTPOST /compare/record 兜底(灰度期两条写路径按 trace_id reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(status='success' 条数),还被「上报更低价」反查原最低价。

savings_record 的区别:本表是「每一次比价行为的完整明细」(不省钱、甚至失败也记);savings_record 是「真正下单成交省了多少」。两表独立、互不喂数据。 与 price_observation / store_mapping 的区别:本表是用户视角(登录后按 user_id 存「我的比价记录」);后两张是 server 侧无条件沉淀的平台/门店视角客观事实(价格事实 / 跨平台店铺身份映射),与本表 trace_id 同源但不互相 join,各存各的视角。

用在哪 / 增删改查

  • C / U(harvest 为主,按 trace_id 幂等):透传壳 compare.py 三段式落库(app/repositories/comparison.py)——harvest_running(帧0 建 running 行)→ harvest_done(done 帧转 success/failed + 派生 best_*/saved_amount_cents;newly_success 仅留日志观测,不在此发邀请奖——#113 已把发奖口径移到「实际下单」POST /order/report)→ harvest_abort(trace/finalizecancelled/failed,不降级已 success)。老客户端仍可 POST /compare/record(upsert_record,按 trace_id 查、整行覆盖)兜底。best_*/saved_amount_cents/is_source_best/status 一律由 _derivecomparison_results 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
  • D:无(关联的 price_report 也只把 comparison_record_id 置空,不删本表)。
  • R:GET /compare/records(列表,created_at 倒序游标 + 「已下单」标记)、GET /compare/records/{id}(详情,限本人);count_success 给里程碑;get_stats(status='success' 计数 + saved_amount_cents 求和)给 GET /compare/stats 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,比价口径);report.py 反查 best_*;admin 大盘/明细。

字段

类型 约束 / 默认 说明(取值 / join)
id Integer PK, autoincrement price_report.comparison_record_id 引用
user_id Integer FK→user.id, index, nullable(2026-07 从 NOT NULL 放开) 归属用户。后端 harvest 帧0 建行时(软鉴权 / 老客户端匿名)可能暂缺 → 可空;C 端「我的比价记录」按 user_id 过滤天然排除 null 行,admin 全看(含孤儿行)
device_id String(64) nullable 设备号(多设备区分 / 与不鉴权期对账)
business_type String(16) NOT NULL, default food, index 取值:food(当前唯一接通)/ ecom / coupon
trace_id String(64) NOT NULL, UNIQUE 一次比价的唯一标识。由 app-server 帧0 用 uuid 签发(注入转发 body + 回填响应顶层给客户端;老客户端自带),全局唯一 = harvest upsert 键 + 关联 pricebot 调试落盘
trace_url String(512) nullable 本次比价公网调试链接(price.shaguabijia.com/traces/{dir}/);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 user.debug_trace_enabled(或本机 agent 调试 include_trace)决定返不返回。旧记录 / 未开上云为 null
source_platform_id / _name String(32) nullable 源平台代号 / 中文名
source_package String(128) nullable 源平台 Android 包名
source_price_cents Integer nullable 源平台到手价(分)
best_platform_id / _name String(32) nullable 最优平台代号 / 中文名(= rank=1)
best_price_cents Integer nullable 最优价(分)。price_report 反查作"原最低价",校验上报价必须更低
best_deeplink String(1024) nullable 最优平台商家深链(再次比价/直达用);2026-06 起新比价才有,旧记录 null
saved_amount_cents Integer nullable 源价 − 最优价(可 0/负:源平台本就最便宜)
is_source_best Boolean nullable 源平台就是最便宜(= 这次没省到)
store_name String(128) nullable 店铺名。savings_record.shop_name 按字符串相等关联,给本记录打「已下单」
product_names String(512) nullable 菜品/商品名拼接串(items[].name 顿号连接,超长截断),专供 admin 比价记录按店/商品模糊搜索(#117:对 JSON 列做 LIKE 不可移植,冗余成扁平列;写入时随 harvest/upsert 同步生成)。C 端不读它
total_dish_count / skipped_dish_count Integer nullable 菜品总数 / 目标平台没找到被跳过数
status String(16) NOT NULL, default success 取值:running(harvest 帧0 建行、比价进行中)/ success(有非源且有价的目标结果)/ failed(出错/没采到目标价)/ cancelled(用户终止 / Phase1 未识别,harvest_abort 写,不降级已 success)。里程碑只数 success
information String(256) nullable done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示)
items JSON(PG: JSONB) NOT NULL, default [] 下单菜品 [{name, qty, specs?}]
comparison_results JSON(PG: JSONB) NOT NULL, default [] 逐平台对比 [{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}];coupon_saved=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),coupon_name=优惠来源名(展示用),applied_coupons=[{name,amount}] 多券明细
skipped_dish_names JSON(PG: JSONB) NOT NULL, default [] 被跳过的菜名
raw_payload JSON(PG: JSONB) nullable 客户端原始上报全量(calibration + done.params),取数兜底
input_tokens Integer nullable 本次 LLM 累计输入 token = Σ llm_calls[].usage.prompt_tokens(server 收上报后从 llm_calls 累加;旧记录/未采集为 null)
output_tokens Integer nullable 本次 LLM 累计输出 token = Σ llm_calls[].usage.completion_tokens(同上)
llm_cost_yuan Float nullable 本次比价 LLM 总成本(元),回填时按「当时价」逐模型算好冻结(见 services/llm_cost.py);旧记录/未回填为 null → 前端回退「估算成本」
llm_price_snapshot JSON(PG: JSONB) nullable 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}};app_config 只存当前价、不留历史,故冻结当时价供审计/复算
created_at DateTime(tz) server_default now(), index 时间

ordered(已下单)是瞬态字段,不在表里:list_records 读取时按 store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合 现挂到实例上供出参用。

关系 / Join Key

  • user_iduser.id(多对一)。
  • ← 被 price_report.comparison_record_id 引用(选中某条比价记录上报更低价);price_report 同时读本表 best_price_cents/best_platform_*/store_name/items 做快照与校验。
  • savings_record(语义 join,无 FK):store_name == savings_record.shop_name(source='compare')→ 标「已下单」,店级。
  • comparison_milestone_claim 间接依赖:解锁进度 = 本表 status='success' 计数。

索引与约束

  • PK id;index user_idbusiness_typecreated_at;复合 index ix_comparison_status_created(status, created_at)(按 status='success' 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(trace_id) = uq_comparison_trace(harvest 按它 upsert,一次比价一行)。

    2026-07 迁移 comparison_record_trace_unique:唯一键从复合 uq_comparison_user_trace(user_id,trace_id)改为 trace_id 单列——harvest 帧0 建行时 user_id 可能暂缺,不能再用复合键去重。上线前须确认历史无重复 trace_id(SELECT trace_id,COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c>1),否则建单列唯一会失败。

注意

  • 4 个 JSON 列用 JSON().with_variant(JSONB(),"postgresql")(SQLite 退化 JSON)。结构化金额列存「分」,comparison_results.price/coupon_saved 原样存「元」。