11 KiB
11 KiB
vr_goods_config JSON 规格说明
版本:v3.0 | 日期:2026-04-20 | 状态:已确认,待实现 关联 Issue:#13
目录
- 一、vr_goods_config 完整结构
- 二、设计意图
- 三、spec_base_id_map 生成与存储
- 四、AdminGoodsSaveHandle 改动方案
- 五、前端数据结构
- 六、需要修改的文件
- 七、降级兼容
- 八、已确认的设计决策
⚠️ v3.0 vs 旧版本的区别
旧版:rooms/sections/seats 存放在 vr_seat_templates.seat_map JSON 里,前端需要跨表查询。
v3.0(最终):发布时将 vr_seat_templates.seat_map 快照存入 template_snapshot,和用户选择一起存储,前端完全不跨表。
一、vr_goods_config 完整结构
[
{
"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 格式说明
"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):
$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 动态构建):
$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 最终格式:
{
"room_id_1776341371905_A_3": 2001,
"room_id_1776341371905_B_5": 2002
}
四、AdminGoodsSaveHandle 改动方案
保存时填充 template_snapshot
在 save_thing_end 时机,BatchGenerate 之前:
// 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 输出)
[
'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 生成逻辑
// 从 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 使用方式
// 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 |
七、降级兼容
$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 由后端保存时填充 |