# 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=房间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
```

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