# price_observation — 比价沉淀的价格事实(平台/门店视角的价格资产层) > 模型 `app/models/price_observation.py` · 仓库 `app/repositories/price_observation.py` · 上报端点 `app/api/internal/price.py`(`POST /internal/price-observation`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) 每完成一次比价,pricebot 在 done 帧把「在某平台某店、这一篮菜/这单到手价多少钱」按平台逐条算出来,**server→server 内部上报**落这里(一次外卖比价 = 源平台 1 条 + N 个目标平台各 1 条,`scope='order'`)。**与登录无关、不依赖客户端鉴权**——比价透传链路(`compare.py`)当前不鉴权,只要比价跑到 done 就记,**匿名用户也记**(来源记 `source_device_id`,`source_user_id` 客户端带上时才有)。 与 [`comparison_record`](./comparison_record.md) 的区别(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 / 未启用时该表不会有新数据。