Files
2026-06-17 23:32:36 +08:00

8.4 KiB
Raw Permalink Blame History

price_observation — 比价沉淀的价格事实(平台/门店视角的价格资产层)

模型 app/models/price_observation.py · 仓库 app/repositories/price_observation.py · 上报端点 app/api/internal/price.py(POST /internal/price-observation) · ← 索引 · 总览

每完成一次比价,pricebot 在 done 帧把「在某平台某店、这一篮菜/这单到手价多少钱」按平台逐条算出来,server→server 内部上报落这里(一次外卖比价 = 源平台 1 条 + N 个目标平台各 1 条,scope='order')。与登录无关、不依赖客户端鉴权——比价透传链路(compare.py)当前不鉴权,只要比价跑到 done 就记,匿名用户也记(来源记 source_device_idsource_user_id 客户端带上时才有)。

comparison_record 的区别(model docstring 明示,两表独立、互不影响):

  • comparison_record = 用户视角的「我的比价记录」,客户端登录后用带 JWT 的通道(POST /compare/record)主动上报,按 (user_id, trace_id) 存。
  • price_observation = 平台/门店视角的客观价格事实server 侧无条件沉淀,按 (平台, 门店, 菜, 时间) 组织。是未来「别人查过同店 → 直接秒回价格」价格大数据的源头(#21,「先存数据、用法后续」)。

raw trace(逐 step 全过程)另存 pricebot 本地 jsonl.gz,本表只存提炼后的价格事实(本表可从 raw 重算);trace_id 软指 pricebot work_logs 供溯源/重算。

用在哪 / 增删改查

  • C(批量插入,幂等)POST /internal/price-observationreport_price_observationrepo.insert_batch)。pricebot 在 done 帧把整批观测 POST 过来落库。幂等键 = (trace_id, platform, scope):仓库先查该 trace 已有的 (platform, scope),只插新的(不依赖 ON CONFLICT,跨方言 PG/SQLite 都安全),并发撞唯一约束则 db.rollback() 后整批跳过(本批本就幂等,不重试,见 P0 约定)。同批内还会去重一次(防 pricebot 同帧给重复平台)。返回 {inserted, skipped}
  • U / D:无。append-only,无更新无删除。
  • R当前无读取端点(纯写入沉淀层,未来做「秒回价格」/聚合时再用)。

上报端点是 server→server 专用:不走用户 JWT,靠共享密钥头 X-Internal-Secret== settings.INTERNAL_API_SECRET,常量时间比较 hmac.compare_digest)。密钥未配置(默认空)→ 直接 503(挡住裸奔的写端点);不匹配 → 401。该密钥须与 pricebot 侧同值。

字段

批级字段(门店/菜篮/地理/来源,一次比价共享,由 insert_batch 灌进每条)vs 逐平台字段(observations[] 各自带)见下表「来源」列。

