作者:波波 · 2026-10-11
触发事件:风物详情页「细说这件风物 / 到店须知」整块不显示(线上)
授权:义父「彻底解决并预留扩展能力,不用我审核,直接制定长线任务开工」
真机打开风物详情页,只剩一张「关于这件风物」卡片,下面一片空白。
控制台无任何报错。
后端 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
| 层 | 问题 | 后果 |
|---|---|---|
| 数据层 | 两套 id(商品 1–59 / 展示 9091–9237),映射只存在于 shop.items 配置里 | 前端每次都要"猜"用哪个 |
| 代码层 | catch(() => {}) 完全静默 | 「接口挂了」与「配置被删了」在页面和 console 上长得完全一样 |
| 失败姿态 | 配置读不到 → 回退默认 → 页面半残但不报错 | 客人和运营都看不出出事了 |
⚠️ 同类事故已发生第二次:2026-10-08 修过 gid(当时也是同因),只补了症状没动结构。
reLaunch('?id=9091&gid=1') 直连 URL,绕过了真实入口(真实入口传 id=1)⇒ 我这边永远"正常"。同一批风物被三份数据描述,且没有指定哪份权威:
| 数据 | 条数 | id 口径 | 谁维护 |
|---|---|---|---|
goods 表 | 59 | id = 1–59(商品 id) | 系统/迁移 |
content_items['shop.items'] | 59 | id = 9091–9237(展示 id) | A 端运营 |
content_items['detail.item-detail.*'] | 59 | key 用展示 id | A 端运营 |
映射关系(9091 ↔ 1)只存在于 shop.items 配置里,goods 表自己不知道。
⚠️ ⇒ 任何"从列表点进详情"的链路,都必须在前端做一次没有类型保护的 id 猜测。
⚠️ ⇒ 这就是同一类 bug 会反复发生的原因。
goods.id。新增表 content_key_alias:
| 列 | 类型 | 说明 |
|---|---|---|
alias | text PK | 请求方用的逻辑 key,如 detail.item-detail.1 |
target_key | text NOT NULL | 实际存储的 key,如 detail.item-detail.9091 |
note | text | 备注(为什么存在这条映射) |
created_at | timestamptz |
解析规则(services/content_items.py::get_items):
1. 先按原 key 精确查(存量命中,最快路径不变)
2. 未命中的 key → 查 content_key_alias 拿 target_key → 再查一次
3. 仍未命中 → 🔴 记一条「配置 key 未命中」诊断(含请求 key + 相近 key 候选)
⚠️ 为什么先做这层:它让「存量展示 id」和「未来商品 id」同时可用,
后续迁移可以随时做、也可以不做,风险为零。
扩展能力:以后套餐/领队/寺供/领队排期都走同一套「别名 → 实体」机制,
不需要每次为 id 口径改代码。
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 之后再评估。
| 场景 | 现状 | 改后 |
|---|---|---|
| 配置接口失败 | 回退缓存 → 无缓存则 {} → 回退默认模块 | 保留兜底,但页面显示「内容加载失败,点击重试」,不装作正常 |
| key 拼错 | 静默空 | 🔴 明确日志:未命中 key=[...] 相近=[...] |
| 部分模块无正文 | console.warn 跳过 | 保留(这是正确行为:文案缺失不编造) |
| # | 任务 | 依赖 | 可回滚 |
|---|---|---|---|
| 1 | 迁移 124:content_key_alias 表 + 59 条映射灌入 | — | DROP TABLE |
| 2 | get_items 加别名解析 + 未命中诊断 | 1 | git revert |
| 3 | goods 接口补 displayId | — | git revert |
| 4 | shop.ts 跳转传 goodsId + displayId | 3 | git revert |
| 5 | item-detail 读配置优先 goodsId | 2, 4 | git revert |
| 6 | 配置失败时页面明确报错 | 2 | git revert |
| 7 | 清理其余静默 catch(同款排查黑洞) | — | git revert |
⚠️ 每一步独立可回滚,任一步出问题不影响前一步已在生产生效的部分。
| 风险 | 影响 | 对策 |
|---|---|---|
| 别名表灌错映射 | 读错配置 | 灌完立即回读校验 59 条;先只写不启用解析(表存在但代码不查) |
| 前端改口径后老分享链接失效 | 老链接打不开 | 别名层让 .9091 与 .1 都能命中;item-detail 保留 displayId 兜底 |
| 生产数据操作 | 不可逆 | 全部走 --commit 保护 + 先备份 + 附回滚 SQL |
接口成功但零命中(因为 key 是对的)。DROP TABLE content_key_alias 一键回退。detail.* 配置 key 正式迁到商品 id** —— 等别名层跑稳后再评估。packages.id=7081 与展示 id 一致,暂不存在同源问题,本期不动。