# 商品发布钩子调研报告 — 票务商品配置注入

> 调研时间：2026-04-17 21:40–22:00 GMT+8
> 调研人：西莉雅 + 子 Agent 代码扫描
> 文档版本：v1.0.0
> 状态：**调研完成，待执行**

---

## 一、当前现状（代码实测）

### 1.1 vr_seat_templates 表完整字段

来源：`database/migrations/001_vr_tables.sql` + 代码交叉验证

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT UNSIGNED | 模板ID，主键 |
| `name` | VARCHAR(180) | 场馆简短名称（搜索用） |
| `category_id` | BIGINT UNSIGNED | 绑定分类ID（**已废弃**，VenueSave 强制置 0） |
| `seat_map` | LONGTEXT | 核心 JSON，完整座位配置 |
| `spec_base_id_map` | LONGTEXT | 座位ID → spec_base_id 映射（SKU生成后填充） |
| `status` | TINYINT UNSIGNED | 状态：0=禁用 1=启用 |
| `add_time` | INT UNSIGNED | 创建时间戳 |
| `upd_time` | INT UNSIGNED | 更新时间戳 |

### 1.2 seat_map JSON 实际完整结构（v3 版）

```json
{
  "venue": {
    "name": "北京国家体育馆",
    "address": "北京市朝阳区天辰东路9号",
    "location": { "lng": "116.397128", "lat": "39.916527" },
    "images": ["https://..."]
  },
  "rooms": [
    {
      "id": "room_1",
      "name": "主要展厅A",
      "sections": [
        { "char": "A", "name": "VIP区",    "price": 999.00, "color": "#ff4d4f" },
        { "char": "B", "name": "普通区",  "price": 299.00, "color": "#1677ff" }
      ],
      "seats": {
        "A": { "char": "A", "name": "VIP区",   "price": 999.00, "color": "#ff4d4f", "label": "VIP区" },
        "B": { "char": "B", "name": "普通区", "price": 299.00, "color": "#1677ff", "label": "普通区" }
      },
      "map": [
        "AAAAA",
        "AAAAA",
        "BBBBB",
        "BBBBB",
        "CCCCC"
      ]
    },
    {
      "id": "room_2",
      "name": "次展厅B",
      "sections": [
        { "char": "C", "name": "观众区", "price": 199.00, "color": "#52c41a" }
      ],
      "seats": { "C": { ... } },
      "map": ["CCCCCC", "CCCCCC"]
    }
  ]
}
```

**关键结构说明**：
- `venue` → 场馆信息顶层，一个模板只有一个 venue
- `rooms[]` → **复数演播厅节点**（核心！一个场馆可有多个厅）
- 每个 room 内 `sections[]` → **座位分区配置**（char/label/price/color）
- 每个 room 内 `map[]` → 字符地图（每排一个字符串，每字符=一个座位）
- `_` 和 `-` → 通道/空白，不算座位
- `seats{}` 字典由**后端 VenueSave 保存时自动从 sections 生成**（前端不用管）

### 1.3 当前商品关联机制

**当前：无 vr_goods_config 表，无商品发布钩子。**

商品与票务的关联仅通过 `goods.item_type` 字段（Event.php 安装时追加）：
```sql
ALTER TABLE {prefix}goods ADD COLUMN item_type VARCHAR(20) NOT NULL DEFAULT 'normal'
COMMENT '商品类型：normal=普通 goods ticket=票务 physical=周边'
```

**当前没有商品编辑页注入**，插件 Hook.php 只注册了：
- `plugins_service_admin_menu_data`（后台菜单）
- `plugins_service_order_pay_success_handle_end`（支付成功）
- `plugins_service_order_delete_success`（订单删除，空实现）

### 1.4 SeatSkuService.php 严重 Bug（⚠️ 需修复）

文件中有大量畸形字符串 `\t	hink\xacade\...`（疑似编辑器损坏或文件编码问题）：

```php
// ❌ 错误写法（文件里大量存在）
$template = 	hink\xacade\Db::name(self::table('seat_templates'))
// ✅ 正确写法应为：
$template = \think\facade\Db::name(self::table('seat_templates'))
```

涉及所有 `Db::` 调用（startTrans/commit/rollback/insertGetId/insertAll 等），会导致 `BatchGenerate()` **完全无法运行**。这是 Phase 0 阶段必须先修复的问题。

### 1.5 BatchGenerate 当前签名（待扩展）

```php
public static function BatchGenerate(int $goodsId, int $seatTemplateId): array
// 目前只接受 goodsId + seatTemplateId，不支持按 rooms/sections 过滤
```

