From 4c2c9a3ed665eefa8bd63cf5ccc024bd5ca3eadb Mon Sep 17 00:00:00 2001 From: Council Date: Sat, 27 Jun 2026 17:50:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(goods-vr-ticket-order):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20H5=20=E5=88=B7=E6=96=B0=E9=A1=B5=E9=9D=A2=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=8F=82=E6=95=B0=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题原因: H5 SPA 模式下,带有复杂 JSON 数据的 URL 参数(seats, selection, buyNotices 等) 在刷新时会被浏览器/路由框架错误解析,导致参数截断丢失。 解决方案: - 参考 buy.vue 的做法,将跳转参数通过 base64 编码打包为单一 data 参数传递 - 在订单确认页 onLoad 中解包 data 参数,并兼容处理单重/双重编码场景 - 新增 safeParseJson 和 safeDecodeURIComponent 辅助方法提高健壮性 修改文件: - pages/goods-vr-ticket/goods-vr-ticket.vue (goToOrder 跳转逻辑) - pages/goods-vr-ticket-order/goods-vr-ticket-order.vue (onLoad 数据解析) --- App_local.vue | 3346 +++++++++++++++++ docs/plan/观影人后端需求计划 plan.md | 732 ++++ docs/plan/观影人改造- walkthrough.md | 289 ++ .../goods-vr-ticket-order.vue | 79 +- pages/goods-vr-ticket/goods-vr-ticket.vue | 24 +- 5 files changed, 4425 insertions(+), 45 deletions(-) create mode 100644 App_local.vue create mode 100644 docs/plan/观影人后端需求计划 plan.md create mode 100644 docs/plan/观影人改造- walkthrough.md diff --git a/App_local.vue b/App_local.vue new file mode 100644 index 00000000..fcd47306 --- /dev/null +++ b/App_local.vue @@ -0,0 +1,3346 @@ + + \ No newline at end of file diff --git a/docs/plan/观影人后端需求计划 plan.md b/docs/plan/观影人后端需求计划 plan.md new file mode 100644 index 00000000..160751c8 --- /dev/null +++ b/docs/plan/观影人后端需求计划 plan.md @@ -0,0 +1,732 @@ +# 观影人信息管理功能实现方案(修订版) + +## 注意事项 +以下内容里的“vrt_”为数据表prefix,会自动处理,在插件使用中,一般建议不要写死而是靠shopxo自动处理。 + +## 目标分解 + +根据业务优先级,分为两个独立阶段: + +### 🔥 阶段 1:订单确认页观影人信息采集(高优先级) + +**核心需求**: +- 在订单确认页强制填写观影人手机号(如果商品配置要求) +- 直接将观影人信息写入票据表 `vrt_vr_tickets` +- 无需独立的观影人管理功能 +- **后端改动极小,前端快速实现** + +### 📋 阶段 2:用户观影人通讯录管理(低优先级,TODO) + +**核心需求**: +- 用户可以保存常用观影人信息 +- 在订单确认页快速选择历史观影人 +- 提供独立的观影人管理页面(增删改查) +- 需要前后端较大改动 + +--- + +## 阶段 1 实现方案(本次实施) + +### 业务流程 + +``` +用户选择商品 → 进入订单确认页 +→ 检查商品是否需要观影人信息 +→ [是] 前端展示观影人表单(姓名 + 手机号必填) +→ 用户填写 → 提交订单 +→ 后端接收 viewer_data 参数 +→ 出票时写入 vrt_vr_tickets 表 +``` + +### 用户审查要点 + +> [!IMPORTANT] +> **阶段 1 核心设计** +> +> **灵活的字段控制**: +> - 商品编辑页新增票务选项,包含 5 个独立开关: +> 1. `require_viewer` - 是否需要填写观影人信息(总开关) +> 2. `require_viewer_per_seat` - 是否每个座位独立填写观影人(阶段1默认关闭) +> 3. `require_viewer_mobile` - 是否必填手机号 +> 4. `require_viewer_name` - 是否必填姓名 +> 5. `require_viewer_idcard` - 是否必填身份证号(预留) +> +> **校验逻辑**: +> - 当 `require_viewer=1` 时,至少需要 `require_viewer_mobile=1`(手机号是核心字段) +> - 其他字段(姓名、身份证)根据商家配置独立控制 +> - 当 `require_viewer_per_seat=1` 时,观影人数量必须等于出票数量 +> +> **业务优势**: +> - 不同场景灵活配置(音乐会可能只要手机号,戏剧演出可能需要实名) +> - 手机号用于快速订单筛选和票务状态筛选 +> - 降低用户填写负担,提升转化率 +> - 数据结构支持未来扩展到多人多票场景 + +> [!NOTE] +> **数据结构设计考虑** +> +> **阶段 1 实现**: +> - `require_viewer_per_seat=0`(默认):前端提交单个观影人,后端复制到所有票据 +> - 数据格式:使用数组结构,但阶段1只包含一个元素 +> +> **未来扩展**(阶段 2): +> - `require_viewer_per_seat=1`:前端提交观影人数组,每个座位对应一个观影人 +> - 数据格式:数组结构无需改动,直接支持多元素 +> +> **扩展性保障**:采用数组结构,平滑过渡到多人场景 + +### 数据库设计 + +> [!NOTE] +> **插件架构说明** +> +> 我们只修改 VR Ticket 插件代码,不动 ShopXO 核心。 +> +> **现有字段**: +> - `vrt_goods.vr_goods_config` LONGTEXT - 插件已有的票务配置字段(JSON 存储) +> - `vrt_goods.item_type` - 商品类型标识(插件设置为 'ticket') +> +> **数据流向**: +> 1. 后台商品编辑页 → Hook 注入 Vue 表单 +> 2. 表单提交 → `vr_goods_config_base64` 隐藏字段 +> 3. `AdminGoodsSaveHandle` Hook 解码写入 `vr_goods_config` + +#### 1. 商品配置字段(复用现有) + +**存储位置**:`vrt_goods.vr_goods_config`(JSON 格式) + +**当前结构**(已有): +```json +[ + { + "template_id": 1, + "selected_rooms": ["room_0"], + "selected_sections": {"room_0": ["A", "B"]}, + "sessions": [ + {"start": "08:00", "end": "23:59"} + ] + } +] +``` + +**新增观影人配置**(扩展): +```json +[ + { + "template_id": 1, + "selected_rooms": ["room_0"], + "selected_sections": {"room_0": ["A", "B"]}, + "sessions": [{"start": "08:00", "end": "23:59"}], + + // ===== 新增观影人配置 ===== + "viewer_config": { + "require_viewer": 1, // 是否需要观影人信息(总开关) + "require_viewer_per_seat": 0, // 是否每个座位独立填写(阶段1=0) + "require_viewer_mobile": 1, // 是否必填手机号(默认1) + "require_viewer_name": 0, // 是否必填姓名(默认0) + "require_viewer_idcard": 0 // 是否必填身份证号(默认0) + } + } +] +``` + +**说明**: +- 每个场馆配置(`template_id`)可以有独立的观影人要求 +- 如果某个场馆没有 `viewer_config`,则不需要观影人信息 +- 保持向后兼容:现有数据没有 `viewer_config` 字段不影响功能 + +#### 2. 票据表(复用现有) + +**表名**:`vrt_vr_tickets` + +无需新增表或字段,直接使用: +- `real_name` VARCHAR(60) - 观影人姓名 +- `phone` CHAR(15) - 观影人手机号 +- `id_card` CHAR(18) - 观影人身份证号(阶段1预留) + +### API 接口设计 + +#### 1. 商品详情接口增强 + +**接口**:`/api/goods/detail`(或现有的商品详情接口) + +**返回数据新增字段**: +```json +{ + "code": 0, + "data": { + "goods": { + // ... 现有商品字段 + "vr_ticket_config": { + "require_viewer": 1, + "require_viewer_mobile": 1, + "require_viewer_name": 0, + "require_viewer_idcard": 0 + } + } + } +} +``` + +**说明**:前端根据此配置决定是否展示观影人表单,以及哪些字段必填 + +#### 2. 订单提交接口修改 + +**新增请求参数**(数组结构,支持未来扩展): +```json +{ + "goods_data": "...", // 现有参数 + "payment_id": 1, // 现有参数 + "viewer_data": [ // 新增:观影人数据数组 + { + "name": "张三", // 观影人姓名(根据商品配置决定是否必填) + "mobile": "13800138000", // 观影人手机号(根据商品配置决定是否必填) + "idcard": "" // 观影人身份证号(阶段1预留) + } + ] +} +``` + +**说明**: +- **阶段 1**:`viewer_data` 数组只包含 1 个元素(所有座位共用) +- **未来扩展**:当 `require_viewer_per_seat=1` 时,数组长度等于座位数量 +- 数据结构一致,无需修改接口定义 + +**校验逻辑**: +1. 从商品配置读取 `require_viewer` 和 `require_viewer_per_seat` 标志 +2. 如果 `require_viewer=1`: + - 校验 `viewer_data` 数组不为空 + - 如果 `require_viewer_per_seat=0`:校验数组长度为 1 + - 如果 `require_viewer_per_seat=1`:校验数组长度等于购买座位数量(阶段1暂不启用) + - 遍历数组,根据配置校验每个观影人的字段 +3. 手机号格式校验:`/^1[3-9]\d{9}$/` +4. 身份证号格式校验:`/^\d{17}[\dXx]$/`(阶段1预留) + +### 代码实现计划 + +#### 步骤 0:后台商品配置界面(修改插件 Hook) + +**文件**:[AdminGoodsSave.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/.worktrees/FrontendDeveloper/shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php) + +**改动位置**:在 Vue 表单的场馆配置区域内,新增观影人配置选项 + +**具体修改**:在第 96-152 行(场馆配置循环内),场次设置之后,新增观影人配置区块: + +```html + +
+ + +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+``` + +**Vue setup() 修改**: + +1. 在 `onTemplateChange` 函数中,初始化 `viewer_config` 默认值: + +```javascript +const onTemplateChange = () => { + const oldConfigs = [...configs.value]; + configs.value = selectedTemplateIds.value.map(tid => { + const existing = oldConfigs.find(c => c.template_id === tid); + if (existing) return existing; + return { + template_id: tid, + selected_rooms: [], + selected_sections: {}, + sessions: defaultSessions(), + // 新增:观影人配置默认值 + viewer_config: { + require_viewer: 0, + require_viewer_per_seat: 0, + require_viewer_mobile: 1, // 默认必填 + require_viewer_name: 0, + require_viewer_idcard: 0 + } + }; + }); +}; +``` + +2. 在配置还原部分(第 209-242 行),确保 `viewer_config` 正确还原: + +```javascript +configs.value = AppData.vrGoodsConfig + .filter(c => validTemplateIds.has(c.template_id)) + .map(c => { + // ... 现有逻辑 + + // 新增:确保 viewer_config 结构完整 + if (!c.viewer_config) { + c.viewer_config = { + require_viewer: 0, + require_viewer_per_seat: 0, + require_viewer_mobile: 1, + require_viewer_name: 0, + require_viewer_idcard: 0 + }; + } + + return c; + }); +``` + +**无需修改**:`outputBase64` 计算属性会自动包含 `viewer_config` 字段 + +#### 步骤 1:修改订单提交接口 + +**文件**:[BuyService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/.worktrees/FrontendDeveloper/shopxo/app/service/BuyService.php#L655-L877) + +**方法**:`OrderInsert` + +**改动**: +1. 接收 `viewer_data` 数组参数 +2. 获取订单中商品的 VR 票务配置 +3. 根据配置进行动态校验(数组结构) + +```php +// 在 OrderInsert 方法中添加 +// 1. 获取商品的观影人配置 +$goods_list = $this->BuyTypeGoodsList($params); +$viewer_config = null; +foreach ($goods_list as $goods) { + if (!empty($goods['extends']['vr_ticket'])) { + $viewer_config = $goods['extends']['vr_ticket']; + break; + } +} + +// 2. 观影人信息校验 +if (!empty($viewer_config['require_viewer']) && $viewer_config['require_viewer'] == 1) { + // 检查 viewer_data 是否存在且为数组 + if (empty($params['viewer_data']) || !is_array($params['viewer_data'])) { + return DataReturn('请填写观影人信息', -1); + } + + // 阶段1:只支持单个观影人(require_viewer_per_seat=0) + $per_seat = !empty($viewer_config['require_viewer_per_seat']) ? $viewer_config['require_viewer_per_seat'] : 0; + if ($per_seat == 0) { + // 单个观影人模式:数组长度必须为1 + if (count($params['viewer_data']) != 1) { + return DataReturn('当前商品只需填写一个观影人信息', -1); + } + } else { + // 多个观影人模式:数组长度必须等于购买数量(阶段1暂不启用) + // 未来实现:校验 count($params['viewer_data']) == 购买座位数量 + return DataReturn('暂不支持每个座位独立填写观影人', -1); + } + + // 3. 遍历校验每个观影人的字段 + foreach ($params['viewer_data'] as $index => $viewer) { + // 手机号校验 + if (!empty($viewer_config['require_viewer_mobile']) && $viewer_config['require_viewer_mobile'] == 1) { + if (empty($viewer['mobile'])) { + return DataReturn('请填写观影人手机号', -1); + } + if (!preg_match('/^1[3-9]\d{9}$/', $viewer['mobile'])) { + return DataReturn('手机号格式不正确', -1); + } + } + + // 姓名校验 + if (!empty($viewer_config['require_viewer_name']) && $viewer_config['require_viewer_name'] == 1) { + if (empty($viewer['name'])) { + return DataReturn('请填写观影人姓名', -1); + } + } + + // 身份证号校验(阶段1预留) + if (!empty($viewer_config['require_viewer_idcard']) && $viewer_config['require_viewer_idcard'] == 1) { + if (empty($viewer['idcard'])) { + return DataReturn('请填写观影人身份证号', -1); + } + if (!preg_match('/^\d{17}[\dXx]$/', $viewer['idcard'])) { + return DataReturn('身份证号格式不正确', -1); + } + } + } + + // 4. 将观影人信息传递给订单数据(保持数组结构) + $params['viewer_info'] = $params['viewer_data']; +} +``` + +#### 步骤 2:修改票务出票流程 + +**文件**:[TicketService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/.worktrees/FrontendDeveloper/shopxo/app/plugins/vr_ticket/service/TicketService.php#L146-L238) + +**方法**:`issueTicket` + +**改动**: +1. 接收订单数据中的 `viewer_info` 数组参数 +2. 在生成票据时,将观影人信息分配到对应的票据 + +```php +// 在 issueTicket 方法中 +// 假设 $order_data 包含 viewer_info 数组 + +// 1. 获取观影人信息数组 +$viewer_info = $order_data['viewer_info'] ?? []; + +// 2. 遍历生成票据 +foreach ($seats as $index => $seat) { + // 阶段1:所有票据使用第一个观影人信息(数组只有1个元素) + // 未来扩展:根据 index 匹配对应的观影人 + $viewer = !empty($viewer_info[0]) ? $viewer_info[0] : []; + + // 如果未来支持 require_viewer_per_seat=1,可以这样取: + // $viewer = !empty($viewer_info[$index]) ? $viewer_info[$index] : $viewer_info[0]; + + $ticket_data = [ + // ... 现有字段 + 'real_name' => $viewer['name'] ?? '', + 'phone' => $viewer['mobile'] ?? '', + 'id_card' => $viewer['idcard'] ?? '', + // ... 其他字段 + ]; + + // 插入票据 + Db::name('vr_tickets')->insert($ticket_data); +} +``` + +**说明**: +- 阶段1:`$viewer_info[0]` 复制到所有票据 +- 未来扩展:根据 `$index` 匹配不同观影人,无需修改数据结构 + +#### 步骤 3:数据传递机制 + +**关键问题**:如何将 `viewer_info` 从 `BuyService::OrderInsert` 传递到 `TicketService::issueTicket`? + +**方案 A**:通过订单扩展数据字段传递 +- 将 `viewer_info` 存入 `vrt_order.extend_data` JSON 字段 +- `issueTicket` 从订单记录中读取 + +**方案 B**:通过插件 Hook 传递 +- 在 `OrderInsert` 触发插件 Hook 时,将 `viewer_info` 放入事件参数 +- 需要检查 ShopXO 的 Hook 机制是否支持 + +**推荐**:方案 A 更稳定,不依赖 Hook 机制 + +### 验证方案 + +#### 手动测试步骤 + +1. **创建测试订单** + ```bash + curl -X POST http://localhost/api/buy/add \ + -d "goods_data=..." \ + -d "payment_id=1" \ + -d "viewer_name=测试用户" \ + -d "viewer_mobile=13800138000" + ``` + +2. **检查票据表数据** + ```sql + SELECT id, ticket_code, real_name, phone + FROM vrt_vr_tickets + WHERE order_id = [刚创建的订单ID]; + ``` + +3. **验证必填校验** + ```bash + # 测试不提供手机号 + curl -X POST http://localhost/api/buy/add \ + -d "goods_data=..." \ + -d "payment_id=1" + # 预期:如果商品要求观影人信息,应返回错误 + ``` + +4. **验证手机号格式** + ```bash + # 测试错误格式手机号 + curl -X POST http://localhost/api/buy/add \ + -d "viewer_mobile=12345" + # 预期:返回"手机号格式不正确"错误 + ``` + +### 前端集成指南 + +#### 订单确认页改动 + +**文件**:`goods-vr-ticket-order.vue`(前端 UniApp) + +**改动点**: +1. 在座位信息卡片和购票须知之间,插入观影人表单卡片 +2. 表单字段根据商品配置动态展示 +3. 提交订单时,将观影人信息组装为数组格式 + +**示例代码**: +```vue + + 观影人信息 + + + + + + + + + + + + + + + +``` + +**说明**: +- 阶段1:`viewer_data` 数组固定长度为 1 +- 未来扩展:当 `require_viewer_per_seat=1` 时,循环生成多个观影人表单 + +#### 自动填充优化(可选) + +如果用户账号有手机号,可以自动填充: +```javascript +// 页面加载时 +onLoad() { + if (this.userInfo.mobile) { + this.viewerMobile = this.userInfo.mobile; + } +} +``` + +### 开发工作量评估 + +#### 后端改动(约 3-4 小时) +- ✅ 后台商品配置界面:1 小时 +- ✅ 商品详情接口返回配置:30 分钟 +- ✅ 修改 `BuyService::OrderInsert`:1 小时 +- ✅ 修改 `TicketService::issueTicket`:30 分钟 +- ✅ 实现数据传递机制:30 分钟 +- ✅ 手动测试验证:1 小时 + +#### 前端改动(约 2-3 小时) +- ✅ 读取商品配置并展示观影人表单:1.5 小时 +- ✅ 根据配置动态校验必填字段:30 分钟 +- ✅ 集成测试:1 小时 + +**总计**:5-7 小时 + +### 实施优先级 + +#### P0(核心功能 - 本次实施) +1. ✅ **步骤 0**:后台商品配置界面(必须先做) +2. ✅ **步骤 1**:订单提交接口校验逻辑 +3. ✅ **步骤 2**:票务出票流程集成 +4. ✅ **步骤 3**:数据传递机制(方案 A:订单扩展字段) +5. ✅ **前端集成**:订单确认页观影人表单 + +#### P1(体验优化 - 后续迭代) +- 手机号自动填充(从用户账号读取) +- 商品详情接口返回观影人配置 +- 前端根据配置动态展示表单 + +--- + +## 阶段 2 实现方案(TODO) + +### 功能范围 + +1. **用户观影人管理页** + - 观影人列表展示 + - 新增观影人 + - 编辑观影人 + - 删除观影人 + - 设置默认观影人 + +2. **订单确认页集成** + - 快速选择历史观影人 + - 自动填充观影人信息 + - 优先展示默认观影人 + +3. **数据库设计** + - 创建 `vrt_user_viewer` 表 + - 实现观影人 CRUD Service + - 实现观影人 API 接口 + +### 开发工作量评估(TODO) + +- 后端:6-8 小时 +- 前端:6-8 小时 +- 测试:2-3 小时 +- **总计**:14-19 小时 + +### 阶段 2 触发条件 + +当以下任一条件满足时,考虑实施阶段 2: +1. 用户反馈每次填写观影人信息太麻烦 +2. 运营数据显示用户重复购票比例较高 +3. 产品需要硬实名认证功能(需要保存身份证号) + +--- + +## 关键决策记录 + +| 决策点 | 阶段 1 方案 | 阶段 2 方案(TODO) | +|--------|-------------|---------------------| +| 数据存储 | 直接写入票据表 | 新增用户观影人表 + 票据表 | +| 多票多人 | 一个订单一个观影人 | 支持每个座位独立观影人 | +| 自动填充 | 前端从用户信息读取 | 后端返回历史观影人列表 | +| 姓名字段 | 可选(待确认) | 必填 | +| 身份证号 | 不使用 | 预留/必填(取决于实名要求) | + +--- + +## 下一步行动 + +1. **确认姓名字段**:必填还是可选? +2. **确认多票多人**:一个订单一个观影人,还是每个座位独立? +3. **开始实施阶段 1**: + - 修改 `BuyService::OrderInsert` + - 修改 `TicketService::issueTicket` + - 前端提供观影人表单 + +等待您的确认后,我立即开始实施阶段 1。 + +--- + +**文档版本**:v2.0(修订版) +**创建时间**:2026-06-24 +**修订原因**:根据用户反馈,分阶段实施,优先完成核心功能 diff --git a/docs/plan/观影人改造- walkthrough.md b/docs/plan/观影人改造- walkthrough.md new file mode 100644 index 00000000..7e24e70c --- /dev/null +++ b/docs/plan/观影人改造- walkthrough.md @@ -0,0 +1,289 @@ +# 观影人信息管理功能 - 实施总结 + +## ✅ 已完成的工作 + +### 1. 插件代码修改(纯插件实现) + +#### 修改文件清单 + +**文件 1**: [Hook.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/.worktrees/FrontendDeveloper/shopxo/app/plugins/vr_ticket/Hook.php) +- **新增方法**: `ValidateViewerData()` (第397-496行) + - 从商品 `extends['vr_ticket']` 读取观影人配置 + - 校验 `viewer_data` 数组格式和长度 + - 根据配置校验手机号、姓名、身份证号(支持独立开关) + - 将观影人信息注入 `$params['extension_data']['viewer_info']` +- **修改方法**: `BuyCheck()` (第291-395行) + - 在停售时间校验后增加观影人校验调用 + - 校验失败则阻止订单提交 + +**文件 2**: [TicketService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/.worktrees/FrontendDeveloper/shopxo/app/plugins/vr_ticket/service/TicketService.php) +- **修改方法**: `issueTicket()` (第222-249行) + - 从 `order['extension_data']['viewer_info']` 读取观影人数组 + - 阶段1:所有票使用第一个观影人信息 + - 保持向后兼容旧格式 `attendee` + - 支持字段映射:`name`→`real_name`, `mobile`→`phone`, `idcard`→`id_card` + +#### 数据流程 + +``` +前端提交订单 + ↓ (viewer_data 数组参数) +Hook::BuyCheck (plugins_service_buy_order_insert_begin) + ↓ (校验成功) +Hook::ValidateViewerData + ↓ (注入 extension_data) +BuyService::OrderInsert + ↓ (创建订单,存储 extension_data 到数据库) +订单支付成功 + ↓ (plugins_service_order_pay_success_handle_end) +TicketService::onOrderPaid + ↓ +TicketService::issueTicket + ↓ (读取 extension_data.viewer_info[0]) +更新 vrt_tickets 表 (real_name, phone, id_card) +``` + +--- + +## 📋 配置结构 + +### 商品扩展配置 (存储在 `sxo_goods.extends` JSON 字段) + +```json +{ + "vr_ticket": { + "require_viewer": 1, // 总开关:是否需要观影人信息 + "require_viewer_per_seat": 0, // 多座位模式(阶段1=0,未来=1) + "require_viewer_mobile": 1, // 必填手机号 + "require_viewer_name": 0, // 必填姓名(可选) + "require_viewer_idcard": 0 // 必填身份证号(预留) + } +} +``` + +### 订单扩展数据 (存储在 `sxo_order.extension_data` JSON 字段) + +```json +{ + "viewer_info": [ + { + "name": "张三", + "mobile": "13800138000", + "idcard": "" + } + ] +} +``` + +--- + +## 🧪 测试步骤 + +### 前置准备 + +1. **配置商品观影人要求** + + 手动更新商品的 `extends` 字段(临时方法,等后台界面完成后使用界面配置): + + ```sql + UPDATE vrt_goods + SET extends = JSON_SET( + COALESCE(extends, '{}'), + '$.vr_ticket.require_viewer', 1, + '$.vr_ticket.require_viewer_mobile', 1, + '$.vr_ticket.require_viewer_name', 0 + ) + WHERE id = <商品ID>; + ``` + +### 测试用例 1:必填手机号校验 + +**前端请求**: +```bash +curl -X POST 'http://localhost/api/buy/add' \ + -H 'Content-Type: application/json' \ + -d '{ + "goods_data": "...", + "payment_id": 1 + }' +``` + +**预期结果**:返回错误 `{"code": -1, "msg": "请填写观影人信息"}` + +### 测试用例 2:手机号格式校验 + +**前端请求**: +```bash +curl -X POST 'http://localhost/api/buy/add' \ + -H 'Content-Type: application/json' \ + -d '{ + "goods_data": "...", + "payment_id": 1, + "viewer_data": [ + {"name": "张三", "mobile": "12345", "idcard": ""} + ] + }' +``` + +**预期结果**:返回错误 `{"code": -1, "msg": "手机号格式不正确"}` + +### 测试用例 3:正常提交订单 + +**前端请求**: +```bash +curl -X POST 'http://localhost/api/buy/add' \ + -H 'Content-Type: application/json' \ + -d '{ + "goods_data": "...", + "payment_id": 1, + "viewer_data": [ + {"name": "张三", "mobile": "13800138000", "idcard": ""} + ] + }' +``` + +**预期结果**: +1. 订单创建成功 +2. 查询订单表:`extension_data` 包含 `viewer_info` +3. 订单支付后,查询票据表:`real_name="张三"`, `phone="13800138000"` + +```sql +-- 查询订单扩展数据 +SELECT id, order_no, extension_data +FROM vrt_order +WHERE id = <订单ID>; + +-- 查询票据观影人信息 +SELECT id, ticket_code, real_name, phone, id_card +FROM vrt_vr_tickets +WHERE order_id = <订单ID>; +``` + +--- + +## 📌 待完成工作 + +### P0 - 后台配置界面 + +**目标**:在插件后台提供商品观影人配置管理界面 + +**实施方案**: +1. 在 `admin/Admin.php` 中添加配置管理方法 +2. 创建后台配置页面模板 +3. 提供商品列表,支持批量配置观影人要求 + +**预计工作量**:1-2 小时 + +### P1 - 前端集成 + +**目标**:订单确认页展示观影人表单 + +**文件**:`vr-shopxo-uniapp/pages/goods-vr-ticket/goods-vr-ticket-order.vue` + +**实施要点**: +1. 从商品详情读取 `vr_ticket_config` +2. 动态展示观影人表单(根据配置显示必填字段) +3. 提交订单时,将观影人信息组装为 `viewer_data` 数组 +4. 集成微信小程序 `getPhoneNumber` 快速授权 + +**预计工作量**:2-3 小时 + +参考实现:[implementation_plan.md](file:///Users/bigemon/.gemini/antigravity-ide/brain/bb454876-da6d-4c57-8e48-ce7de6be1116/implementation_plan.md#L409-L522) + +--- + +## 🔍 关键设计决策 + +### 1. 纯插件实现 + +✅ **不修改 ShopXO 核心代码** +- 通过 Hook `plugins_service_buy_order_insert_begin` 介入订单流程 +- 升级 ShopXO 不受影响 + +### 2. 数组结构的前瞻性 + +✅ **支持未来扩展到多人多票** + +**阶段1**(当前): +```json +"viewer_data": [{"name": "张三", "mobile": "138..."}] // 长度=1 +``` + +**阶段2**(未来): +```json +"viewer_data": [ + {"name": "张三", "mobile": "138..."}, // 座位1 + {"name": "李四", "mobile": "139..."} // 座位2 +] +``` + +代码无需修改接口,只需: +- `ValidateViewerData`: 校验数组长度 = 座位数量 +- `issueTicket`: 根据座位索引匹配观影人 + +### 3. 灵活的字段控制 + +✅ **5 个独立开关,商家自定义配置** + +| 开关 | 说明 | 阶段1状态 | +|------|------|-----------| +| `require_viewer` | 总开关 | 可用 | +| `require_viewer_per_seat` | 多座位模式 | 暂不启用 | +| `require_viewer_mobile` | 必填手机号 | 可用(核心字段)| +| `require_viewer_name` | 必填姓名 | 可用 | +| `require_viewer_idcard` | 必填身份证号 | 预留 | + +### 4. 向后兼容 + +✅ **保持对旧数据格式的支持** + +`issueTicket` 同时支持: +- 新格式:`extension_data.viewer_info` (数组) +- 旧格式:`extension_data.attendee` (对象) + +--- + +## 📝 注意事项 + +### 表前缀 + +代码中使用 `BaseService::table('tickets')` 或 `Db::name('vr_tickets')` 而不是硬编码 `vrt_vr_tickets`,因为 ShopXO 会自动添加配置的表前缀。 + +### 日志调试 + +观影人校验过程已添加日志: +```php +BaseService::log('ValidateViewerData: success', [ + 'viewer_count' => count($viewer_data), + 'viewer_data' => $viewer_data +], 'info'); +``` + +查看日志文件(需确认实际路径): +```bash +tail -f shopxo/runtime/log/*.log | grep ValidateViewerData +``` + +--- + +## 🚀 部署清单 + +### 1. 代码部署 + +将修改后的文件部署到生产环境: +- `shopxo/app/plugins/vr_ticket/Hook.php` +- `shopxo/app/plugins/vr_ticket/service/TicketService.php` + +### 2. 配置示例商品 + +为测试商品配置观影人要求(使用上述 SQL) + +### 3. 前端更新 + +等待前端团队完成订单确认页的观影人表单集成 + +--- + +**完成时间**:2026-06-24 +**实施人员**:Claude Code (Backend) +**文档版本**:v1.0 diff --git a/pages/goods-vr-ticket-order/goods-vr-ticket-order.vue b/pages/goods-vr-ticket-order/goods-vr-ticket-order.vue index a3a9fba5..0130a0418 100644 --- a/pages/goods-vr-ticket-order/goods-vr-ticket-order.vue +++ b/pages/goods-vr-ticket-order/goods-vr-ticket-order.vue @@ -409,6 +409,18 @@ export default { onLoad(params) { const app = getApp(); this.app = app; + + // 如果是通过 base64 传递的单一 data 参数,先解包 + if (params && params.data) { + try { + const base64 = require('@/common/js/lib/base64.js'); + const decodedStr = base64.decode(decodeURIComponent(params.data)); + const decodedParams = JSON.parse(decodedStr); + params = Object.assign({}, decodedParams, params); + } catch (e) { + console.error('[Order Confirm] Unpack params.data failed:', e); + } + } // 主题 this.theme_view = app.globalData.get_theme_value_view(); @@ -427,41 +439,23 @@ export default { // 解析座位数据 if (params.seats) { - try { - this.confirmedSeats = JSON.parse(decodeURIComponent(params.seats)); - } catch (e) { - console.error('[Order Confirm] Parse seats failed:', e); - this.confirmedSeats = []; - } + this.confirmedSeats = this.safeParseJson(params.seats) || []; } // 解析选中的规格 if (params.selection) { - try { - this.currentSelection = JSON.parse(decodeURIComponent(params.selection)); - } catch (e) { - console.error('[Order Confirm] Parse selection failed:', e); - this.currentSelection = {}; - } + this.currentSelection = this.safeParseJson(params.selection) || {}; } // 解析须知数据 if (params.buyNotices) { - try { - this.buyNotices = JSON.parse(decodeURIComponent(params.buyNotices)); - } catch (e) { - this.buyNotices = this.defaultBuyNotices; - } + this.buyNotices = this.safeParseJson(params.buyNotices) || this.defaultBuyNotices; } else { this.buyNotices = this.defaultBuyNotices; } if (params.services) { - try { - this.services = JSON.parse(decodeURIComponent(params.services)); - } catch (e) { - this.services = this.defaultServices; - } + this.services = this.safeParseJson(params.services) || this.defaultServices; } else { this.services = this.defaultServices; } @@ -473,28 +467,23 @@ export default { // 解析场次日期时间 if (params.sessionDatetime) { - this.sessionDatetime = decodeURIComponent(params.sessionDatetime); + this.sessionDatetime = this.safeDecodeURIComponent(params.sessionDatetime); } // 解析海报URL if (params.posterUrl) { - this.posterUrl = decodeURIComponent(params.posterUrl); + this.posterUrl = this.safeDecodeURIComponent(params.posterUrl); } // 解析场馆地址 if (params.venueAddress) { - this.venueAddress = decodeURIComponent(params.venueAddress); + this.venueAddress = this.safeDecodeURIComponent(params.venueAddress); } // 解析观影人配置 if (params.viewerConfig) { - try { - this.viewerConfig = JSON.parse(decodeURIComponent(params.viewerConfig)); - console.log('[Order] viewerConfig loaded:', this.viewerConfig); - } catch (e) { - console.error('[Order] Failed to parse viewerConfig:', e); - this.viewerConfig = { require_viewer: false }; - } + this.viewerConfig = this.safeParseJson(params.viewerConfig) || { require_viewer: false }; + console.log('[Order] viewerConfig loaded:', this.viewerConfig); } // 自动填充观影人信息 @@ -987,6 +976,32 @@ export default { uni.navigateBack(); }, + // 安全解析 JSON + safeParseJson(val) { + if (!val) return null; + if (typeof val === 'object') return val; + try { + return JSON.parse(val); + } catch (e) { + try { + return JSON.parse(decodeURIComponent(val)); + } catch (e2) { + console.error('[Order Confirm] safeParseJson failed for:', val, e2); + return null; + } + } + }, + + // 安全解码 URI Component + safeDecodeURIComponent(val) { + if (!val) return ''; + try { + return decodeURIComponent(val); + } catch (e) { + return val; + } + }, + // 设置数据的兼容方法 setData(obj) { Object.keys(obj).forEach(key => { diff --git a/pages/goods-vr-ticket/goods-vr-ticket.vue b/pages/goods-vr-ticket/goods-vr-ticket.vue index caf03bb6..32730183 100644 --- a/pages/goods-vr-ticket/goods-vr-ticket.vue +++ b/pages/goods-vr-ticket/goods-vr-ticket.vue @@ -608,30 +608,28 @@ export default { } } - // 组装跳转参数 - const params = { + // 组装跳转参数,使用 base64 封装以避免 URL 刷新丢失或格式解析错误 + const dataParams = { goodsId: this.goodsId, - seats: encodeURIComponent(JSON.stringify(this.confirmedSeats)), - selection: encodeURIComponent(JSON.stringify(this.currentSelection)), - buyNotices: encodeURIComponent(JSON.stringify(this.buyNotices)), - services: encodeURIComponent(JSON.stringify(this.services)), + seats: JSON.stringify(this.confirmedSeats), + selection: JSON.stringify(this.currentSelection), + buyNotices: JSON.stringify(this.buyNotices), + services: JSON.stringify(this.services), batchExpire: this.goodsData.batch_number_expire || '', sessionDatetime: sessionDatetime, posterUrl: posterImg, venueAddress: this.currentVenue?.address || '', - viewerConfig: viewerConfig ? encodeURIComponent(JSON.stringify(viewerConfig)) : '' + viewerConfig: viewerConfig ? JSON.stringify(viewerConfig) : '' }; - // 拼接 URL 参数 - let url = '/pages/goods-vr-ticket-order/goods-vr-ticket-order?'; - Object.keys(params).forEach(key => { - url += `${key}=${params[key]}&`; - }); + const base64 = require('@/common/js/lib/base64.js'); + const dataStr = encodeURIComponent(base64.encode(JSON.stringify(dataParams))); + const url = `/pages/goods-vr-ticket-order/goods-vr-ticket-order?data=${dataStr}`; console.log('[VR Ticket] Navigate to order confirm:', url); uni.navigateTo({ - url: url.slice(0, -1) // 移除末尾的 & + url: url }); } },