# store_mapping — 跨平台「同一家店」的 id/名/deeplink 身份映射（门店资产层）

> 模型 `app/models/store_mapping.py` · 仓库 `app/repositories/store_mapping.py` · 上报/反查端点 `app/api/internal/store.py`（`POST /internal/store-mapping`、`GET /internal/store-mapping/lookup`、`POST /internal/store-mapping/invalidate`） · [← 索引](./README.md) · [总览](./OVERVIEW.md)

每完成一次比价，pricebot 把同一家物理店在 **淘宝/美团/京东** 各自的店铺 id、店名、可复跳 deeplink **server→server 内部上报**落这里一行（一次比价 = 一个 trace = 一行，各平台「腿」解析出 id 后填空合并进同一行）。**与登录无关、不依赖客户端鉴权**（同 [`price_observation`](./price_observation.md)，`source_user_id` 客户端带上时才有，匿名也记）。

与 [`price_observation`](./price_observation.md) 的区别（两表平行、独立）：
- `price_observation` = 平台/门店视角的**价格事实**（某店这单多少钱）。
- `store_mapping` = 平台/门店视角的**身份映射**（同一家店在各平台的店铺 id/名/deeplink）。是未来「我见过这家店 → 跳过重新搜索/匹配，直接 deeplink 进店内搜索」的源头（#48）。

与 [`comparison_record`](./comparison_record.md)（用户视角、带 JWT、按 `user_id` 存）也无关：本表是 server 无条件沉淀的客观映射、匿名也记。

> **⚠️ 数据质量**：跨平台「同一家店」的连接来自 agent 的 **LLM 店铺匹配**，匹配错则一行里 `id_taobao` 与 `id_jd`/`name_meituan` **连到不同店**。本表是 **append-only 原始记录**（每比价一行、`trace_id` 幂等防重试重复），「一行里多平台 id 共存」只表示「两条腿搜同一个源店名各自匹配到了某家店」= name-match 置信度、**非已核实同一实体**；清洗/归一/精确匹配二期由下游做。

## 用在哪 / 增删改查
- **C / U（upsert，trace_id 维度幂等 + 填空合并）**：`POST /internal/store-mapping`（`report_store_mapping` → `repo.upsert`）。合并键 = `trace_id`（唯一约束）。一次跨平台比价 = 一个 trace = 一个真实店铺：某条腿解析出 id 先建行（填自己那几列），后到的腿（将来）再 upsert 进**同一行**。**合并策略 = 填空（fill-the-blanks）**：`_merge_fill_blanks` 只写该行当前为 NULL 的列、**绝不覆盖已有非空值**；共享列（geo/source/溯源）先到先得。首写用逐列 `setattr`（而非硬编码构造器，否则首写是美团/京东腿时其列会漏）。不依赖 `ON CONFLICT`，跨方言安全；并发撞唯一约束则 `rollback()` 后转填空合并路径。返回 `{inserted, row_id}`（inserted=1 新建 / 0 合并）。
- **R（lookup 反查，命中即省一整段现场反查）**：`GET /internal/store-mapping/lookup?source_platform=&name=&lat=&lng=`（`lookup_nearest`）。**比价前**按「源平台店名」反查各目标平台已沉淀的店铺 id，命中就让 pricebot **直接 deeplink 跳店内搜索**，省掉「开平台→进店→分享→反查」整段。机制：匹配键 = 源平台对应的 name 列精确等于 `name`；每个目标**分别**取「含该目标 id 的同名候选里最近一条」（有入参 geo 且候选带 geo → haversine 取最近，否则取 `created_at` 最新）；**排除源平台自己**（不复用源平台 id）；**过滤掉 deeplink 已失效的候选**（见下）。返回 `{target_key: {id..., deeplink, row_id, dist_km?}}`，空 = 没缓存、pricebot 走现场反查老路。
- **U（deeplink 失效标记）**：`POST /internal/store-mapping/invalidate`（`{platform, shop_id}`）。pricebot 撞错误页回退时上报，按 `shop_id` 把**所有**匹配行的对应 `*_deeplink_invalid_at` 置 `now()`（幂等：已标记的跳过）。`taobao`→`mark_taobao_deeplink_invalid`、`jd`→`mark_jd_deeplink_invalid`；**其它平台（含 meituan）当前 no-op 返回 `affected=0`、不报错**（向后兼容 pricebot 将来扩展）。返回 `{ok, affected}`。
- **D**：无业务删除。