---

## 二、设计方案（待确认执行）

### 2.1 两大钩子的职责分工

```
用户进入商品发布页（/admin/goods/saveinfo）
    │
    └─► Hook[1] plugins_view_admin_goods_save
        └─► 注入票务配置面板（隐藏状态）
        └─► 附"是否为票务商品"勾选框
        └─► 用户勾选后，票务配置面板解除禁用/展开
        │
用户点击「发布商品」
    │
    └─► Hook[2] plugins_service_goods_save_handle
        └─► 读取表单传上来的 vr_goods_config（JSON）与 vr_is_ticket 标识
        └─► 若 vr_is_ticket=1，强制设置商品 item_type = 'ticket'；否则设为 'normal'
        └─► 对每个 templateId 调用 BatchGenerate（带节点过滤）
        └─► 写入/更新 sku（goods_spec_base + goods_spec_value）
        └─► 存储 vr_goods_config 到 goods 表新字段
```

### 2.2 Hook[1] 注入面板设计

**注入位置**：商品发布页 `saveinfo.html`，在规格区域之前或商品基本信息区底部

**UI 交互**：
```
添加商品
  [商品名称]  [商品分类 ▼]
  ☑ 是否为票务商品        ← 勾选框，解锁票务配置
  ─────────────────────────
  ▼ 票务配置（勾选后展开）
    场馆模板(可多选多个场馆)：[请选择 ▼]
      └─ 场馆名称预览 + 地址

    演播厅选择（多选）：
      [✓] 主要展厅A（3区×6座 = 18座）
      [✓] 次展厅B（1区×6座 = 6座）

    分区选择（每个演播厅内多选）：
      主要展厅A:
        [✓] VIP区(A) — 999元  🔴
        [ ] 普通区(B) — 299元  🔵
        [ ] 后排区(C) — 199元  🟢

      次展厅B:
        [✓] 观众区(C) — 199元  🟢
```

**隐藏方案**：票务配置面板初始 `display: none`，由勾选框的 `@change` 事件控制展开。

**数据来源**：
```php
// 查询所有可用模板（status=1）
$templates = Db::name('vr_seat_templates')
    ->field('id, name, seat_map')
    ->where('status', 1)
    ->select();
// seat_map JSON 里已有 venue + rooms + sections，无需额外查询
```

### 2.3 Hook[2] 数据处理流程

用户提交后，`params` 里携带多个template,version 用于未来不同前端识别适配：
```json
{
  “version": "1.0.0",
  "vr_is_ticket": "1",
  "vr_goods_config": [
    {
      "template_id": 1,
      "selected_rooms": ["room_1", "room_2"],
      "selected_sections": {
        "room_1": ["A", "B"],
        "room_2": ["C"]
      }
    }
  ]
}
```

**存储**：新增一个 `goods.vr_goods_config` LONGTEXT 字段（JSON 数组），直接存这个结构，不建新表。

### 2.4 BatchGenerate 扩展（按分区过滤）

```php
public static function BatchGenerate(
    int    $goodsId,
    int    $seatTemplateId,
    array  $selectedRooms     = [],   // e.g. ['room_1', 'room_2']，空=全部
    array  $selectedSections  = []    // e.g. ['A','B'] per room，空=全部
): array
```

**扩展逻辑**：
```php
// 参数清理：过滤掉空的分类
$selectedRooms = array_filter($selectedRooms);
$selectedSections = array_filter($selectedSections);

// 遍历每个 room
foreach ($seatMap['rooms'] as $room) {
    // 跳过未选中的房间
    if (!empty($selectedRooms) && !in_array($room['id'], $selectedRooms)) {
        continue;
    }

    foreach ($room['map'] as $rowIndex => $rowStr) {
        $chars = mb_str_split($rowStr);
        foreach ($chars as $colIndex => $char) {
            // 跳过通道/空白
            if ($char === '_' || $char === '-') continue;
            
            // 跳过未选中的座位类型
            if (!empty($selectedSections[$room['id']]) 
                && !in_array($char, $selectedSections[$room['id']])) {
                continue;
            }
            // 生成 SKU...
        }
    }
}
```

**向后兼容**：当 `selectedRooms` 和 `selectedSections` 均为空时，退化为全量生成（兼容旧逻辑）。

### 2.5 数据库迁移

```sql
-- 在 goods 表新增字段（Event.php 中执行）
ALTER TABLE {prefix}goods
ADD COLUMN vr_goods_config LONGTEXT COMMENT '票务配置：[{template_id, selected_rooms, selected_sections}]'
AFTER item_type;
```

