# 后台编辑器 + 商品发布注入方案设计

> 版本：v3.0 | 日期：2026-04-15 21:45 | 状态：**已确认，待执行**
>
> v3.0 更新（2026-04-15 21:45 大头确认）：
> 1. venue 信息放入 seat_map 顶层（不独立建表）
> 2. BatchGenerate() 必须支持按分区过滤（enabledZones 参数）
> 3. 新增 sxo_goods.vr_ticket_config JSON 字段，老商品兼容
> 4. 增加美化版 seat_map 示例 JSON

---

## 一、整体架构一句话

**插件在 ShopXO 后台建一套"场馆配置"管理界面，用户发布票务商品时，选场馆 → 选分区 → 插件自动只生成这些分区的海量 Spec 并注入商品，用户全程不碰 ShopXO 原生 Spec 管理。**

---

## 二、三大核心区域

```
┌─────────────────────────────────────────────────────────────┐
│  ShopXO 原生后台                                               │
│                                                               │
│  ┌──────────────────┐     ┌────────────────────────┐        │
│  │ 商品管理           │ ←→  │ 插件专属后台（新增）       │        │
│  │  发布/编辑商品     │     │ 场馆配置管理               │        │
│  │  （注入点）        │     │ 座位分区模板编辑器         │        │
│  └──────────────────┘     └────────────────────────┘        │
└─────────────────────────────────────────────────────────────┘
```

### 区域 A：插件专属后台（新增）

商户在 ShopXO 后台左侧菜单进入「VR票务」：

```
VR票务
  ├── 场馆配置        ← Phase 3-1 新增
  ├── 座位模板        ← 已存在（Phase 2）
  ├── 电子票管理      ← 已存在
  ├── 核销员管理      ← 已存在
  └── 核销记录        ← 已存在
```

> ⚠️ **当前状态**：`Venue.php` 控制器不存在，Phase 3-1 从零新建。

### 区域 B：ShopXO 商品发布页（注入点）

> ⚠️ **待核实**：商品发布页 Tab 名称需对照 `saveinfo.html` 模板确认。

商户在 ShopXO 后台「商品管理 → 添加商品」：

```
添加商品
  [商品名称] [商品分类]

  ▼ 票务配置           ← ShopXO 原生区域，插件注入票务选择器
    场馆：[请选择场馆 ▼]     ← 来自 vr_seat_templates 表
    分区：[□VIP区  □看台区  □普通区]   ← 多选，根据场馆联动

  [商品详情 富文本编辑器]
  ▼ 其他Tab（参数/图片等）← ShopXO 原生
```

### 区域 C：插件商品详情页（已存在）

用户在前台看到票务商品详情页，选座下单，已实现。

---

## 三、场馆配置管理（区域 A）

### 3.1 seat_map 数据结构（v3.0 最终版）

```json
{
  "venue": {
    "name": "国家体育馆",
    "address": "北京市朝阳区奥体中心",
    "image": "/uploads/vr/venue/national-stadium.jpg"
  },
  "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": [
    { "char": "A", "name": "VIP区",   "color": "#e74c3c" },
    { "char": "B", "name": "看台区", "color": "#3498db" },
    { "char": "C", "name": "普通区", "color": "#2ecc71" }
  ],
  "row_labels": ["A", "B", "C"]
}
```

**字段说明**：

| 字段 | 类型 | 说明 |
|------|------|------|
| `venue` | object | 场馆信息。**v3.0 新增**。name 即 `$vr-场馆` spec 值，不再硬编码 |
| `map` | string[] | 每排座位数字符串（A=6座，B=6座，C=6座），决定总座位数 |
| `seats` | object | **字段名是 seats 不是 zones**。每行（char）的默认 price/color/label |
| `sections` | object[] | 每个分区的元信息（char → name/color 映射），前端分区选择器用此渲染多选框 |
| `row_labels` | string[] | 行标签，用于前端渲染座位坐标（"A_1", "A_2"...）|

**`$vr-场馆` spec 值来源**：从 `seat_map.venue.name` 读取，Phase 3-1 改造 `BatchGenerate`。

**与旧数据的兼容性**：
- 存量 seat_map 无 `venue` 顶层 → 降级取 `vr_seat_templates.name` 作为 `$vr-场馆` 值
- 存量 seat_map 无 `sections` 顶层 → 降级从 `seats` 推导

### 3.2 美化版 seat_map 示例（Phase 3-1 表单填充示例）

