docs: add 13_GOODS_ADD_HOOK_RESEARCH.md - goods add hook survey
- Code-verified: vr_seat_templates full field list + seat_map v3 JSON structure - Confirmed: no goods_save hook exists yet, no vr_goods_config table - Critical bug found: SeatSkuService.php has corrupted \t\think\xacade strings (P0 fix) - Two-hook design: plugins_view_admin_goods_save + plugins_service_goods_save_handle - goods.vr_goods_config JSON field (no new table needed) - BatchGenerate extension: room/section filteringcouncil/SecurityEngineer
parent
207f49839b
commit
111063d785
|
|
@ -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 编码后由 `<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 | 初始版本,代码实测 + 大头确认设计 |
|
||||||
Loading…
Reference in New Issue