老商品 `vr_goods_config = NULL`，编辑页检测到时展示为空表单。

### 2.6 钩子注册（config.json 更新）

```json
{
  "hook": {
    "plugins_service_admin_menu_data": ["app\\plugins\\vr_ticket\\Hook"],
    "plugins_service_order_pay_success_handle_end": ["app\\plugins\\vr_ticket\\Hook"],
    "plugins_service_order_delete_success": ["app\\plugins\\vr_ticket\\Hook"],
    "plugins_view_admin_goods_save": ["app\\plugins\\vr_ticket\\hook\\AdminGoodsSave"],
    "plugins_service_goods_save_handle": ["app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"]
  }
}
```

### 2.7 新增文件清单

| 文件 | 职责 |
|------|------|
| `hook/AdminGoodsSave.php` | Hook[1]：注入票务配置面板 HTML（Vue3 CDN） |
| `hook/AdminGoodsSaveHandle.php` | Hook[2]：保存时读取 vr_goods_config → 调用 BatchGenerate |
| `service/SeatSkuService.php` | **修复**畸形字符串 + **扩展**按房间/分区过滤 |
| `Event.php` | 安装时追加 `goods.vr_goods_config` 字段 |

---

## 三、大头确认的设计决策

| 决策点 | 结论 |
|--------|------|
| vr_goods_config 存在哪 | 直接存 `goods.vr_goods_config`（JSON 数组字段），不建新表 |
| 数据库定义澄清 | 统一使用 ShopXO 默认数据库连接与 `{prefix}` 机制（表如 `{prefix}goods`，插件表为 `{prefix}vr_seat_templates`）|
| 票务配置面板是否默认显示 | **否**，默认隐藏，用户勾选"是否为票务商品"后展开 |
| 商品核心类型联动 | 若勾选“是否为票务商品”，Hook 将同步改变商品的核心 `item_type` 字段为 `'ticket'`。 |
| 多模板支持 | 支持（vr_goods_config 是 JSON 数组，每个元素=一个模板配置） |
| 座位模板表内字段废弃 | `category_id` 绑定字段已废弃，不使用 |
| category_id 废弃后的模板查询 | 直接查询 `vr_seat_templates.status=1`，不再依赖 category_id |

---

## 四、与前端商品详情的联动

```
商品发布（钩子注入配置）→ goods.vr_goods_config 写入
          ↓
商品详情页加载 → BaseService::isTicketGoods() 判断
          ↓
item_type === 'ticket' → 渲染 ticket_detail.html
          ↓
前端读取 goods.vr_goods_config → 获取 templateId → API 查询最新座位状态
          ↓
用户在前台看到选座界面（每个座位独立 SKU）
```

---

## 五、实施优先级

| 优先级 | 任务 | 原因 |
|--------|------|------|
| **P0** | 修复 SeatSkuService.php 畸形字符串 | BatchGenerate 完全无法运行 |
| **P0** | 新增 `goods.vr_goods_config` 字段迁移 | 所有后续逻辑依赖此字段 |
| **P0** | 注册两个钩子到 config.json | 钩子不注册 = 功能不触发 |
| **P1** | 实现 AdminGoodsSave.php（Hook[1]） | 面板注入，核心交互入口 |
| **P1** | 实现 AdminGoodsSaveHandle.php（Hook[2]） | 保存时生成 SKU |
| **P1** | 扩展 BatchGenerate 增加按房间/分区过滤 | 核心算法升级 |
| **P2** | 商品编辑页回显（读取 vr_goods_config 还原勾选状态） | 提升体验 |

---

## 六、关键技术参考

### 场馆配置页 Vue3 实现参考
`view/venue/save.html` 使用 Vue 3 CDN（`vue@3.3.4.prod`），`delimiters: ['[[', ']]']` 避免与 ThinkPHP 模板冲突。

### Base64 传输方案
Vue 数据通过 Base64 编码后由 `<input type="hidden" name="seat_map_raw">` 传输（避免 JSON 被框架 URL 净化截断）。商品配置同理可用同样模式。

### 商品发布页模板位置
ShopXO 后台商品发布页：`app/admin/view/default/goods/saveinfo.html`
钩子注入点：`<span>plugins_view_admin_goods_save</span>` 标记处

---

## 七、版本历史

| 版本 | 日期 | 修改内容 |
|------|------|---------|
| v1.0.0 | 2026-04-17 | 初始版本，代码实测 + 大头确认设计 |