> 上报/反查/失效端点全是 **server→server 专用**，复用 `price.py` 的 `_check_secret`（同一 `X-Internal-Secret` 密钥，未配置→503，不匹配→401）。

## 字段

各平台列**按比价角色稀疏填充**（淘宝当目标才有 `*_taobao`，依此类推）。

| 列 | 类型 | 约束 / 默认 | 说明（取值 / join） |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| **跨平台身份** | | | 同一物理店在各平台的 id/名 |
| `id_taobao` | String(64) | index, 可空 | 淘宝 = 分享短链解析出的 **shopId**（淘宝当目标、走完取 id 流程才有）。门店稳定数字主键 |
| `name_taobao` | String(128) | 可空 | 店铺页 a11y `content_desc`「店铺标题：xxx」剥前缀 |
| `id_meituan` | String(64) | index, 可空 | **留给将来 CPS API 的稳定数字 poi_id**；当前无 share→稳定 id 机制 → 多为空（可复跳票据见 `meituan_poi_id_str`） |
| `name_meituan` | String(128) | 可空 | 源平台店名（来自 intent/agent 匹配名） |
| `id_jd` | String(64) | index, 可空 | 京东 = **storeId**（门店稳定数字主键，同 taobao shopId；3.cn 短链反查）。`venderId` 另存 `jd_vender_id` |
| `name_jd` | String(128) | 可空 | 京东店铺页店名 |
| **地理** | | | 同名店异地区分 / 地理分桶匹配主要燃料 |
| `city` | String(64) | 可空 | |
| `geohash` | String(16) | index, 可空 | |
| `lng` / `lat` | Float | 可空 | lookup 时按入参 geo 取最近候选用 |
| `taobao_address` | String(256) | 可空 | 淘宝门店地址（店铺页 a11y 抓到才有；比经纬度更利于人工/LLM 匹配） |
| **溯源 / 画像** | | | |
| `source_platform` | String(32) | index, 可空 | 发起比价那家：`meituan` / `taobao_flash` / `jd_waimai` … lookup 据此选 name 列、排除源平台自己 |
| `business_type` | String(16) | NOT NULL, default `food` | |
| `trace_id` | String(64) | NOT NULL | **幂等合并键**（唯一约束）+ 回指原始 trace（溯源）。软指 pricebot work_logs，无硬 FK |
| `source_device_id` | String(64) | index, 可空 | |
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到，客户端带上时才有**，软指 `user.id` |
| **淘宝原料**（可复跳/重解析/调试） | | | |
| `taobao_share_url` | String(256) | 可空 | `m.tb.cn` 短链 |
| `taobao_resolved_url` | Text | 可空 | 解析出的目标 URL（含 shopId）。Text 因 URL 可能很长 |
| `taobao_deeplink` | Text | 可空 | 拼好的 et-store/search deeplink |
| `taobao_deeplink_invalid_at` | DateTime(tz) | 可空 | **淘宝 deeplink 失效标记**（#53）。撞「页面出错了」降级页时被置；**NULL=有效**。lookup 反查过滤掉非 NULL 的淘宝候选 |
| **美团原料**（dpurl.cn 短链 → 302 反查 → imeituan://） | | | |
| `meituan_poi_id_str` | String(64) | index, 可空 | **⚠️ 每次分享重新加密、非稳定主键**（调研 §八）→ 单列存「可复跳的一次性票据」，**不进 `id_meituan`** |
| `meituan_share_url` | String(256) | 可空 | `dpurl.cn` 短链 |
| `meituan_resolved_url` | Text | 可空 | 302 落地 menu URL（含 poi_id_str） |
| `meituan_deeplink` | Text | 可空 | 拼好的 `imeituan://` 店内搜索 deeplink |
| **京东原料**（3.cn 短链 → 重定向反查 → openapp.jdmobile://） | | | |
| `jd_vender_id` | String(64) | index, 可空 | venderId 单列存（deeplink 模板 venderId+storeId 都要；venderId 是商家维度、可跨门店，与门店 storeId 分开记） |
| `jd_share_url` | String(256) | 可空 | `3.cn` 短链 |
| `jd_resolved_url` | Text | 可空 | 反查出的目标 `openapp.jdmobile://` URL |
| `jd_deeplink` | Text | 可空 | 拼好的 pages/search 店内搜索 deeplink |
| `jd_deeplink_invalid_at` | DateTime(tz) | 可空 | **京东 deeplink 失效标记**（#56，对标淘宝）。撞「当前门店超出配送范围」页时被置；**NULL=有效**。lookup 过滤非 NULL 的京东候选 |
| `attrs` | JSON（PG JSONB） | 可空 | 灵活字段兜底，免得加字段就迁移 |
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | lookup 同名候选无 geo 时按它取最新 |

