vr-shopxo-plugin/docs/VR_GOODS_CONFIG_SPEC.md

334 lines
10 KiB
Markdown
Raw Normal View History

# 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
```
### 现有前端兼容性
- 前端只提交 `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 由后端保存时填充 |