vr-shopxo-plugin/docs/VR_GOODS_CONFIG_SPEC.md

350 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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=房间IDvalue=该房间选中的分区字符列表 |
| `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 = 房间 IDvalue = 该房间选中的分区字符数组。为什么用对象格式?因为同一个 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 = <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>;
// 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 由后端保存时填充 |