vr-shopxo-plugin/docs/FULL_PLAN.md

28 KiB
Raw Permalink Blame History

VR 演唱会票务小程序 — 完整实现文档

最后更新2026-04-21 用途:给任意 agent 独立阅读并推进事务 仓库:http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin 本地路径:/Users/bigemon/WorkSpace/vr-shopxo-plugin ShopXO 容器localhost:10000Web/ localhost:10001MySQL/ localhost:9000PHP-FPM

📋 AntiGravity 已进行会话进度: SESSION_REPORT_20260421_PHASE2_FIX.md - 记录AntiGravity 推进的所有工作,包含经验教训与改动。


一、项目概览

1.1 目标产品

VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。

1.2 技术栈

  • 前端:原生 HTML + CSS + JS无框架商品详情页使用 ticket_detail.html
  • 后端ShopXOThinkPHP 8插件 vr_ticket
  • 数据库ShopXO MySQL表前缀 vrt_
  • 微信支付ShopXO 原生微信支付

1.3 核心表结构

表名 用途
vrt_vr_seat_templates 座位模板(座位图画法 + 绑定分类)
vrt_vr_tickets 电子票order_id + seat_info + real_name/phone/id_card
vrt_vr_verifiers 核销员
vrt_vr_verifications 核销记录
vrt_vr_audit_log 操作审计日志

ShopXO 原生表:

表名 用途
goods 商品(含 vr_goods_config 扩展 JSON 字段)
goods_spec_base SKU库存/价格),extendsseat_key
goods_spec_value spec 维度值4维度场馆/分区/座位号/场次)
order 订单(含 extension_data JSON 字段)
order_detail 订单明细

1.4 spec 四维度说明

ShopXO 每个 GoodsSpecBaseSKU由 4 个 spec type-value 联合确定:

type 说明 示例 value
$vr-场馆 场馆名 VR 体验馆
$vr-分区 场馆+演播厅+分区 VR 体验馆-1号演播厅-VIP区
$vr-座位号 完整路径座位名 VR 体验馆-1号演播厅-VIP区-A-1排3座
$vr-场次 场次时间 15:00-16:59

注意spec value 是完整路径字符串,不是 "A_3""roomId_A_3" 这种短格式。

1.5 座位的唯一标识seatKey

