vr-shopxo-plugin/docs/13_GOODS_ADD_HOOK_RESEARCH.md

11 KiB
Raw Blame History

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

调研时间2026-04-17 21:4022: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 版)

{
  "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 安装时追加):

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\...(疑似编辑器损坏或文件编码问题):

// ❌ 错误写法(文件里大量存在)
$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 当前签名(待扩展)

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_configJSON
        └─► 对每个 templateId 调用 BatchGenerate带节点过滤
        └─► 写入/更新 skugoods_spec_base + goods_spec_value

2.2 Hook[1] 注入面板设计

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

UI 交互

添加商品
  [商品名称]  [商品分类 ▼]
  ☑ 是否为票务商品        ← 勾选框,解锁票务配置
  ─────────────────────────
  ▼ 票务配置(勾选后展开)
    场馆模板:[请选择 ▼]
      └─ 场馆名称预览 + 地址

    演播厅选择(多选):
      [✓] 主要展厅A3区×6座 = 18座
      [✓] 次展厅B1区×6座 = 6座

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

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

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

数据来源

// 查询所有可用模板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 里携带:

{
  "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 扩展(按分区过滤)

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

扩展逻辑

// 遍历每个 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...
        }
    }
}

向后兼容:当 selectedRoomsselectedSections 均为空时,退化为全量生成(兼容旧逻辑)。

2.5 数据库迁移

-- 在 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 更新)

{
  "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]:注入票务配置面板 HTMLVue3 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_configJSON 数组),不建新表
表名 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.phpHook[1] 面板注入,核心交互入口
P1 实现 AdminGoodsSaveHandle.phpHook[2] 保存时生成 SKU
P1 扩展 BatchGenerate 增加按房间/分区过滤 核心算法升级
P2 商品编辑页回显(读取 vr_goods_config 还原勾选状态) 提升体验

六、关键技术参考

场馆配置页 Vue3 实现参考

view/venue/save.html 使用 Vue 3 CDNvue@3.3.4.proddelimiters: ['[[', ']]'] 避免与 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 初始版本,代码实测 + 大头确认设计