**示例 1：大型演唱会（3 区，每区 6 座）**
```json
{
  "venue": {
    "name": "鸟巢",
    "address": "北京市朝阳区国家体育场南路1号",
    "image": "/uploads/vr/venue/bird-nest.jpg"
  },
  "map": ["AAAAAA", "BBBBBB", "CCCCCC"],
  "seats": {
    "A": { "price": 1299, "color": "#e74c3c", "label": "内场VIP" },
    "B": { "price": 899,  "color": "#9b59b6", "label": "内场A区" },
    "C": { "price": 599,  "color": "#3498db", "label": "内场B区" }
  },
  "sections": [
    { "char": "A", "name": "内场VIP", "color": "#e74c3c" },
    { "char": "B", "name": "内场A区", "color": "#9b59b6" },
    { "char": "C", "name": "内场B区", "color": "#3498db" }
  ],
  "row_labels": ["A", "B", "C"]
}
```

**示例 2：小型剧场（2 区，每区 4 座）**
```json
{
  "venue": {
    "name": "上海音乐厅",
    "address": "上海市黄浦区延安东路523号",
    "image": "/uploads/vr/venue/shanghai-concert-hall.jpg"
  },
  "map": ["AAAA", "BBBB"],
  "seats": {
    "A": { "price": 499, "color": "#f39c12", "label": "前排" },
    "B": { "price": 299, "color": "#1abc9c", "label": "后排" }
  },
  "sections": [
    { "char": "A", "name": "前排", "color": "#f39c12" },
    { "char": "B", "name": "后排", "color": "#1abc9c" }
  ],
  "row_labels": ["A", "B"]
}
```

**示例 3：开放部分分区（电影院场景）**
```json
{
  "venue": {
    "name": "IMAX 影城",
    "address": "北京市朝阳区建国路88号",
    "image": "/uploads/vr/venue/imax-cinema.jpg"
  },
  "map": ["AAAAAA", "BBBBBB", "CCCCCC", "DDDDDD", "EEEEE"],
  "seats": {
    "A": { "price": 99, "color": "#e74c3c", "label": "情侣座" },
    "B": { "price": 69, "color": "#3498db", "label": "VIP厅" },
    "C": { "price": 49, "color": "#2ecc71", "label": "普通座" },
    "D": { "price": 49, "color": "#2ecc71", "label": "普通座" },
    "E": { "price": 39, "color": "#95a5a6", "label": "边座" }
  },
  "sections": [
    { "char": "A", "name": "情侣座", "color": "#e74c3c" },
    { "char": "B", "name": "VIP厅", "color": "#3498db" },
    { "char": "C", "name": "普通座", "color": "#2ecc71" },
    { "char": "D", "name": "普通座", "color": "#2ecc71" },
    { "char": "E", "name": "边座",   "color": "#95a5a6" }
  ],
  "row_labels": ["A", "B", "C", "D", "E"]
}
```
> 示例 3 场景：发布商品时只选 A+B 分区，C/D/E 暂不开放。`BatchGenerate(enabledZones=['A','B'])` 只生成 12 个座位 SKU。

### 3.3 场馆配置管理页面

> ⚠️ **待实现**：当前 `Venue.php` 控制器不存在。

商户在插件后台「场馆配置 → 添加」看到的表单：

```
【场馆基本信息】
  场馆名称：[_______________________________]
  场馆地址：[_______________________________]
  场馆图片：[上传按钮]

【分区配置】（增删分区，拖拽排序）
  ┌──────────────────────────────────────────────────┐
  │ Char │ 标签      │ 单价（元）│ 颜色      │       │
  ├──────────────────────────────────────────────────┤
  │ A    │ 内场VIP   │ 1299      │ [■红    ▼]│ [删除] │
  ├──────────────────────────────────────────────────┤
  │ B    │ 内场A区   │ 899       │ [■紫    ▼]│ [删除] │
  ├──────────────────────────────────────────────────┤
  │ C    │ 内场B区   │ 599       │ [■蓝    ▼]│ [删除] │
  └──────────────────────────────────────────────────┘
  [+ 添加分区]

【座位排布预览】（实时渲染）
  🔴 🔴 🔴 🔴 🔴 🔴    ← A区（VIP，1299元）
  🟣 🟣 🟣 🟣 🟣 🟣    ← B区（内场A区，899元）
  🔵 🔵 🔵 🔵 🔵 🔵    ← C区（内场B区，599元）

  每排座位数：[6___]   总座位数：18

【保存】  【取消】
```

