# Phase 3 P0 — 5维 Spec 重构：演播室层补全

> 版本：v1.0 | 日期：2026-04-22 | 状态：**P0 阻塞**

---

## 一、问题定义

### 1.1 现象

当前商品详情页（ticket_detail.html）可以选择：
- 场次
- 场馆
- 分区（A区/B区/C区）
- 座位

但设计文档要求的层级是：
```
场次 → 场馆 → 演播室 → 分区 → 座位号
```

**演播室（第3层）完全消失**。用户在整个购买流程中感知不到这一层的存在。

### 1.2 影响

- seat_map JSON 没有 `rooms` 字段，只有 flat `sections[]`
- GoodsSpecType 里没有 `$vr-演播室` 记录
- SPEC_DIMS 常量只有 4 维（缺演播室）
- buildSeatSpecMap() 无法输出演播室维度
- 前后端代码虽有 `rooms` fallback 预留，但从未真正启用

---

## 二、数据模型现状

### 2.1 当前 seat_map JSON（goods_id=112，座位模板 ID=1）

```json
{
  "venue": {
    "name": "国家体育馆",
    "address": "北京市朝阳区奥体中心",
    "image": ""
  },
  "sections": [
    {"char": "A", "name": "VIP区", "color": "#e74c3c"},
    {"char": "B", "name": "看台",  "color": "#3498db"},
    {"char": "C", "name": "普通",  "color": "#2ecc71"}
  ],
  "map": ["AAAAAA", "BBBBBB", "CCCCCC"],
  "seats": {
    "A": {"price": 899, "color": "#e74c3c", "label": "VIP"},
    "B": {"price": 599, "color": "#3498db", "label": "看台"},
    "C": {"price": 299, "color": "#2ecc71", "label": "普通"}
  },
  "row_labels": ["A", "B", "C"]
}
```

**问题**：`sections`、`map`、`seats` 都是 flat 结构，没有 `rooms` 嵌套层。

### 2.2 目标 seat_map JSON

```json
{
  "venue": {
    "name": "国家体育馆",
    "address": "北京市朝阳区奥体中心",
    "image": ""
  },
  "rooms": [
    {
      "id": "room_001",
      "name": "主厅",
      "sections": [
        {"char": "A", "name": "VIP区", "color": "#e74c3c"},
        {"char": "B", "name": "看台",  "color": "#3498db"},
        {"char": "C", "name": "普通",  "color": "#2ecc71"}
      ],
      "map": ["AAAAAA", "BBBBBB", "CCCCCC"],
      "seats": {
        "A": {"price": 899, "color": "#e74c3c", "label": "VIP"},
        "B": {"price": 599, "color": "#3498db", "label": "看台"},
        "C": {"price": 299, "color": "#2ecc71", "label": "普通"}
      }
    }
  ]
}
```

**变更说明**：
- `sections`、`map`、`seats` 从 flat 移入 `rooms[0]`
- `rooms[].id` = 演播室标识（`room_001`）
- `rooms[].name` = 演播室名称（`主厅`）
- 保留 flat `sections/map/seats` 作为 fallback（Admin.php:646 和 ticket_detail.html:262 已有兼容逻辑）
- 未来可扩展多个 room（多厅模式）

### 2.3 当前 GoodsSpecType（goods_id=112）

| ID  | name           | 含义         | value 示例 |
|-----|----------------|-------------|-----------|
| 1942 | `$vr-场馆`     | 场馆         | `[{"name":"国家体育馆","images":""}]` |
| 1943 | `$vr-分区`     | 分区         | `[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]` |
| 1944 | `$vr-时段`     | 场次时间      | `[{"name":"2026-05-01 19:00","images":""}]` |
| 1945 | `$vr-座位号`   | 座位号       | `[{"name":"待选座位","images":""}]` |

**注意**：数据库里是 `$vr-时段`，SPEC_DIMS 里是 `$vr-场次`，需统一命名。

### 2.4 GoodsSpecValue（goods_id=112）

**当前：0 条**。

BatchGenerate() 虽然写了 GoodsSpecBase（SKU 存在，有价格/库存），但没有写 GoodsSpecValue（维度连接表），导致 buildSeatSpecMap() 只能从 seat_map JSON 反推维度，演播室完全丢失。

---

## 三、目标 5 维 Spec 结构

### 3.1 SPEC_DIMS 常量（目标值）

```php
const SPEC_DIMS = [
    '$vr-场次',    // 第1维：场次时间（来自 GoodsSpecType）
    '$vr-场馆',    // 第2维：场馆名（来自 GoodsSpecType）
    '$vr-演播室',  // 第3维：演播室（新增！）
    '$vr-分区',    // 第4维：分区/区号（来自 seat_map.rooms[].sections）
    '$vr-座位号',  // 第5维：座位号（来自 seat_key 的 row_col 部分）
];
```

**命名统一**：`$vr-场次` 替代 `$vr-时段`。

### 3.2 seat_key 格式（不变）

```
{room_id}_{row_label}_{col_num}
例：room_001_A_1  →  主厅 A排1号
```

