(工作区既有改动, 非本次会话产出)
8.4 KiB
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_id,source_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-observation(report_price_observation→repo.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 |
JSON(PG JSONB) | 可空 | 菜篮 [{name, qty, subtotal?}](外卖)。dish 级单价二期从这里 + 引擎增强推。批级共享 |
attrs |
JSON(PG 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 重复上报幂等去重。 - index:
observed_at、trace_id、business_type、platform、store_name、geohash、source_device_id、source_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 / 未启用时该表不会有新数据。