类型 约束 / 默认 说明(取值 / join
id Integer PK, autoincrement
observed_at DateTime(tz) server_default now(), index, NOT NULL 观测时刻(≈比价 done 时;服务端落库时取 now)。按时间查「最近有效价」用
trace_id String(64) index, NOT NULL pricebot 侧 trace_id幂等去重键(含于唯一约束)+ 回指 A 层原始 trace(溯源/重算)。软指 pricebot work_logs,无硬 FK
business_type String(16) NOT NULL, default food, index food(外卖,当前唯一接通)/ ecom(电商,二期)。批级共享
门店维度 denormalize 成文本,不建维度表;实体归一二期再做
platform String(32) index, NOT NULL 平台标识。逐平台observations[].platform),含于唯一约束
platform_store_id String(64) 可空 平台内门店 ID(抓得到才存,将来归一最稳的 key;现在多为空)。逐平台
store_name String(128) index, 可空 门店名。批级共享(源/目标同名场景下整批一份)
city String(64) 可空 批级共享
geohash String(16) index, 可空 地理(现在拿不到→留空;二期把 device 经纬度透传进比价请求后回填)。批级共享
lng / lat Float 可空 同上,批级共享
价格事实
is_source Boolean NOT NULL, default false 是否源平台(发起比价那家)。源平台价也是真实观测,照记。逐平台
scope String(16) NOT NULL, default order 口径:order(整单到手价,外卖当前唯一)/ dish(单菜单价,引擎暂不逐菜读,二期)。含于唯一约束。逐平台
price_cents Integer 可空 该口径到手价(分);None = 该平台采集失败 / 门店打烊,无有效价逐平台
coupon_saved_cents Integer 可空 红包/券省了多少(分)。逐平台
coupon_name String(64) 可空 优惠来源名。逐平台
store_closed String(32) 可空 目标店打烊原因(非空时 price_cents 为 None)。逐平台
rank Integer 可空 本次比价全局名次(1=最便宜;按到手价升序,源/目标统一排)。逐平台
明细(JSON
dishes JSONPG JSONB 可空 菜篮 [{name, qty, subtotal?}](外卖)。dish 级单价二期从这里 + 引擎增强推。批级共享
attrs JSONPG JSONB 可空 灵活字段兜底(配送费/起送/活动名/跳过菜数 等),免得加字段就迁移。逐平台observations[].attrs
来源(溯源/去重/置信)
source_device_id String(64) index, 可空 来源设备;匿名比价也有。批级共享
source_user_id Integer index, 可空 比价链路不鉴权拿不到,客户端带上时才有,先可空、不进唯一键。软指 user.id批级共享
confidence Float NOT NULL, default 1.0 置信度,预留
created_at DateTime(tz) server_default now(), NOT NULL 落库时间

关系 / Join Key

  • 无硬外键
  • trace_id 软指 pricebot work_logs(溯源/重算/排查),并作幂等去重键;与 comparison_record.trace_id 同源(同一次比价的 pricebot trace),但两表不互相 join、各存各的视角。
  • source_user_id 软指 user.id(可空,不进唯一键、不阻塞落库)。
  • source_device_id 与不鉴权期 comparison_record.device_id / 领券三表 device_id 同源,可用于对账(无 SQL 级约束)。

索引与约束

  • PK id
  • UNIQUE(trace_id, platform, scope) = uq_price_obs_trace_platform_scope:一次比价(trace)里同一平台、同一口径只一条 → pricebot 重试 / 客户端 replay 重复上报幂等去重。
  • indexobserved_attrace_idbusiness_typeplatformstore_namegeohashsource_device_idsource_user_id(为将来按 平台/门店/菜/时间/地理 查询聚合预埋)。

注意

  • 资产层定位「先存后用」:当前只写不读(无 R 端点、不参与任何业务判断),是为「别人查过同店→秒回价格」大数据预先沉淀的原始事实。结构化列预埋查询/聚合维度,用法二期再定。
  • attrs / dishes 是 JSONB 兜底,别塞原始无障碍树(几十 KB → 行膨胀);原始大树/逐 step 看 trace_id 指过去的 pricebot 落盘。_JSON = JSON().with_variant(JSONB(), "postgresql")PG 用 JSONB(可建 GIN),SQLite dev 退化通用 JSON(同 comparison_record / coupon_claim_record)。
  • price_cents=None 是有意义的观测:表示该平台采集失败 / 门店打烊(看 store_closed),不是「没记」。统计「最低价」时要先过滤 None。
  • 当前数据多为半空platform_store_id 多为空(门店 ID 抓不全)、geohash/lng/lat 留空(device 经纬度二期才透传进比价请求)、source_user_id 多为空(链路不鉴权)。这些是二期回填计划,不是 bug。
  • 幂等/并发:不重试、失败就失败(P0 约定);同批去重 + 唯一键 + IntegrityError 回滚三重防御,但回滚是整批回滚(撞约束则本批全跳过)。
  • 上报端点未配 INTERNAL_API_SECRET 时返 503(不是 401),这是「内部端点默认关闭」的安全默认态——本地 dev / 未启用时该表不会有新数据。