diff --git a/docs/13_GOODS_ADD_HOOK_RESEARCH.md b/docs/13_GOODS_ADD_HOOK_RESEARCH.md
new file mode 100644
index 0000000..bfd8faf
--- /dev/null
+++ b/docs/13_GOODS_ADD_HOOK_RESEARCH.md
@@ -0,0 +1,333 @@
+# 商品发布钩子调研报告 — 票务商品配置注入
+
+> 调研时间: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
+ └─► 读取 goods.vr_goods_config(JSON)
+ └─► 对每个 templateId 调用 BatchGenerate(带节点过滤)
+ └─► 写入/更新 sku(goods_spec_base + goods_spec_value)
+```
+
+### 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` 里携带:
+```json
+{
+ "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
+// 遍历每个 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 数组),不建新表 |
+| 表名 | MySQL 数据库 `vrticket`(不是 `shopxo` 默认前缀) |
+| 票务配置面板是否默认显示 | **否**,默认隐藏,用户勾选"是否为票务商品"后展开 |
+| 勾选框名称 | "是否为票务商品"(checkbox) |
+| 多模板支持 | 支持(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 | 初始版本,代码实测 + 大头确认设计 |
\ No newline at end of file