# 商品发布钩子调研报告 — 票务商品配置注入
> 调研时间: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 编码后由 `` 传输(避免 JSON 被框架 URL 净化截断)。商品配置同理可用同样模式。
### 商品发布页模板位置
ShopXO 后台商品发布页:`app/admin/view/default/goods/saveinfo.html`
钩子注入点:`plugins_view_admin_goods_save` 标记处
---
## 七、版本历史
| 版本 | 日期 | 修改内容 |
|------|------|---------|
| v1.0.0 | 2026-04-17 | 初始版本,代码实测 + 大头确认设计 |