From 741f25451c536533b2e1fa88a699efd7c908c931 Mon Sep 17 00:00:00 2001 From: Council Date: Mon, 20 Apr 2026 09:04:23 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20v3.0=20=E6=9C=80=E7=BB=88=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=20-=20template=5Fsnapshot=20=E5=AD=97=E6=AE=B5=20+=20?= =?UTF-8?q?selected=5Fsections=20=E5=AF=B9=E8=B1=A1=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: - 新增 template_snapshot 字段(发布时从 vr_seat_templates 读取并存储) - selected_sections 确认为对象格式 { room_id: ["A","B"] } - spec_base_id_map 使用 goods_spec_base.extends 动态构建 - 现有前端编辑体验完全不受影响 - Issue #13 已同步更新 --- docs/PHASE2_PLAN.md | 121 +++++----------- docs/VR_GOODS_CONFIG_SPEC.md | 260 +++++++++++++++++++---------------- 2 files changed, 176 insertions(+), 205 deletions(-) diff --git a/docs/PHASE2_PLAN.md b/docs/PHASE2_PLAN.md index d31304b..6f235d1 100644 --- a/docs/PHASE2_PLAN.md +++ b/docs/PHASE2_PLAN.md @@ -1,59 +1,35 @@ # Phase 2 — 计划与当前状态 > 版本:v3.0 | 日期:2026-04-20 | 状态:实现准备就绪 -> 关联提交:c894e7018(模板渲染)、1b0ac3276(精简 footer) +> 关联 Issue:#13 > 关联文档:`docs/VR_GOODS_CONFIG_SPEC.md`(v3.0 JSON 格式,已确认) --- -## ⚠️ v3.0 重大变更摘要 +## ⚠️ v3.0 核心变更摘要 -- `vr_goods_config` 包含完整 `rooms[]` 快照,不再查 `vr_seat_templates` 表 -- `spec_base_id_map` 不入库,GetGoodsViewData 动态从 `goods_spec_base.extends->seat_key` 构建 -- `selected_sections` 改为数组格式 `["A","B"]`(不是对象) +- 新增 `template_snapshot` 字段(发布时从 `vr_seat_templates.seat_map` 读取并存储) +- `selected_sections` 保持对象格式 `{ room_id: ["A","B"] }` +- `spec_base_id_map` 不入库,GetGoodsViewData 从 `goods_spec_base.extends->seat_key` 动态构建 +- 现有前端编辑体验**完全不受影响**(前端只提交选择项) 完整规格见 `docs/VR_GOODS_CONFIG_SPEC.md`。 --- -## 一、Phase 2 当前状态 - -### ✅ 已完成 - -| 任务 | 提交 | 说明 | -|------|------|------| -| 模板渲染 | c894e7018 | PHP ModuleInclude,渲染正常 | -| 票务专用 footer | 1b0ac3276 | 精简 footer | - -### ⚠️ 实现准备就绪(待动手) - -| 任务 | 说明 | 依赖 | -|------|------|------| -| BatchGenerate 写入 extends | 存储 seat_key 到 goods_spec_base.extends | VR_GOODS_CONFIG_SPEC.md 已确认 | -| GetGoodsViewData 重写 | 动态构建 spec_base_id_map + 适配 selected_sections | 同上 | -| ticket_detail.html JS 更新 | seatKey 格式改为 roomId_rowLabel_colNum | 同上 | - -### ❌ 未开始 - -| 任务 | -|------| -| 核销 API | -| 后台 4 控制器联调 | -| AdminGoodsSaveHandle selected_sections 透传确认 | - ---- - -## 二、vr_goods_config v3.0 结构(已确认) +## 一、vr_goods_config v3.0 结构(已确认) ```json { - "version": 1.0, + "version": 3.0, "template_id": 4, "selected_rooms": ["room_id_xxx"], - "selected_sections": ["A", "B"], + "selected_sections": { "room_id_xxx": ["A", "B"] }, "sessions": [{ "start": "15:00", "end": "16:59" }], - "venue": { "name": "...", "address": "...", "location": {}, "images": [] }, - "rooms": [{ "id": "room_id_xxx", "name": "...", "map": [...], "sections": [...], "seats": {...} }] + "template_snapshot": { + "venue": { ... }, + "rooms": [{ "id": "...", "name": "...", "map": [...], "sections": [...], "seats": {...} }] + } } ``` @@ -61,73 +37,42 @@ --- -## 三、spec_base_id_map 解决方案(已确认) +## 二、实现顺序(Issue #13) -### 断路根因 +### Step 1:AdminGoodsSaveHandle — 保存时填充 template_snapshot -BatchGenerate 生成 GoodsSpecBase.id 后,从未写入 spec_base_id_map。前端用 `roomId_row_col` 格式查,存储端从未按此格式写入。 +在 `save_thing_end` 时机,BatchGenerate 之前: +1. 用 `template_id` 读取 `vr_seat_templates.seat_map`(最新数据) +2. 按 `selected_rooms` 过滤(只存用户选中的房间) +3. 填充 `config.template_snapshot` -### 解决方案:使用 goods_spec_base.extends +### Step 2:BatchGenerate — 写入 extends -``` -BatchGenerate 写入时: - extends.seat_key = "room_id_rowLabel_colNum" (无 MD5) +insertGetId 中加入 `extends.seat_key = roomId_rowLabel_colNum` -GetGoodsViewData 读取时: - 遍历 goods_spec_base(inventory > 0) - → 解析 extends.seat_key - → 构建 spec_base_id_map[key] = id +### Step 3:GetGoodsViewData — 重写 -前端 submit() 时: - seatKey = seat.roomId + '_' + seat.rowLabel + '_' + seat.colNum - specBaseId = spec_base_id_map[seatKey] -``` +- 读 `vr_goods_config[0]`,透传 `template_snapshot` +- 从 `goods_spec_base.extends` 动态构建 `spec_base_id_map` +- `selected_sections` 适配对象格式 +- `goods_spec_data` 按场次聚合 -详见 `docs/VR_GOODS_CONFIG_SPEC.md` 第三章。 - ---- - -## 四、下一步工作(实现顺序) - -### Step 1:BatchGenerate 写入 extends - -```php -// SeatSkuService::BatchGenerate() 中,insertGetId 前: -$extends = json_encode([ - 'seat_key' => $roomId . '_' . $rowLabel . '_' . $col -], JSON_UNESCAPED_UNICODE); - -// insertGetId 中加入: -'extends' => $extends, -``` - -### Step 2:GetGoodsViewData 重写 - -```php -// 1. 读取 vr_goods_config[0] -// 2. 直接透传 rooms[] / sessions[] / selected_sections / venue -// 3. 动态构建 spec_base_id_map(从 goods_spec_base.extends) -// 4. 生成 goods_spec_data(场次+最低价聚合) -// 5. 返回 { vr_seat_template, goods_spec_data, goods_config } -``` - -### Step 3:ticket_detail.html JS +### Step 4:ticket_detail.html JS — seatKey 格式 ```javascript -// seatKey 格式改为带 roomId: +// 改为带 roomId: var seatKey = seat.roomId + '_' + seat.rowLabel + '_' + seat.colNum; -// 例:"room_id_1776341371905_A_3" var specBaseId = self.specBaseIdMap[seatKey] || 0; ``` --- -## 五、模板渲染当前状态 +## 三、Phase 2 当前状态 -| 项目 | 状态 | +| 任务 | 状态 | |------|------| | 模板渲染 | ✅ 正常 | | 票务 footer | ✅ 已精简 | -| 场次显示 | ❌ 待 GetGoodsViewData 重写 | -| 座位图渲染 | ❌ 待 GetGoodsViewData + JS 更新 | -| 已售座位标记 | ❌ 待 loadSoldSeats() 实现 | +| Issue #13 实现(v3.0 落地) | ⚠️ 待动手 | +| 核销 API | ❌ 未开始 | +| 后台 4 控制器联调 | ❌ 未开始 | diff --git a/docs/VR_GOODS_CONFIG_SPEC.md b/docs/VR_GOODS_CONFIG_SPEC.md index ff7c638..51ec6fe 100644 --- a/docs/VR_GOODS_CONFIG_SPEC.md +++ b/docs/VR_GOODS_CONFIG_SPEC.md @@ -1,17 +1,15 @@ # vr_goods_config JSON 规格说明 > 版本:v3.0 | 日期:2026-04-20 | 状态:**已确认,待实现** -> 关联提交:待实现 +> 关联 Issue:#13 --- -## ⚠️ 重要:v3.0 vs 旧版本的区别 +## ⚠️ v3.0 vs 旧版本的区别 -**v2.0(旧)**:rooms/sections/seats 存放在 `vr_seat_templates.seat_map` JSON 里,前端需要跨表查询。 +**旧版**:rooms/sections/seats 存放在 `vr_seat_templates.seat_map` JSON 里,前端需要跨表查询。 -**v3.0(新)**:完整快照直接嵌入 `goods.vr_goods_config`,前端完全不跨表。 - -**破坏性变更**:`selected_sections` 从**对象格式** `{"room_id": ["A","B"]}` 改为**数组格式** `["A","B"]`(每个房间一个选中分区列表)。 +**v3.0(最终)**:发布时将 `vr_seat_templates.seat_map` 快照存入 `template_snapshot`,和用户选择一起存储,前端完全不跨表。 --- @@ -20,39 +18,40 @@ ```json [ { - "version": 1.0, + "version": 3.0, "template_id": 4, "selected_rooms": ["room_id_1776341371905", "room_id_1776341444657"], - "selected_sections": ["A", "B"], + "selected_sections": { + "room_id_1776341371905": ["A", "B"], + "room_id_1776341444657": ["A"] + }, "sessions": [ { "start": "15:00", "end": "16:59" }, { "start": "18:00", "end": "21:59" } ], - "venue": { - "name": "测试 2", - "address": "测试地址", - "location": { "lng": "", "lat": "" }, - "images": [] - }, - "rooms": [ - { - "id": "room_id_1776341371905", - "name": "1号放映室VV", - "map": [ - "AAAAB__BBB_BAAAA", - "AAAAB__BBB_BAAAA", - "AAAAB__BBB_BAAAA" - ], - "sections": [ - { "char": "A", "name": "VIP区", "price": 100, "color": "#f06292" }, - { "char": "B", "name": "看台区", "price": 50, "color": "#4fc3f7" } - ], - "seats": { - "A": { "char": "A", "name": "VIP区", "price": 100, "color": "#f06292" }, - "B": { "char": "B", "name": "看台区", "price": 50, "color": "#4fc3f7" } + "template_snapshot": { + "venue": { + "name": "测试 2", + "address": "测试地址", + "location": { "lng": "", "lat": "" }, + "images": [] + }, + "rooms": [ + { + "id": "room_id_1776341371905", + "name": "1号放映室VV", + "map": ["AAAAB__BBB_BAAAA", "AAAAB__BBB_BAAAA"], + "sections": [ + { "char": "A", "name": "VIP区", "price": 100, "color": "#f06292" }, + { "char": "B", "name": "看台区", "price": 50, "color": "#4fc3f7" } + ], + "seats": { + "A": { "char": "A", "name": "VIP区", "price": 100, "color": "#f06292" }, + "B": { "char": "B", "name": "看台区", "price": 50, "color": "#4fc3f7" } + } } - } - ] + ] + } } ] ``` @@ -61,36 +60,51 @@ | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `version` | float | ✅ | 协议版本,当前 1.0,用于前向兼容判断 | -| `template_id` | int | ✅ | 来源场馆模板 ID(溯源用,不用于查询) | -| `selected_rooms` | string[] | ✅ | 本商品启用的房间 ID 列表(决定渲染哪些房间) | -| `selected_sections` | string[] | ✅ | 本商品选中的分区字符列表(当前房间的分区,`["A","B"]`) | -| `sessions` | object[] | ✅ | 本商品场次列表 | -| `venue` | object | ✅ | 场馆基本信息快照 | -| `rooms` | object[] | ✅ | 房间完整数据快照(直接复制自 vr_seat_templates.rooms) | +| `version` | float | ✅ | 协议版本(当前 3.0),用于前向兼容判断 | +| `template_id` | int | ✅ | 发布/编辑时读取最新 vr_seat_templates 的依据 | +| `selected_rooms` | string[] | ✅ | 用户选择:启用了哪些演播(房间 ID 列表) | +| `selected_sections` | object | ✅ | 用户选择:key=房间ID,value=该房间选中的分区字符列表 | +| `sessions` | object[] | ✅ | 用户管理:场次列表 | +| `template_snapshot` | object | ✅ | 发布时从 vr_seat_templates.seat_map 读取的快照(含 venue + rooms) | + +### selected_sections 格式说明 + +```json +"selected_sections": { + "room_id_1776341371905": ["A", "B"], + "room_id_1776341444657": ["A"] +} +``` + +key = 房间 ID,value = 该房间选中的分区字符数组。为什么用对象格式?因为同一个 section char(如 "A")可能在不同房间里代表不同的区(VIP区 vs 普通区),所以必须按 room_id 区分。 --- -## 二、selected_sections 格式说明 +## 二、设计意图 -**格式**:`string[]` — 选中分区的字符列表,应用于 `selected_rooms` 中的**当前选中房间**。 +### 流程说明 -**为什么不是对象**:`{"room_id": ["A","B"]}`(v2.0 旧格式)改为 `["A","B"]`。 - -**简化理由**:用户在后端编辑商品时,一次只能编辑一个房间的分区选择。所以 `selected_sections` 是"当前房间的选中分区",不需要跨房间存储。 - -```php -// AdminGoodsSaveHandle 中的转换(旧格式 → 新格式) -// 旧格式(已废弃): -// $config['selected_sections'] = { "room_id_xxx": ["A","B"] } - -// 新格式(v3.0): -// 前端传入 selected_sections: ["A","B"] -// 直接存储,不做任何转换 -$selectedSections = $config['selected_sections'] ?? []; - -// BatchGenerate 适配(详见第五章) ``` +商品发布/编辑时: + 前端提交 → selected_rooms / selected_sections / sessions + 后端 AdminGoodsSaveHandle → + 1. 用 template_id 读取 vr_seat_templates.seat_map(最新数据) + 2. 按 selected_rooms 过滤,填充 template_snapshot + 3. 和 selected_* 一起写入 goods.vr_goods_config + 4. BatchGenerate 生成 SKU +``` + +### 现有前端兼容性 + +- 前端只提交 `selected_rooms` / `selected_sections` / `sessions`,**不提交 `template_snapshot`** +- `template_snapshot` 由后端在保存时自动填充 +- 现有商品编辑体验**完全不受影响** + +### template_snapshot 的作用 + +- 前端渲染所需的所有座位图/sections/seats 数据都来自 `template_snapshot` +- `selected_rooms` / `selected_sections` 用于高亮、过滤等交互逻辑 +- 若 `template_snapshot` 为空(兼容旧商品),降级读 `vr_seat_templates` 表 --- @@ -101,20 +115,17 @@ $selectedSections = $config['selected_sections'] ?? []; ``` BatchGenerate() → 生成 GoodsSpecBase.id → 从未写入 spec_base_id_map ← 断路 - -Admin.php Save → 从 form 存旧格式 spec_base_id_map(空或无效) -GetGoodsViewData → 读 vr_seat_templates.spec_base_id_map(旧路径) -前端 JS → 用 "A_3" 格式查 → key 不匹配 → 永远查不到 +前端 JS → 用 "roomId_row_col" 格式查 → 永远查不到 ``` -### 3.2 解决方案:使用 goods_spec_base.extends 字段 +### 3.2 解决方案:使用 goods_spec_base.extends -ShopXO 原生 `goods_spec_base` 表有 `extends` 字段(JSON 扩展数据),我们的 BatchGenerate 每次都重建全量 spec(delete + insert),所以写入 `extends` 不会被覆盖。 +ShopXO 原生 `goods_spec_base` 表有 `extends` 字段(JSON 扩展数据)。BatchGenerate 每次都删除+重建全量 spec,放心写入 `extends`。 **存储时**(BatchGenerate 写入 GoodsSpecBase): ```php $extends = json_encode([ - 'seat_key' => $roomId . '_' . $rowLabel . '_' . $col // 例:"room_id_xxx_A_3" + 'seat_key' => $roomId . '_' . $rowLabel . '_' . $col ], JSON_UNESCAPED_UNICODE); Db::name('GoodsSpecBase')->insertGetId([ @@ -122,11 +133,11 @@ Db::name('GoodsSpecBase')->insertGetId([ 'price' => $seatPrice, 'inventory' => 1, // ... 其他字段 - 'extends' => $extends, // ← 新增 + 'extends' => $extends, ]); ``` -**读取时**(GetGoodsViewData 动态构建 spec_base_id_map): +**读取时**(GetGoodsViewData 动态构建): ```php $specs = Db::name('GoodsSpecBase') ->where('goods_id', $goodsId) @@ -146,53 +157,80 @@ foreach ($specs as $spec) { ```json { "room_id_1776341371905_A_3": 2001, - "room_id_1776341371905_B_5": 2002, - "room_id_1776341444657_A_1": 2050 + "room_id_1776341371905_B_5": 2002 } ``` -### 3.3 字段不冲突说明 +--- -`extends` 字段是 ShopXO 的标准扩展字段,ShopXO 自己的 GoodsSave 更新 spec 时只修改核心字段(price/inventory 等),不碰 `extends`。而我们的 BatchGenerate 在商品保存时全量重建 spec,`extends` 由我们写入,不存在被覆盖的风险。 +## 四、AdminGoodsSaveHandle 改动方案 + +### 保存时填充 template_snapshot + +在 `save_thing_end` 时机,BatchGenerate 之前: + +```php +// foreach ($configs as $config) 循环内: +$templateId = intval($config['template_id'] ?? 0); +$selectedRooms = $config['selected_rooms'] ?? []; + +// 1. 读取最新 vr_seat_templates.seat_map +$template = Db::name(self::table('seat_templates'))->find($templateId); +$seatMap = json_decode($template['seat_map'] ?? '{}', true); +$allRooms = $seatMap['rooms'] ?? []; + +// 2. 按 selected_rooms 过滤(只存用户选中的房间) +$filteredRooms = []; +foreach ($allRooms as $room) { + if (in_array($room['id'], $selectedRooms)) { + $filteredRooms[] = $room; + } +} + +// 3. 填充 template_snapshot +$config['template_snapshot'] = [ + 'venue' => $seatMap['venue'] ?? [], + 'rooms' => $filteredRooms, +]; + +// 4. 用更新后的 config 覆盖($configs[$i] = $config) +// 5. 后续 BatchGenerate 继续使用更新后的 $config +``` --- -## 四、前端数据结构(GetGoodsViewData 输出) +## 五、前端数据结构(GetGoodsViewData 输出) ```php [ 'vr_seat_template' => [ - 'venue' => $config['venue'], // 场馆信息 - 'rooms' => $config['rooms'], // 房间快照 - 'sessions' => $config['sessions'], // 场次列表 - 'selected_rooms' => $config['selected_rooms'], - 'selected_sections'=> $config['selected_sections'], - 'spec_base_id_map' => $specBaseIdMap, // 动态构建(见3.2) + 'venue' => $config['template_snapshot']['venue'] ?? [], + 'rooms' => $config['template_snapshot']['rooms'] ?? [], // 直接透传快照 + 'sessions' => $config['sessions'] ?? [], + 'selected_rooms' => $config['selected_rooms'] ?? [], + 'selected_sections'=> $config['selected_sections'] ?? {}, // {room_id: [chars]} + 'spec_base_id_map' => $specBaseIdMap, // 动态构建 ], - 'goods_spec_data' => [...], // 场次+价格(用于前端场次卡片) - 'goods_config' => $config // 原始 vr_goods_config[0] + 'goods_spec_data' => $goodsSpecData, + 'goods_config' => $config ] ``` ### goods_spec_data 生成逻辑 -从 `goods_spec_base` 按场次维度聚合(每个座位 = 一条 GoodsSpecBase): - ```php -// 取每个场次的最低价格作为卡片显示价 -$specs = Db::name('GoodsSpecBase') - ->where('goods_id', $goodsId) - ->where('inventory', '>', 0) - ->select(); +// 从 goods_spec_base 按场次维度聚合(每个座位 = 一条 GoodsSpecBase) +$specs = Db::name('GoodsSpecBase')->where('goods_id', $goodsId) + ->where('inventory', '>', 0)->select(); -$sessionPrices = []; // sessionStr => minPrice -$sessionSpecs = []; // sessionStr => first spec_base_id +$sessionPrices = []; +$sessionSpecs = []; foreach ($specs as $spec) { - // 从 goods_spec_value 找到场次维度值 + // 从 goods_spec_value 找到场次维度值(格式:"HH:mm-HH:mm") $sessionValue = Db::name('GoodsSpecValue') ->where('goods_spec_base_id', $spec['id']) - ->where('value', 'like', '%-%:%') + ->where('value', 'REGEXP', '^[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}$') ->find(); if ($sessionValue) { @@ -206,7 +244,6 @@ foreach ($specs as $spec) { } } -// 构建 goods_spec_data $goodsSpecData = []; foreach ($sessions as $s) { $start = $s['start'] ?? ''; @@ -234,7 +271,7 @@ var specBaseId = specBaseIdMap[seatKey] || 0; // goods_params 格式(每座一行) { goods_id: goodsId, - spec_base_id: specBaseId, // ← 用 seatKey 查到 + spec_base_id: specBaseId, stock: 1, extension_data: JSON.stringify({ attendee, seat: {...} }) } @@ -242,44 +279,33 @@ var specBaseId = specBaseIdMap[seatKey] || 0; --- -## 五、需要修改的文件 +## 六、需要修改的文件 | 文件 | 改动 | |------|------| -| `SeatSkuService::BatchGenerate()` | 写入 `extends` JSON(含 `seat_key`) | -| `SeatSkuService::GetGoodsViewData()` | 动态从 `extends` 构建 `spec_base_id_map`;适配 `selected_sections` 数组格式 | +| `AdminGoodsSaveHandle` | save_thing_end 中 BatchGenerate 之前:从 vr_seat_templates 读取并填充 `config.template_snapshot` | +| `SeatSkuService::BatchGenerate()` | insertGetId 中写入 `extends.seat_key` | +| `SeatSkuService::GetGoodsViewData()` | 重写:读 `template_snapshot`;从 `extends` 动态构建 `spec_base_id_map`;适配 `selected_sections` 对象格式 | | `ticket_detail.html` JS | `seatKey` 格式改为 `roomId + '_' + rowLabel + '_' + colNum` | -| `AdminGoodsSaveHandle` | selected_sections 透传(已经是数组格式,无需改动) | --- -## 六、数据库迁移(如需要) - -如后续需要在 `goods_spec_base` 加专用列(非必须,`extends` 已够用): - -```sql -ALTER TABLE `{{prefix}}goods_spec_base` ADD COLUMN `seat_key` VARCHAR(100) DEFAULT NULL COMMENT '座位键:roomId_row_col'; -``` - ---- - -## 七、版本判断(降级兼容) +## 七、降级兼容 ```php $config = json_decode($goods['vr_goods_config'] ?? '', true); if (empty($config)) { - return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null]; + return /* 错误 */; } -$config = $config[0]; +$config = $config[0] ?? $config; -if (empty($config['rooms'])) { - // v1/v2 旧格式:降级读 vr_seat_templates 表 +if (version_compare($config['version'] ?? 0, 3.0, '<')) { + // 旧版格式(无 template_snapshot):降级读 vr_seat_templates 表 return self::GetGoodsViewDataLegacy($goodsId, $config); } // v3.0 新格式 -$version = $config['version'] ?? 1.0; // ... ``` @@ -289,9 +315,9 @@ $version = $config['version'] ?? 1.0; | 决策 | 结论 | |------|------| -| `selected_sections` 格式 | 数组 `["A","B"]`,不是对象 | -| `spec_base_id_map` 存储 | 不入库,GetGoodsViewData 动态构建 | -| seat_key 存储位置 | `goods_spec_base.extends->seat_key`(JSON) | -| seat_key 格式 | `{roomId}_{rowLabel}_{colNum}`(无 MD5) | -| goods_spec_data.price | 取该场次所有座位中的最低价(用于卡片显示) | -| 降级兼容 | `version` 字段判断,v1/v2 走旧 vr_seat_templates 表路径 | +| `selected_sections` 格式 | 对象 `{ room_id: ["A","B"] }`(每个房间独立选择) | +| `template_snapshot` | 发布时从 vr_seat_templates.seat_map 读取并存储,不实时查表 | +| `spec_base_id_map` | 不入库,GetGoodsViewData 动态从 `extends.seat_key` 构建 | +| `seat_key` 格式 | `{roomId}_{rowLabel}_{colNum}`(无 MD5) | +| `goods_spec_data.price` | 取该场次所有座位中的最低价(用于卡片显示) | +| 现有前端兼容性 | ✅ 前端只提交选择项,template_snapshot 由后端保存时填充 |