Files
shaguabijia-app-server/docs/database/store_mapping.md
T
2026-06-17 23:32:36 +08:00

86 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 / Uupserttrace_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 走现场反查老路。
- **Udeeplink 失效标记)**`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 shopId3.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` | JSONPG 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 / 未启用时该表不会有新数据。