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