**技术实现**：
- layui 表单 + Vue3 CDN（轻量，不破坏 ShopXO 后台 jQuery/layui 结构）
- ~500 行前端代码，1-1.5 人天
- 保存：表单 → seat_map JSON → 写入 `vr_seat_templates.seat_map`
- 颜色选择器：预设色盘（不引入额外依赖）

---

## 四、商品发布页注入（区域 B）

### 4.1 注入原理

```
ShopXO admin Goods::SaveInfo()
  → 调用 hook plugins_view_admin_goods_save（Goods.php:159）
  → 触发 vr_ticket/hook/AdminGoodsSave.php
  → 返回票务配置面板 HTML
  → 插入 saveinfo.html base tab <div class="am-form-group"> 内
```

### 4.2 钩子注册方式

> ⚠️ **重要**：ShopXO **不支持** `backend_hook` 字段，实际使用 `hooks` 数组。

**plugin.json 追加**（Phase 3-2）：
```json
{
  "hooks": [
    "plugins_service_order_pay_success_handle_end",
    "plugins_service_order_delete_success",
    "plugins_view_admin_goods_save",
    "plugins_service_goods_save_handle"
  ]
}
```

### 4.3 AdminGoodsSave.php（待新建）

> ⚠️ **待实现**：`vr_ticket/` 下当前无此文件。

**文件**：`plugins/vr_ticket/hook/AdminGoodsSave.php`

职责：
- 查询 `vr_seat_templates` 表，构造场馆下拉 + 分区多选 HTML
- 返回 layui + Vue3 CDN 的票务配置面板

**数据来源**：
```php
$templates = Db::name('vr_seat_templates')
    ->field('id, name, seat_map, category_id')
    ->where(['status' => 1])
    ->select();

foreach ($templates as &$t) {
    $seatMap = json_decode($t['seat_map'], true);
    // v3.0: venue.name 优先，否则降级取模板 name
    $t['venue_name']  = $seatMap['venue']['name'] ?? $t['name'];
    // sections 用于渲染分区多选框
    $t['zones']       = $seatMap['sections'] ?? [];
}
```

### 4.4 AdminGoodsSaveHandle.php（待新建）

**文件**：`plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`

触发时机：`plugins_service_goods_save_handle`（GoodsService.php:1550）

职责：
```php
// 接收 POST: { template_id, selected_zones: ['A', 'C'] }
$templateId  = $params['vr_template_id'] ?? 0;
$zones      = $params['vr_zones'] ?? [];  // e.g. ['A', 'C']

// 1. 生成 SKU（只生成选中分区）
$result = SeatSkuService::BatchGenerate($goodsId, $templateId, $zones);
// goodsId 从 $params['id'] 或新建商品的返回值中获取

// 2. 写入 vr_ticket_config 快照（Phase 3-3 新增字段）
$snapshot = [
    'template_id'      => $templateId,
    'selected_zones'   => $zones,
    'spec_base_id_map' => $result['data']['spec_base_id_map'],
    'created_at'       => time(),
];
// Db::name('goods')->where('id', $goodsId)->update(['vr_ticket_config' => json_encode($snapshot)]);
```

---

## 五、商品发布完整流程

**场景**：商户发布一张票务商品（只开放 A 区和 C 区）

```
Step 1：商户进入 ShopXO 后台 → 商品管理 → 添加商品

Step 2：填写基础信息
  商品名称：周杰伦 VR 虚拟演唱会
  商品分类：VR演出

Step 3：在票务配置面板选场馆和分区
  票务配置
    场馆：[鸟巢 ▼]
    分区：[✓VIP区(A)  □看台区(B)  ✓普通区(C)]

Step 4：点击发布
  Save() → GoodsService::GoodsSave()
    → 触发 plugins_service_goods_save_handle
    → AdminGoodsSaveHandle 收到 POST
    → BatchGenerate(goodsId=123, templateId=1, enabledZones=['A','C'])
    → 只生成 A 区（6座）和 C 区（6座）= 12 个 SKU
    → 写入 sxo_goods_spec_base
    → 写入 vr_ticket_config JSON 快照
    → 商品保存完成

Step 5：用户前台看到选座页
  → 用户点击 A_1 座位
  → specBaseIdMap['A_1'] = 2001（来自 vr_ticket_config.snapshot）
  → goodsParams: [{goods_id:123, spec_base_id:2001, stock:1}]
  → 下单
```

