商城数据源与标识体系治理 · 设计方案 v1.0

作者:波波 · 2026-10-11
触发事件:风物详情页「细说这件风物 / 到店须知」整块不显示(线上)
授权:义父「彻底解决并预留扩展能力,不用我审核,直接制定长线任务开工」

一、故障复盘(先把事实钉住)

1.1 现象

真机打开风物详情页,只剩一张「关于这件风物」卡片,下面一片空白。

控制台无任何报错。

1.2 完整因果链(由真机日志逐环证实)

后端 GET /api/miniapp/products/goods
    返回 id = 1(商品 id),🔴 不含展示 id
        ↓
miniprogram-v3/utils/shop-items.ts:123
    fromGoodsApi() 映射: id: String(r.id)  →  "1"
        ↓
miniprogram-v3/pages/shop/shop.ts:275
    跳转 URL: ?id=1&gid=1        ← 🔴 id 是商品 id,不是展示 id 9091
        ↓
miniprogram-v3/pages/item-detail/item-detail.ts:222
    loadDetailConfig('item-detail', displayId) → displayId = "1"
        ↓
GET /api/miniapp/content/items?keys=detail.item-detail.1
    后端 SQL: WHERE key = ANY($1)  →  🔴 精确匹配,零命中
    (库里59 条配置的 key 全是 detail.item-detail.9091)
        ↓
loadContent().catch() 静默回退 → loadDetailConfig 拿不到 pageConfig
        ↓
normalizeDetail 回退**默认模块**(只有 intro,标题写死在 detail-config.ts:169)
        ↓
页面只剩 intro,且**全程无报错**

真机日志(决定性证据):

content-config.ts:72 🔴 接口成功但**零命中**(返回 items 为空)
                 请求keys=[detail.item-detail.1] ⇒ 页面将只剩默认模块
  at item-detail.ts:222
[shop] 商品来源: goods id: 1

1.3 为什么难查(三层叠加)

层问题后果
数据层两套 id(商品 1–59 / 展示 9091–9237),映射只存在于 shop.items 配置里前端每次都要"猜"用哪个
代码层catch(() => {}) 完全静默「接口挂了」与「配置被删了」在页面和 console 上长得完全一样
失败姿态配置读不到 → 回退默认 → 页面半残但不报错客人和运营都看不出出事了

⚠️ 同类事故已发生第二次:2026-10-08 修过 gid(当时也是同因),只补了症状没动结构。

1.4 我的两处操作失误(非系统问题,但要记)

  1. 测试方法错:一直用 reLaunch('?id=9091&gid=1') 直连 URL,绕过了真实入口(真实入口传 id=1)⇒ 我这边永远"正常"。
  2. 只跑 esbuild 就提交 tabbar 改动:把开发者工具搞到报 jsbridge 异常。「编译过」不等于「运行对」,当晚已因此栽过多次。

二、根因

同一批风物被三份数据描述,且没有指定哪份权威:
数据条数id 口径谁维护
goods 表59id = 1–59(商品 id)系统/迁移
content_items['shop.items']59id = 9091–9237(展示 id)A 端运营
content_items['detail.item-detail.*']59key 用展示 idA 端运营

映射关系(9091 ↔ 1)只存在于 shop.items 配置里,goods 表自己不知道。

⚠️ ⇒ 任何"从列表点进详情"的链路,都必须在前端做一次没有类型保护的 id 猜测。

⚠️ ⇒ 这就是同一类 bug 会反复发生的原因。


三、设计原则(先定规矩,再动代码)

  1. 一个对象一个主标识:商品的一切引用最终落到 goods.id。
  2. 展示 id 降级为"对外编号":可以保留(A 端可读、分享链接可用),但不再是数据主键。
  3. 配置 key 是逻辑标识,不是物理主键:后端负责解析,前端不猜。
  4. 失败必须响:读不到配置就明说,不许回退成"看起来正常"。
  5. 兼容优先:存量配置零迁移风险,新写入走新口径,逐步收敛。