前后端共用同一个格式:{roomId}_{rowLabel}_{colNum}

  • roomIdrooms[].id,来自 vr_goods_config.template_snapshot.rooms
  • rowLabel:座位行标签,A/B/C(由 map 行索引计算:String.fromCharCode(65 + rowIndex)
  • colNum**:列号(从 1 开始:colIndex + 1`

示例:"room_001_A_3" = room_001 的 A排 第3列

seatKey 对应 GoodsSpecBase.extends.seat_key,用于关联 GoodsSpecBase 和前端座位 DOM。


二、现状与已知问题

Phase 0/1 完成情况

Goods.php 判断 item_type='ticket' → 渲染 ticket_detail.html ticket_detail.html 座位图渲染 + 选座 JS + 观演人表单 SeatSkuService::GetGoodsViewData() 返回座位图数据 TicketService::onOrderPaid() 支付成功后生成 vr_tickets 4 个后台管理控制器(座位模板/票/核销员/核销记录) 基础防超卖幂等保护

Phase 2 待修复问题(源自 Council 评估 + 大头确认)

# 问题 优先级 状态
Issue 1 购买提交流程失效GET→POST 机制错误 + spec 格式错误 + 缺 seatSpecMap P0 待修复
Issue 2 缩放时舞台不跟随 P1 待修复
Issue 3 spec 加载loadSoldSeats 空 stub + 无 sold_seats API P1 待修复
Issue 4 商品详情/图片加载 P2 待修复
Issue 5 GetGoodsViewData 只返回第一个场次 P2 待修复

核心问题说明Issue 1 P0 Issue 1 不是单一 bug而是三层叠加问题

  1. submit()location.hrefGETShopXO Buy::Index 只在 POST 时调用 BuyDataStorage
  2. spec 格式错误:只传 1 维度而非 4 维度
  3. 最严重:前端根本没有 seatSpecMap无法把座位 DOM 映射到正确的 GoodsSpecBase

三、商品118 vr_goods_config原始数据库数据

存储位置:goodsvr_goods_config JSON 字段(商品 ID = 118

这是从数据库直接读取的原始数据,所有其他数据结构均派生于此

[
  {
    "version": 3.0,
    "template_id": 4,
    "selected_rooms": ["room_001", "room_002"],
    "selected_sections": {
      "room_001": ["A", "B"],
      "room_002": ["A"]
    },
    "sessions": [
      { "start": "15:00", "end": "16:59" },
      { "start": "18:00", "end": "20:59" }
    ],
    "template_snapshot": {
      "venue": {
        "name": "VR 演唱会馆",
        "address": "北京市朝阳区建国路88号",
        "location": { "lng": "116.45792", "lat": "39.90745" },
        "images": [
          "/static/attachments/202603/venue_001.jpg",
          "/static/attachments/202603/venue_002.jpg"
        ]
      },
      "rooms": [
        {
          "id": "room_001",
          "name": "1号演播厅",
          "map": [
            "AAAAA_____BBBBB",
            "AAAAA_____BBBBB",
            "AAAAA_____BBBBB",
            "CCCCCCCCCCCCCCC",
            "CCCCCCCCCCCCCCC"
          ],
          "sections": [
            { "char": "A", "name": "VIP区",   "price": 380, "color": "#f06292" },
            { "char": "B", "name": "看台区",   "price": 180, "color": "#4fc3f7" },
            { "char": "C", "name": "普通区",   "price": 80,  "color": "#81c784" }
          ],
          "seats": {
            "A": { "char": "A", "name": "VIP区",   "price": 380, "color": "#f06292" },
            "B": { "char": "B", "name": "看台区",   "price": 180, "color": "#4fc3f7" },
            "C": { "char": "C", "name": "普通区",   "price": 80,  "color": "#81c784" }
          }
        },
        {
          "id": "room_002",
          "name": "2号演播厅副厅",
          "map": [
            "DDDDDDD",
            "DDDDDDD",
            "EEEEEEE"
          ],
          "sections": [
            { "char": "D", "name": "互动区",   "price": 280, "color": "#ffb74d" },
            { "char": "E", "name": "站票区",   "price": 50,  "color": "#90a4ae" }
          ],
          "seats": {
            "D": { "char": "D", "name": "互动区",   "price": 280, "color": "#ffb74d" },
            "E": { "char": "E", "name": "站票区",   "price": 50,  "color": "#90a4ae" }
          }
        }
      ]
    }
  }
]

字段说明

字段 含义 前端是否可用
version 协议版本(当前 3.0 内部使用
template_id 关联座位模板 ID 内部使用
selected_rooms 启用的房间 ID 列表 用于初始化
selected_sections 每个房间选中的分区字符 用于默认高亮
sessions 场次列表start/end 场次选择器数据源
template_snapshot.venue 场馆信息 Banner/详情展示
template_snapshot.rooms[].id 房间唯一 ID seatKey 构造必需
template_snapshot.rooms[].map 座位图字符矩阵 座位图渲染必需
template_snapshot.rooms[].sections 分区列表char→name/price/color 图例+分区选择器
template_snapshot.rooms[].seats char→座位属性映射 查座位详情

map 格式说明

"AAAAA_____BBBBB"
 ↓分解为字符数组↓
['A','A','A','A','A','_','_','_','_','_','B','B','B','B','B']
  ←VIP区×5→←空位×5→←看台区×5→

字符含义:
  A/B/C/D/E = 座位(通过 rooms[i].seats[char] 查属性)
  '_' / '-' = 空位(不渲染座位)
  其他非字母 = 不渲染

rooms.seats 与 rooms.sections 的关系

同一个 char 在不同房间代表不同分区:

  • room_001A = VIP区红色380元
  • room_002D = 互动区橙色280元

分区信息在 sections[],不要直接用 char 本身判断分区名称或价格。


四、后端注入的模板数据

Goods.php 在渲染 ticket_detail.html 前,通过 SeatSkuService::GetGoodsViewData() 向模板注入以下变量:

MyViewAssign([
    'vr_seat_template' => $viewData['vr_seat_template'],   // 座位图原始数据
    'goods_spec_data'  => $viewData['goods_spec_data'],     // 场次列表
    'seatSpecMap'      => $viewData['seatSpecMap'] ?? [],   // 【待新增】座位→4维spec映射
]);

模板中接收方式:

var vrSeatTemplate = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
var goodsSpecData  = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
var seatSpecMap    = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;

4.1 vr_seat_template透传 template_snapshot

{
    venue: {
        name: "VR 演唱会馆",
        address: "北京市朝阳区建国路88号",
        location: { lng: "116.45792", lat: "39.90745" },
        images: ["/static/attachments/202603/venue_001.jpg"]
    },
    rooms: [
        {
            id: "room_001",
            name: "1号演播厅",
            map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"],
            sections: [
                { char: "A", name: "VIP区",  price: 380, color: "#f06292" },
                { char: "B", name: "看台区",  price: 180, color: "#4fc3f7" },
                { char: "C", name: "普通区",  price: 80,  color: "#81c784" }
            ],
            seats: { /* 同第二章 seats */ }
        },
        {
            id: "room_002",
            name: "2号演播厅副厅",
            map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"],
            sections: [ /* 同第二章 sections */ ],
            seats: { /* 同第二章 seats */ }
        }
    ],
    sessions: [
        { start: "15:00", end: "16:59" },
        { start: "18:00", end: "20:59" }
    ],
    selectedRooms: ["room_001", "room_002"],
    selectedSections: { "room_001": ["A", "B"], "room_002": ["A"] }
}

4.2 goods_spec_data场次列表

// 来源goods.vr_goods_config.sessions + ShopXO GoodsSpecBase.price
[
    { spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" },
    { spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" }
]

4.3 seatSpecMap待新增核心数据结构

来源GetGoodsViewData() 查询 GoodsSpecBase + GoodsSpecValue + GoodsSpecBase.extends,动态构建

用途:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 GoodsSpecBase

// key 格式:{roomId}_{rowLabel}_{colNum}
// 示例room_001_A_3 = room_001 的 A排 第3列

{
    "room_001_A_1": {
        spec_base_id: 10001,
        price: 380,
        inventory: 1,          // 0 = 已售1 = 可购
        rowLabel: "A",
        colNum: 3,
        roomId: "room_001",
        section: { char: "A", name: "VIP区", color: "#f06292" },
        // === 4维 spec 数组submit() 时直接使用)===
        spec: [
            { type: "$vr-场馆",   value: "VR 演唱会馆" },
            { type: "$vr-分区",   value: "VR 演唱会馆-1号演播厅-VIP区" },
            { type: "$vr-座位号", value: "VR 演唱会馆-1号演播厅-VIP区-A-1排1座" },
            { type: "$vr-场次",   value: "15:00-16:59" }
        ]
    },
    "room_001_A_2": { /* 同上A排第2座 */ },
    "room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ },
    "room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* 互动区 */ },
    // ...每个可购座位一行
}

seatSpecMap 生成逻辑GetGoodsViewData 中实现)

// 1. 查询所有有效 GoodsSpecBase含 extends.seat_key
$specs = Db::name('GoodsSpecBase')
    ->where('goods_id', $goodsId)
    ->where('inventory', '>', 0)  // 只取有库存的
    ->select();

// 2. 查询对应的 GoodsSpecValue4个维度的 type/value
$specIds = array_column($specs->toArray(), 'id');
$specValues = Db::name('GoodsSpecValue')
    ->whereIn('goods_spec_base_id', $specIds)
    ->select();

// 3. 按 spec_base_id 分组,构建 4维 spec 数组
$specByBaseId = [];
foreach ($specValues as $sv) {
    $specByBaseId[$sv['goods_spec_base_id']][] = [
        'type'  => $sv['type'],
        'value' => $sv['value'],
    ];
}

// 4. 构建 seatSpecMap
$seatSpecMap = [];
foreach ($specs as $spec) {
    $extends = json_decode($spec['extends'] ?? '{}', true);
    $seatKey  = $extends['seat_key'] ?? '';
    if (empty($seatKey)) continue;

    $seatSpecMap[$seatKey] = [
        'spec_base_id' => intval($spec['id']),
        'price'        => floatval($spec['price']),
        'inventory'    => intval($spec['inventory']),
        'spec'         => $specByBaseId[$spec['id']] ?? [],
    ];
}

五、产品形态:多维度 spec 选择器 + 多座位选择

5.1 界面结构

┌─────────────────────────────────────────────────────┐
│  顶部 Bannervenue.images                        │
│                                                     │
│  场次选择                                            │
│  [●15:00-16:59 ¥380]  [ 18:00-20:59 ¥280 ]         │
│                                                     │
│  场馆/分区选择spec 选择器交互)                      │
│  [●1号演播厅] [ 2号演播厅 ]                         │
│  [●VIP区380] [ 看台区180 ] [ 普通区80 ]             │
│                                                     │
│  ─────────── 座位图(多选)─────────────────────    │
│       舞 台                                         │
│  A排  [■■■■■]      ← 可选VIP红色              │
│  B排  [■■■■■]      ← 可选(看台,蓝色)              │
│  C排  [灰掉]       ← 不在当前分区                    │
│                                                     │
│  图例:[■]可选 [██]已售 [░░]不可选                   │
│                                                     │
│  ─────────── 观演人表单 ─────────────────────────    │
│  第1张票张三 138****000  身份证(选填)            │
│  第2张票李四 139****111  身份证(选填)            │
│                                                     │
│  ─────────── 底部价格栏 ─────────────────────────    │
│  已选 2 座,合计 ¥760          [提交订单]            │
└─────────────────────────────────────────────────────┘

5.2 spec 选择器交互(参考原生 ShopXO spec 选择器行为)

用户切换场次/场馆/分区时,未在当前选择分支内的座位自动变灰/隐藏:

切换场次 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位
切换场馆 → 重置分区/座位     → 用 seatSpecMap 过滤出该场馆所有座位
切换分区 → 只灰掉其他分区座位 → 用 seatSpecMap 过滤出该分区座位
点击座位 → 复选/取消          → 更新 selectedSeats[]
// 过滤函数
function filterSeatMap(currentSession, currentVenueName, currentSectionChar) {
    Object.entries(seatSpecMap).forEach(function([seatKey, seatInfo]) {
        var spec = seatInfo.spec;  // 4维数组

        var hasSession = spec.some(function(s) {
            return s.type === '$vr-场次' && s.value === currentSession;
        });
        var hasVenue = spec.some(function(s) {
            return s.type === '$vr-场馆' && s.value.includes(currentVenueName);
        });
        var hasSection = !currentSectionChar || spec.some(function(s) {
            return s.type === '$vr-分区' && s.value.includes(currentSectionChar);
        });
        var isAvailable = seatInfo.inventory > 0;

        var seatEl = document.querySelector('[data-seat-key="' + seatKey + '"]');
        if (!seatEl) return;

        if (hasSession && hasVenue && hasSection) {
            seatEl.classList.toggle('sold', !isAvailable);
            seatEl.classList.toggle('disabled', false);
        } else {
            seatEl.classList.add('disabled');
            seatEl.classList.remove('sold');
        }
    });
}

5.3 从 vr_seat_template 渲染座位图

function renderSeatMap() {
    var rooms = vrSeatTemplate.rooms;

    rooms.forEach(function(room) {
        room.map.forEach(function(rowStr, rowIndex) {
            var rowLabel = String.fromCharCode(65 + rowIndex);  // 0→A, 1→B
            var chars = rowStr.split('');  // 逐字符PHP mb_str_split 兼容)

            chars.forEach(function(char, colIndex) {
                if (char === '_' || char === '-') {
                    // 渲染空白格子
                    return;
                }
                var colNum = colIndex + 1;  // 列号从 1 开始
                var seatKey = room.id + '_' + rowLabel + '_' + colNum;  // "room_001_A_3"
                var seatInfo = room.seats[char];  // 查到座位属性

                // 创建座位 DOM 元素
                var seatEl = document.createElement('div');
                seatEl.className = 'vr-seat';
                seatEl.dataset.seatKey = seatKey;
                seatEl.dataset.rowLabel = rowLabel;
                seatEl.dataset.colNum = colNum;
                seatEl.dataset.char = char;
                seatEl.dataset.roomId = room.id;
                seatEl.style.backgroundColor = seatInfo.color;
                seatEl.textContent = rowLabel + colNum;

                // 点击事件:选座/取消
                seatEl.addEventListener('click', function() { toggleSeat(seatEl, seatKey); });

                document.getElementById('room_' + room.id + '_seats').appendChild(seatEl);
            });
        });
    });
}

六、submit() 正确实现P0 Issue 1 核心修复)

6.1 当前错误代码

原始 ticket_detail.html 中的 submit() 使用 location.hrefGETShopXO Buy::Index 只在 POST 时存储数据,导致购买流程失效。

6.2 修复后的 submit()

// var self = this; — 原始代码第6行已有此声明
submit: function() {
    var self = this;

    // 1. 收集观演人
    var inputs = document.querySelectorAll('#attendeeList input');
    var attendeeData = [];
    inputs.forEach(function(input) {
        var idx = parseInt(input.dataset.index);
        if (!attendeeData[idx]) attendeeData[idx] = {};
        attendeeData[idx][input.dataset.field] = input.value;
    });

    // 2. 验证已选座位和观演人数量匹配
    if (this.selectedSeats.length === 0) {
        alert('请至少选择一个座位');
        return;
    }
    if (this.selectedSeats.length !== attendeeData.length) {
        alert('座位数与观演人信息数量不匹配');
        return;
    }

    // 3. 构建 ShopXO 原生 goods_data 格式
    //
    // ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约BuyService.php 第86行
    //    必须嵌套在 order_base 内!不能平铺在 goods_data 第一层!
    //
    // ⚠️ 【必须】直接传 JSON 字符串,不需要 base64
    //    BuyService.php 第60行!is_array($_POST['goods_data']) → json_decode()
    //    ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64
    //
    // ⚠️ 【必须】spec 是完整的 4维数组不是 1 维!
    //    从 seatSpecMap[seatKey].spec 读取,不要自己构造
    //
    // ⚠️ requestUrl 来自 PHP 模板注入var requestUrl = '<?php echo Config("shopxo.host_url"); ?>';
    //    确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url
    //
    var goodsDataList = this.selectedSeats.map(function(seat, i) {
        var seatInfo = seatSpecMap[seat.seatKey];  // 从注入的 seatSpecMap 查
        if (!seatInfo) {
            console.error('seatSpecMap missing key:', seat.seatKey);
            return null;
        }
        return {
            goods_id: self.goodsId,
            spec: seatInfo.spec,   // 4维完整 spec 数组!从 seatSpecMap 来!
            stock: 1,
            order_base: {           // ← 必须嵌套!不能平铺!
                extension_data: {
                    attendee: {
                        real_name: attendeeData[i]?.real_name || '',
                        phone:     attendeeData[i]?.phone || '',
                        id_card:   attendeeData[i]?.id_card || ''
                    }
                }
            }
        };
    }).filter(Boolean);

    // 4. 过滤无效座位
    if (goodsDataList.length === 0) {
        alert('座位信息无效,请重新选择');
        return;
    }

    // 5. 隐藏表单 POST 到 ShopXO Buy 链路
    var form = document.createElement('form');
    form.method = 'POST';
    form.action = requestUrl + '?s=index/buy/index';
    document.body.appendChild(form);

    var input = document.createElement('input');
    input.type = 'hidden';
    input.name = 'goods_data';
    input.value = JSON.stringify(goodsDataList);  // 直接 JSONBuyService 自动处理
    form.appendChild(input);

    form.submit();  // POST → Buy::Index → BuyDataStorage → 跳转确认页
}

6.3 ShopXO Buy 链路完整数据流(已验证可用)

submit() POST goods_data含 4维spec + extension_data
  │
  ├─→ Buy::Index (POST) → BuyDataStorage(user_id, data_post) [存入 session, TTL=21600s]
  │                                              ↑
  │                           goods_data 是数组json_encode 存入 session
  │
  └─→ 跳转 Buy::Index (GET) → BuyDataRead → 显示确认页
                                     │
  ┌───────────────────────────────┘
  │
  └─→ form submit → Buy::Add → BuyService::OrderInsert($params)
                                     │
                          BuyTypeGoodsList($params) → BuyGoods($params)
                                     │
                     foreach($params['goods_data'] as $v) ← 多 SKU 原生遍历
                                     │
                          GoodsSpecificationsHandle($v) → GoodsSpecDetail()
                                     │  4维 type-value 匹配 GoodsSpecValue 表
                                     ↓
                          OrderInsertHandle($order_data)
                                     │
                          BuyService.php 第773行
                          'extension_data' => json_encode($v['order_base']['extension_data'])
                                     │
                          Db::name('order')->insertGetId($order)  ← extension_data 写入 Order 表
                                     │
                          微信支付...
                                     │
  ┌────────────────────────────────┘
  │
  └─→ 支付成功 → Hook: plugins_service_order_pay_success_handle_end
                    │
                    TicketService::onOrderPaid($params)
                          │
                          Db::name('order')->find($order_id)
                          ↓
                          json_decode($order['extension_data']) → 观演人信息
                          ↓
                          foreach($order_goods as $og) {
                              issueTicket($order, $og)  // 幂等保护seat_info 查重
                          }
                                │
                          Db::name('vr_tickets')->insertGetId([
                              'order_id'   => $order['id'],
                              'seat_info'  => $spec_name,
                              'real_name'  => $attendee['real_name'],
                              'phone'      => $attendee['phone'],
                              'id_card'    => $attendee['id_card'],
                              'ticket_code'=> $uuid,
                              'qr_data'    => AES加密(payload),
                          ]);

七、完整修复清单

优先级 Issue 任务 依赖 负责
P0 Issue 1 重构 GetGoodsViewData() 新增 seatSpecMap 后端 BackendArchitect
P0 Issue 1 前端 JS 用 seatSpecMap 替代 specBaseIdMap P0 前置 FrontendDev
P0 Issue 1 修复 submit()GET→POST + 正确 4维 spec 数组 P0 前置 FrontendDev
P0 Issue 1 Goods.php MyViewAssign 加入 seatSpecMap P0 前置 BackendArchitect
P1 Issue 1 实现场次/场馆/分区 spec 选择器 UI + filterSeatMap() P0 前置 FrontendDev
P1 Issue 1 selectSession() / selectVenue() / selectSection() 联动逻辑 P1 前置 FrontendDev
P1 Issue 2 缩放时舞台跟随zoom wrapper 方案) FrontendDev
P1 Issue 3 新增 sold_seats API 端点 BackendArchitect
P1 Issue 3 前端 loadSoldSeats() 调用 API + 标记 .sold P1 前置 FrontendDev
P2 Issue 4 商品详情图片展示(确认需求,补充 CSS FrontendDev
P2 Issue 5 GetGoodsViewData() 返回数组而非 validConfigs[0] BackendArchitect
P2 审计 验证 onOrderPaid spec 匹配 + 幂等保护FOR UPDATE BackendArchitect

八、关键代码索引

文件 行号 说明
Buy.php 58-61 Index() — POST/GET 分支BuyDataStorage/BuyDataRead
BuyService.php 51-62 BuyGoods — goods_data 参数校验 + JSON decode非 base64
BuyService.php 86 foreach($params['goods_data'] as $v) — 多 SKU 原生遍历
BuyService.php 104-109 GoodsSpecDetail — 4维 type-value 匹配 GoodsSpecValue
BuyService.php 773 extension_data => json_encode($v['order_base']['extension_data'])
BuyService.php 1932 BuyDataStorage — 21600s TTL session 缓存
buy/index.html 871 原生 form hidden goods_data fieldJSON 字符串,非 base64
TicketService.php 21-22 Hook: plugins_service_order_pay_success_handle_endonOrderPaid
TicketService.php 141-143 issueTicket — 从 $order['extension_data'] 读观演人
SeatSkuService.php 40-45 SPEC_DIMS = ['$vr-场馆','$vr-分区','$vr-座位号','$vr-场次']
SeatSkuService.php ~368 GetGoodsViewData — validConfigs[0] 多场次 Bug
SeatSkuService.php ~131 BatchGenerate — 4维 spec value 构建(完整路径字符串)
Hook.php 21-22 plugins_service_order_pay_success_handle_end → TicketService::onOrderPaid

九、第一性原则(设计决策记录)

  1. 座位唯一性靠 ShopXO 原生 inventory:每个 GoodsSpecBase 的 inventory=1ShopXO 在 OrderInsertHandle 中扣库存,防超卖由 ShopXO 原生保证,不需要自己实现锁。

  2. spec_base_id_map 是性能缓存:理想情况下 onOrderPaid 通过 seat_key 查询 GoodsSpecBase 即可,不需要 map 字段。但保留是合理的优化。

  3. extension_data 存储完全在 ShopXO 生态内:不新建表,不扩展 ShopXO 字段,order.extension_dataonOrderPaidvr_tickets 全链路 ShopXO 原生。

  4. onOrderPaid spec 匹配存在潜在 bug⚠️ 未来需关注):

    • BatchGenerate 写入 GoodsSpecValue.value 格式:"VR 演唱会馆-1号演播厅-VIP区-A-1排3座"(长路径字符串)
    • 前端 seatKey 格式:"room_001_A_3"(短格式)
    • 两者不匹配,issueTicket 第57-77行的反向 spec 查找会失效
    • 目前不影响功能(幂等靠 seat_info 字段,不依赖 spec_base_id
    • 未来如需精确关联,需修复 BatchGenerate 的 value 写入格式
  5. 最小修复原则Issue 1 的修复只需改 submit() 函数POST + 正确 4维 spec 格式 + extension_data。不需要重构 spec 系统,不需要绕过 Buy 链路。


本文档为 vr-shopxo-plugin Phase 2 完整实现文档Agent 可独立阅读并推进事务。