# 商品发布钩子调研报告 — 票务商品配置注入 > 调研时间: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 | 初始版本,代码实测 + 大头确认设计 |