---

## 六、Spec 生成后内部结构（技术细节）

> ⚠️ **表前缀**：ShopXO 原生表用 `sxo_`，插件自定义表用 `{$prefix}vrt_`。

### sxo_goods_spec_base（每行 = 一个座位 SKU）

| spec_base_id | goods_id | inventory | price |
|---|---|---|---|
| 2001 | 123 | 1 | 1299 |  ← A_1 座位
| 2002 | 123 | 1 | 1299 |  ← A_2 座位
| ... | 123 | 1 | 1299 |  ← A_6 座位
| 5001 | 123 | 1 | 599  |  ← C_1 座位
| ... | 123 | 1 | 599  |  ← C_6 座位
> 注：B 区座位未生成（商户只选了 A + C）

### sxo_goods_spec_type（每行 = 一个规格维度）

| id | goods_id | name | value |
|---|---|---|---|
| 1 | 123 | $vr-场馆 | `[{"name":"鸟巢"}]` |
| 2 | 123 | $vr-分区 | `[{"name":"VIP区"},{"name":"普通区"}]` |
| 3 | 123 | $vr-时段 | `[{"name":"待选场次"}]` |
| 4 | 123 | $vr-座位号 | `[{"name":"A_1"},...{"name":"C_6"}]` |

### vr_ticket_config（sxo_goods 表新增字段）

```json
{
  "template_id": 1,
  "selected_zones": ["A", "C"],
  "spec_base_id_map": {
    "A_1": 2001, "A_2": 2002, ..., "A_6": 2006,
    "C_1": 5001, "C_2": 5002, ..., "C_6": 5006
  },
  "created_at": 1744659200
}
```

---

## 七、BatchGenerate 接口（含 v3.0 改造）

### 7.1 当前已实现版本（全量，v2.0）

> ⚠️ 以下为当前 main 分支代码，**不支持**按分区过滤（Phase 3-3 需改造）：

```php
public static function BatchGenerate(int $goodsId, int $seatTemplateId): array
// 行为：按模板全部座位生成 SKU
```

### 7.2 Phase 3-3 改造版本（按分区过滤）

```php
public static function BatchGenerate(
    int   $goodsId,
    int   $seatTemplateId,
    array $enabledZones = []   // e.g. ['A', 'C'] — 空=全量
): array

// 内部逻辑改造：
// foreach ($seatMap['map'] as $rowChar => $rowSeats) {
//     if (!empty($enabledZones) && !in_array($rowChar, $enabledZones)) {
//         continue;  // 跳过未选中的分区
//     }
//     foreach (range(1, strlen($rowSeats)) as $col) {
//         // 生成该座位 SKU...
//     }
// }
```

**与旧版差异**：

| 维度 | 旧版（全量） | 新版（v3.0） |
|------|------------|-------------|
| 参数 | `(goodsId, templateId)` | `(goodsId, templateId, enabledZones)` |
| 行为 | 所有座位都生成 | 只生成 enabledZones 内的分区座位 |
| 兼容性 | — | enabledZones 为空时退化为全量（向后兼容）|

---

## 八、vr_ticket_config 字段（v3.0 新增）

### 8.1 数据库迁移

```sql
-- EventListener.php 中追加（Phase 3-3）
ALTER TABLE {$prefix}goods
ADD COLUMN vr_ticket_config JSON COMMENT '票务配置快照：template_id/selected_zones/spec_base_id_map' AFTER extension_data;
```

**老商品兼容**：存量商品 `vr_ticket_config = NULL`，编辑页检测到为空时提示"请重新选择"。

### 8.2 生命周期

| 时机 | 操作 |
|------|------|
| 发布商品时 | `AdminGoodsSaveHandle` 写入 `vr_ticket_config` |
| 编辑商品时 | 从此字段还原"选择了哪些场馆/分区" → 不展示琳琅满目的 spec_base 列表 |
| 删除商品时 | 钩子 `plugins_service_order_delete_success` 同步清理关联 spec |

---

## 九、与 ShopXO 原生 Spec 管理的关系