### 3.3 GoodsSpecType 目标记录（goods_id=112）

| 顺序 | name           | 来源         | value 说明 |
|-----|----------------|-------------|-----------|
| 1   | `$vr-场次`     | 商品规格维度  | 场次时间列表 |
| 2   | `$vr-场馆`     | 商品规格维度  | 场馆名 |
| 3   | `$vr-演播室`   | seat_map.rooms[].name | 演播室列表 |
| 4   | `$vr-分区`     | seat_map.rooms[].sections[].name | 分区列表 |
| 5   | `$vr-座位号`   | seat_key row_col | 座位号（自动生成）|

### 3.4 buildSeatSpecMap() 目标输出

```php
$seatSpecMap['room_001_A_1'] = [
    'spec_base_id' => 123,
    'price'        => 899.00,
    'inventory'   => 1,
    'spec'         => [
        ['type' => '$vr-场次',   'value' => '2026-05-01 19:00'],
        ['type' => '$vr-场馆',   'value' => '国家体育馆'],
        ['type' => '$vr-演播室', 'value' => '主厅'],        // ← 新增
        ['type' => '$vr-分区',   'value' => 'VIP区'],
        ['type' => '$vr-座位号', 'value' => 'A1'],
    ],
    'venueName'   => '国家体育馆',
    'roomId'      => 'room_001',
    'roomName'    => '主厅',     // ← 新增
    'section'     => ['char' => 'A', 'name' => 'VIP区', 'color' => '#e74c3c'],
    'rowLabel'    => 'A',
    'colNum'      => 1,
];
```

---

## 四、受影响文件清单

### 4.1 数据库（需 Migration）

| 表 | 操作 | 说明 |
|----|------|------|
| `vrt_goods_spec_type` | 清空 goods_id=112 的旧记录，重新插入5条 | 加 `$vr-演播室`，统一 `$vr-场次` |
| `vrt_goods_spec_base` | 保留（已有 sku + seat_key） | 不改结构 |
| `vrt_goods_spec_value` | 清空重建，按5维生成 | 连接 spec_base_id 和维度 |
| `vrt_vr_seat_templates` | 更新 seat_map JSON | 加 rooms 层 |
| `vrt_vr_goods_config` | 检查 config JSON 是否受影响 | 通常只存 template_id 和快照 |

> 当前数据量极小（1个模板，0条 GoodsSpecValue），可直接 truncate 后重生成。

### 4.2 PHP 文件

| 文件 | 行号 | 改动 |
|------|------|------|
| `SeatSkuService.php` | 29 | `SPEC_DIMS` 改为5维，加 `$vr-演播室`，`$vr-场次` 替代 `$vr-时段` |
| `SeatSkuService.php` | ~171-178 | `batchGenerate()` 按5维提取维度，需加演播室提取 |
| `SeatSkuService.php` | ~270 | `whereIn('name', SPEC_DIMS)` 过滤5个维度 |
| `SeatSkuService.php` | ~306 | 插入缺失维度逻辑，按5维顺序 |
| `SeatSkuService.php` | ~522-700 | `buildSeatSpecMap()` 完全重写：从 rooms[] 结构读取，加 roomName 提取 |
| `SeatSkuService.php` | ~648-665 | switch case 加 `$vr-演播室` |
| `BaseService.php` | 187-190 | 维度默认值数组加 `$vr-演播室` |
| `TicketService.php` | 65,68 | `$vr-座位号` / `$vr-分区` 不变 |
| `Admin.php` | ~176-191 | VenueSave 保存 seat_map 时自动包 rooms 层（fallback已有） |

### 4.3 前端文件

| 文件 | 改动 |
|------|------|
| `ticket_detail.html` | `specTypeList` 增加 `$vr-演播室` 选择器；`renderAllSelectors()` 渲染演播室选择器 |
| `ticket_detail.html` | `filterSeats()` 增加 `currentRoom` 过滤条件 |
| `ticket_detail.html` | `submit()` 的 spec 数组加 `$vr-演播室` 维度 |

### 4.4 不需要改的文件（fallback 已存在）

- `Admin.php:646-653` — 已有 `$seatMap['rooms']` fallback，会自动适配新 JSON
- `ticket_detail.html:262` — 已有 `seatMapData.rooms[0].map` fallback
- `ticket_detail.html:269-272` — 已有 `rooms` fallback 逻辑

---

## 五、Migration 执行步骤

> 假设 goods_id=112，座位模板 ID=1。

### Step 1：更新 seat_map JSON（座位模板）

```sql
-- 查看当前 seat_map
SELECT id, name, seat_map FROM vrt_vr_seat_templates WHERE id=1;

-- 更新 JSON 结构：加 rooms 层
-- 旧结构 flat sections/map/seats → 移入 rooms[0]
```

