# 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 / 未启用时该表不会有新数据。