| 维度 | ShopXO 原生 Spec | 票务 Spec |
|------|----------------|----------|
| 谁创建 | 商户手动添加 | 插件自动生成（静默） |
| 表前缀 | `sxo_` | `sxo_goods_spec_base` / `sxo_goods_spec_value` |
| 商户感知 | 在后台规格管理看到 | 无感（注入的表单已覆盖场景） |
| 用户在前台 | 购物车/下单流程 | 票务选座 UI |
| 核销 | 不涉及 | 每座位一个 QR（vrt_vr_tickets）|

---

## 十、实施步骤

### Phase 3-1：后台场馆配置管理（新建 admin 页面）

- [x] 决策已确认：venue 信息存入 seat_map 顶层
- [x] 新建 `admin/controller/Venue.php`（CRUD + preview 调试接口）
- [x] 新建 `admin/view/venue/list.html`（场馆列表页）
- [x] 新建 `admin/view/venue/save.html`（Vue3 交互式表单编辑器：分区增删/座位排布/实时预览）
- [x] `BatchGenerate()` venue.name 动态读取（不再硬编码"国家体育馆"）
- [x] 将现有测试数据迁移为带 venue 的格式（vr_seat_templates id=1）
  - name = '国家体育馆'，venue.name = '国家体育馆'，3 区 × 6 座 = 18 座
- [ ] `BatchGenerate` 读取 `seat_map.venue.name`（不再硬编码）

### Phase 3-2：商品发布页注入

- [ ] `plugin.json` 的 `hooks` 数组追加 2 个钩子
- [ ] 新建 `hook/AdminGoodsSave.php`（注入票务配置 HTML）
- [ ] 新建 `hook/AdminGoodsSaveHandle.php`（处理保存数据）
- [ ] 场馆下拉 + 分区多选联动（Vue3，轻量）
- [ ] 确认商品发布页实际 Tab 名称（对照 `saveinfo.html`）

### Phase 3-3：Spec 自动生成接入 + 按分区过滤

- [ ] `BatchGenerate()` 增加 `enabledZones` 参数
- [ ] `AdminGoodsSaveHandle` 从 POST 提取选中分区，传给 `BatchGenerate`
- [ ] 数据库迁移：`sxo_goods` 新增 `vr_ticket_config` JSON 字段
- [ ] 发布时写入 `vr_ticket_config` 快照

### Phase 3-4（优先级低）：商品编辑回显

- [ ] 编辑页加载时解析 `vr_ticket_config`，还原场馆 + 分区状态
- [ ] 编辑页票务配置面板回显
- [ ] 若修改了 venue/zone：提示重新生成 spec 或自动重建

---

## 十一、v3.0 确认决策

| 决策点 | 确认结果 |
|--------|---------|
| venue 信息存在哪 | 存入 `seat_map.venue` 顶层，不独立建表 |
| 是否支持按分区过滤 | **必须支持**，Phase 3-3 改造 BatchGenerate 增加 enabledZones 参数 |
| 数据库扩展兼容性 | 新增 `sxo_goods.vr_ticket_config` JSON 字段，老商品留 NULL |
| 老 seat_map 兼容性 | 降级处理：无 venue → 取模板 name；无 sections → 从 seats 推导 |

---

## 十二、勘误表（v1.0 → v2.0 → v3.0）

| # | v1.0 描述 | 实际/修正 |
|---|---------|---------|
| 1 | `plugin.json` 用 `backend_hook` 注册 | ShopXO 用 `hooks` 数组 ✅ v2.0 已修正 |
| 2 | seat_map 有 `venue` 顶层字段 | 不存在，v3.0 决策新增 ✅ |
| 3 | seat_map 有 `zones` 顶层字段 | 实际是 `seats` + `sections` ✅ v2.0 已修正 |
| 4 | BatchGenerate 全量生成 | v3.0 决策：必须支持按分区过滤 ✅ |
| 5 | `goods_spec_base` 前缀是 `vrt_` | 原生表是 `sxo_` ✅ v2.0 已修正 |
| 6 | `vr_ticket_config` 字段存在 | 不存在，v3.0 决策新增 ✅ |
| 7 | 场馆配置管理是 Phase 2 已完成 | 待实现，Phase 3-1 ✅ v2.0 已修正 |
| 8 | spec_base_id_map 是内存/缓存 | 持久化到 `vr_seat_templates.spec_base_id_map` ✅ v2.0 已修正 |
| 9 | 商品分类需绑定票务配置 | 代码未强制校验，删除此约束 ✅ v2.0 已修正 |
| 10 | 无 vr_ticket_config 导致编辑页 spec 堆满 | v3.0 决策：新增字段解决 ✅ |