四、方案(三层)

层1 ·别名解析层(后端,新增扩展能力)

新增表 content_key_alias:

列类型说明
aliastext PK请求方用的逻辑 key,如 detail.item-detail.1
target_keytext NOT NULL实际存储的 key,如 detail.item-detail.9091
notetext备注(为什么存在这条映射)
created_attimestamptz

解析规则(services/content_items.py::get_items):

1. 先按原 key 精确查(存量命中,最快路径不变)
2. 未命中的 key → 查 content_key_alias 拿 target_key → 再查一次
3. 仍未命中 → 🔴 记一条「配置 key 未命中」诊断(含请求 key + 相近 key 候选)

⚠️ 为什么先做这层:它让「存量展示 id」和「未来商品 id」同时可用,

后续迁移可以随时做、也可以不做,风险为零。

扩展能力:以后套餐/领队/寺供/领队排期都走同一套「别名 → 实体」机制,

不需要每次为 id 口径改代码。


层 2 · 单一主标识(数据 + 接口收敛)

2.1 goods 接口补展示 id

GET /api/miniapp/products/goods 每项新增:

{ "id": 1, "goodsId": 1, "displayId": "9091", ... }

⚠️ 取值来源:shop.items 配置里的 goodsId → id 映射(已存在,无需新建)。

2.2 前端跳转统一

shop.ts: ?id=<goodsId>&gid=<goodsId>&displayId=<displayId>
           └ 取数与读配置都用 goodsId;displayId 仅供分享/埋点/运营识别

2.3 item-detail 读配置

// 优先商品 id(单一权威);别名层兜底展示 id(存量兼容)
const cfg = await loadDetailConfig('item-detail', String(gid || id))

⚠️ 后端别名层保证:请求 .1 能命中 .9091,存量配置不用迁移。

2.4 (可选,后续排期)配置 key 正式迁到商品 id

⚠️ 动静最大(59 条配置 + A 端录入界面 + 分享链接兼容),

本期不做,等别名层跑稳、确认没有外部依赖展示 id 之后再评估。


层 3 · 失败必须响(消除"半残")

场景现状改后
配置接口失败回退缓存 → 无缓存则 {} → 回退默认模块保留兜底,但页面显示「内容加载失败,点击重试」,不装作正常
key 拼错静默空🔴 明确日志:未命中 key=[...] 相近=[...]
部分模块无正文console.warn 跳过保留(这是正确行为:文案缺失不编造)

五、执行顺序(依赖驱动)

#任务依赖可回滚
1迁移 124:content_key_alias 表 + 59 条映射灌入—DROP TABLE
2get_items 加别名解析 + 未命中诊断1git revert
3goods 接口补 displayId—git revert
4shop.ts 跳转传 goodsId + displayId3git revert
5item-detail 读配置优先 goodsId2, 4git revert
6配置失败时页面明确报错2git revert
7清理其余静默 catch(同款排查黑洞)—git revert

⚠️ 每一步独立可回滚,任一步出问题不影响前一步已在生产生效的部分。


六、风险与对策

风险影响对策
别名表灌错映射读错配置灌完立即回读校验 59 条;先只写不启用解析(表存在但代码不查)
前端改口径后老分享链接失效老链接打不开别名层让 .9091 与 .1 都能命中;item-detail 保留 displayId 兜底
生产数据操作不可逆全部走 --commit 保护 + 先备份 + 附回滚 SQL

七、验收判据(怎么算做完)

  1. 真机从商城点进任意风物 ⇒ 「细说这件风物」「到店须知」都出现。
  2. 请求 key 是商品 id 时,命中展示 id 的存量配置(别名层生效)。
  3. 请求一个不存在的 key ⇒ 页面明确报错,不是空白。
  4. Console 不再出现 接口成功但零命中(因为 key 是对的)。
  5. 迁移 124 可用 DROP TABLE content_key_alias 一键回退。

八、遗留(本方案不做,明确记录)