# vr_goods_config JSON 规格说明 > 版本:v3.0 | 日期:2026-04-20 | 状态:**已确认,待实现** > 关联 Issue:#13 ## 目录 - [一、vr_goods_config 完整结构](#一vr_goods_config-完整结构) - [二、设计意图](#二设计意图) - [三、spec_base_id_map 生成与存储](#三spec_base_id_map-生成与存储) - [四、AdminGoodsSaveHandle 改动方案](#四admingoodsshandler-改动方案) - [五、前端数据结构](#五前端数据结构) - [六、需要修改的文件](#六需要修改的文件) - [七、降级兼容](#七降级兼容) - [八、已确认的设计决策](#八已确认的设计决策) --- ## ⚠️ v3.0 vs 旧版本的区别 **旧版**:rooms/sections/seats 存放在 `vr_seat_templates.seat_map` JSON 里,前端需要跨表查询。 **v3.0(最终)**:发布时将 `vr_seat_templates.seat_map` 快照存入 `template_snapshot`,和用户选择一起存储,前端完全不跨表。 --- ## 一、vr_goods_config 完整结构 ```json [ { "version": 3.0, "template_id": 4, "selected_rooms": ["room_id_1776341371905", "room_id_1776341444657"], "selected_sections": { "room_id_1776341371905": ["A", "B"], "room_id_1776341444657": ["A"] }, "sessions": [ { "start": "15:00", "end": "16:59" }, { "start": "18:00", "end": "21:59" } ], "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" } } } ] } } ] ``` ### 字段说明 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `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_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 ``` ### template_snapshot 的前端职责 vs 后端职责 **前端职责(Admin 编辑页)**: - 用户打开新建/编辑页时,前端用 `template_id` 读取**最新** `vr_seat_templates` - 将 `venue` + `rooms` 快照填入 `template_snapshot`,随表单一起提交 - 编辑过程中模板变化了?以打开页面时的快照为准,**不重新读**(避免不确定性) **后端职责(AdminGoodsSaveHandle)**: - 检测 `template_snapshot` 是否缺失,若缺失则从 `vr_seat_templates` 读表填充(兜底) - 将填充后的完整 config 写回 `goods.vr_goods_config` - 再执行 `BatchGenerate` 生成 SKU **数据层分离**: - `template_snapshot` → **前端渲染用**(展示层) - `vr_seat_templates` 实时读取 → **BatchGenerate 生成 SKU**(数据层) ### 现有前端兼容性 - 前端在编辑页加载时填充 `template_snapshot` 并提交 - `AdminGoodsSaveHandle` 若检测到未传则自动填充(兜底),完全透明 - 现有商品编辑体验**完全不受影响** ### template_snapshot 的作用 - 前端渲染所需的所有座位图/sections/seats 数据都来自 `template_snapshot` - `selected_rooms` / `selected_sections` 用于高亮、过滤等交互逻辑 - 若 `template_snapshot` 为空(兼容旧商品),降级读 `vr_seat_templates` 表 --- ## 三、spec_base_id_map 生成与存储 ### 3.1 当前断路问题 ``` BatchGenerate() → 生成 GoodsSpecBase.id → 从未写入 spec_base_id_map ← 断路 前端 JS → 用 "roomId_row_col" 格式查 → 永远查不到 ``` ### 3.2 解决方案:使用 goods_spec_base.extends ShopXO 原生 `goods_spec_base` 表有 `extends` 字段(JSON 扩展数据)。BatchGenerate 每次都删除+重建全量 spec,放心写入 `extends`。 **存储时**(BatchGenerate 写入 GoodsSpecBase): ```php $extends = json_encode([ 'seat_key' => $roomId . '_' . $rowLabel . '_' . $col ], JSON_UNESCAPED_UNICODE); Db::name('GoodsSpecBase')->insertGetId([ 'goods_id' => $goodsId, 'price' => $seatPrice, 'inventory' => 1, // ... 其他字段 'extends' => $extends, ]); ``` **读取时**(GetGoodsViewData 动态构建): ```php $specs = Db::name('GoodsSpecBase') ->where('goods_id', $goodsId) ->where('inventory', '>', 0) ->select(); $specBaseIdMap = []; foreach ($specs as $spec) { $ext = json_decode($spec['extends'] ?? '{}', true); if (!empty($ext['seat_key'])) { $specBaseIdMap[$ext['seat_key']] = intval($spec['id']); } } ``` **spec_base_id_map 最终格式**: ```json { "room_id_1776341371905_A_3": 2001, "room_id_1776341371905_B_5": 2002 } ``` --- ## 四、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 输出) ```php [ 'vr_seat_template' => [ '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' => $goodsSpecData, 'goods_config' => $config ] ``` ### goods_spec_data 生成逻辑 ```php // 从 goods_spec_base 按场次维度聚合(每个座位 = 一条 GoodsSpecBase) $specs = Db::name('GoodsSpecBase')->where('goods_id', $goodsId) ->where('inventory', '>', 0)->select(); $sessionPrices = []; $sessionSpecs = []; foreach ($specs as $spec) { // 从 goods_spec_value 找到场次维度值(格式:"HH:mm-HH:mm") $sessionValue = Db::name('GoodsSpecValue') ->where('goods_spec_base_id', $spec['id']) ->where('value', 'REGEXP', '^[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}$') ->find(); if ($sessionValue) { $sessionStr = $sessionValue['value']; if (!isset($sessionPrices[$sessionStr]) || $spec['price'] < $sessionPrices[$sessionStr]) { $sessionPrices[$sessionStr] = floatval($spec['price']); } if (!isset($sessionSpecs[$sessionStr])) { $sessionSpecs[$sessionStr] = intval($spec['id']); } } } $goodsSpecData = []; foreach ($sessions as $s) { $start = $s['start'] ?? ''; $end = $s['end'] ?? ''; $sessionStr = $start && $end ? "{$start}-{$end}" : ($start ?: $end); $goodsSpecData[] = [ 'spec_id' => $sessionSpecs[$sessionStr] ?? 0, 'spec_name' => $sessionStr, 'price' => $sessionPrices[$sessionStr] ?? floatval($goods['price'] ?? 0), ]; } ``` ### 前端 JS 使用方式 ```javascript // ticket_detail.html JS var specBaseIdMap = ; // submit() 时:根据选中座位查 spec_base_id var seatKey = seat.roomId + '_' + seat.rowLabel + '_' + seat.colNum; // 例:"room_id_1776341371905_A_3" var specBaseId = specBaseIdMap[seatKey] || 0; // goods_params 格式(每座一行) { goods_id: goodsId, spec_base_id: specBaseId, stock: 1, extension_data: JSON.stringify({ attendee, seat: {...} }) } ``` --- ## 六、需要修改的文件 | 文件 | 改动 | |------|------| | `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` | --- ## 七、降级兼容 ```php $config = json_decode($goods['vr_goods_config'] ?? '', true); if (empty($config)) { return /* 错误 */; } $config = $config[0] ?? $config; if (version_compare($config['version'] ?? 0, 3.0, '<')) { // 旧版格式(无 template_snapshot):降级读 vr_seat_templates 表 return self::GetGoodsViewDataLegacy($goodsId, $config); } // v3.0 新格式 // ... ``` --- ## 八、已确认的设计决策 | 决策 | 结论 | |------|------| | `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 由后端保存时填充 |