JSON 转换伪代码：
```php
$old = json_decode($old_seat_map, true);
$new = [
    'venue' => $old['venue'],
    'rooms' => [[
        'id'       => 'room_001',
        'name'     => '主厅',
        'sections' => $old['sections'] ?? [],
        'map'      => $old['map'] ?? [],
        'seats'    => $old['seats'] ?? [],
    ]],
    // 保留 flat fallback（兼容旧代码）
    'sections' => $old['sections'] ?? [],
    'map'      => $old['map'] ?? [],
    'seats'    => $old['seats'] ?? [],
];
```

### Step 2：重建 GoodsSpecType（5维）

```sql
DELETE FROM vrt_goods_spec_type WHERE goods_id=112;

INSERT INTO vrt_goods_spec_type (goods_id, name, value, add_time) VALUES
(112, '$vr-场次',   '[{"name":"2026-05-01 19:00","images":""}]', UNIX_TIMESTAMP()),
(112, '$vr-场馆',   '[{"name":"国家体育馆","images":""}]', UNIX_TIMESTAMP()),
(112, '$vr-演播室', '[{"name":"主厅","images":""}]', UNIX_TIMESTAMP()),
(112, '$vr-分区',   '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', UNIX_TIMESTAMP()),
(112, '$vr-座位号', '[{"name":"待选座位","images":""}]', UNIX_TIMESTAMP());
```

### Step 3：重建 GoodsSpecValue（连接 goods_spec_base 和维度）

当前 GoodsSpecBase 有 sku + extends.seat_key。需要生成 5 条 GoodsSpecValue 记录，每条对应一个维度。

GoodsSpecValue 表结构：
```sql
-- goods_spec_base_id → 哪个 SKU
-- name → 维度名（如 $vr-演播室）
-- value → 维度值（如 主厅）
-- md5_key → 唯一键
```

生成逻辑（参考 buildSeatSpecMap）：
1. 遍历所有 GoodsSpecBase（goods_id=112, inventory>0）
2. 从 extends.seat_key 解析 room_id, row_label, col_num
3. 从 seat_map JSON 反查 roomName（通过 room_id）
4. 生成 5 条 GoodsSpecValue

### Step 4：验证

1. 访问商品详情页，检查 specTypeList 是否包含 5 个维度
2. 检查前端演播室选择器是否正确渲染
3. 选择座位后 submit，检查 goods_data 中的 spec 数组是否有 5 个维度
4. 检查 BuyService::BuyGoods 能正确解析 5 维 goods_data

---

## 六、前端交互变更

### 6.1 新的选择器层级

```
[场次选择器]  ← goods_spec_data（已有）
[场馆选择器]  ← specTypeList['$vr-场馆'].options（已有）
[演播室选择器] ← specTypeList['$vr-演播室'].options（新增！）
[分区选择器]  ← specTypeList['$vr-分区'].options（已有）
[座位图]     ← 按 currentRoom/currentSection 过滤（已有 filterSeats，需加 room 过滤）
```

### 6.2 filterSeats() 变更

```javascript
// 现有
if (self.currentVenue) { matchVenue = ... }
if (self.currentSection) { matchSection = ... }

// 新增
if (self.currentRoom) {
    matchRoom = false;
    for (var i = 0; i < seatInfo.spec.length; i++) {
        if (seatInfo.spec[i].type === '$vr-演播室' && seatInfo.spec[i].value === self.currentRoom) {
            matchRoom = true;
            break;
        }
    }
}
```

### 6.3 submit() spec 数组格式（不变）

```javascript
spec: seatInfo.spec  // 5维完整数组
```

---

## 七、已知约束

1. **当前是单 room 模式**（rooms[0]），演播室选择器默认选主厅，用户不可切换。未来可扩展多 room。
2. **GoodsSpecValue 为 0 的根因**：BatchGenerate() 没有写 GoodsSpecValue，只有 GoodsSpecBase。这是之前就存在的问题，不是本次引入的。本次修复 BatchGenerate 的同时也要补全 GoodsSpecValue。
3. **命名统一**：`$vr-时段` → `$vr-场次`，涉及 DB 数据和 SPEC_DIMS 常量。

---

## 八、验收标准

- [ ] seat_map JSON 有 `rooms[]` 结构，sections/map/seats 移入 rooms[0]
- [ ] GoodsSpecType 有 5 条记录，包含 `$vr-演播室`
- [ ] SPEC_DIMS 是 5 维数组
- [ ] buildSeatSpecMap() 输出 seatSpecMap 包含 roomName 和 `$vr-演播室` 维度
- [ ] 前端有演播室选择器
- [ ] filterSeats() 按 currentRoom 过滤
- [ ] submit() 提交的 spec 数组有 5 个维度
- [ ] BuyService::BuyGoods 能正确处理 5 维 goods_data

---

## 九、相关文档

- `docs/SESSION_REPORT_20260421_PHASE2_FIX.md` — Phase 2 会话记录
- `docs/FULL_PLAN.md` — 完整开发计划
- `docs/PLAN_GHOST_SPEC_FIX.md` — 幽灵 spec 修复（相关）
- `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` — 核心服务
- `shopxo/app/plugins/vr_ticket/admin/Admin.php` — 座位模板管理