## 关系 / Join Key
- **无硬外键**。
- `trace_id` **软指** pricebot work_logs（溯源/排查），并作 upsert 幂等合并键。
- `source_user_id` **软指** `user.id`（可空，不进唯一键、不阻塞落库）。
- lookup 的匹配靠**店名字符串精确相等**（`_SOURCE_NAME_COLUMN` 把源平台映到对应 name 列）+ 可选 geo 取最近，**非 id 级 join**。

## 索引与约束
- PK `id`。
- **UNIQUE(`trace_id`) = `uq_store_mapping_trace`**：一次比价（trace）只一行 → pricebot 重试 / 客户端 replay 重复上报幂等去重；后到的平台腿填空合并进同一行。
- index：`id_taobao`、`id_meituan`、`id_jd`、`meituan_poi_id_str`、`jd_vender_id`、`geohash`、`source_platform`、`source_device_id`、`source_user_id`、`created_at`（lookup 反查 / 失效标记按 id 列查需要）。

## 注意
- **deeplink 失效→标记→回退闭环**（#53 淘宝 / #56 京东，本表最关键机制）：缓存的 deeplink 会过期（淘宝撞「页面出错了」、京东撞「当前门店超出配送范围」）。pricebot 撞到错误降级页 → 调 `/internal/store-mapping/invalidate` 按 shopId/storeId 把**所有**相关行打上 `*_deeplink_invalid_at` → 后续 `lookup_nearest` **整条排除**失效候选（当没缓存，pricebot 走正常搜店）→ 重搜成功会写**新行**自然覆盖。按 shopId 标记所有行（不是单行）是因为同一坏 id 可能散在多次比价的多行里，全标掉才能让 lookup 不再返回它。
- **美团没有失效标记**：`meituan_poi_id_str` 是「每次分享重新加密」的一次性票据、本就不稳定不复用为稳定 id，故无 invalidate 路径（`invalidate` 端点对 meituan no-op）。三平台 share→id 反查机制不对称：淘宝 `m.tb.cn`→shopId（稳定）、京东 `3.cn`→venderId+storeId（稳定）、美团 `dpurl.cn`→poi_id_str（**非稳定**）。
- **资产层「先存后用」+ append-only**：lookup 是已接通的读用途（省现场反查），但表本身是原始记录、不做清洗归一。「一行多平台 id 共存」**不代表已核实同一实体**——下游做精确匹配前别当真值用。
- **数据质量依赖 LLM 匹配**：跨平台连接来自 agent 店铺匹配，**可能连错店**；排查某行可疑映射时回 `trace_id` 指的 pricebot trace 看当时的匹配上下文。
- **`attrs` 是 JSONB 兜底，别塞大树**（同 `price_observation`）；`_JSON = JSON().with_variant(JSONB(), "postgresql")`，PG JSONB / SQLite dev 通用 JSON。
- **填空合并不覆盖**：共享列（geo/source/device）先到先得，后到的腿只填自己那几列的空位；想「纠正」已写错的列**当前没有路径**（append-only，靠新 trace 写新行）。
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**（默认关闭态），本地 dev / 未启用时该表不会有新数据。
