324 lines
9.9 KiB
Markdown
324 lines
9.9 KiB
Markdown
# vr_goods_config JSON 规格说明
|
||
|
||
> 版本:v3.0 | 日期:2026-04-20 | 状态:**已确认,待实现**
|
||
> 关联 Issue:#13
|
||
|
||
---
|
||
|
||
## ⚠️ 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
|
||
```
|
||
|
||
### 现有前端兼容性
|
||
|
||
- 前端只提交 `selected_rooms` / `selected_sections` / `sessions`,**不提交 `template_snapshot`**
|
||
- `template_snapshot` 由后端在保存时自动填充
|
||
- 现有商品编辑体验**完全不受影响**
|
||
|
||
### 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 由后端保存时填充 |
|