vr-shopxo-plugin/docs/VR_GOODS_CONFIG_SPEC.md

10 KiB
Raw Blame History

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 完整结构

[
  {
    "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 格式说明

"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

$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 由后端保存时填充