fix(goods-vr-ticket-order): 修复 H5 刷新页面导致参数丢失的问题

问题原因:
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 数据解析)
master
Council 2026-06-27 17:50:27 +08:00
parent 29e6f0a6d0
commit 4c2c9a3ed6
5 changed files with 4425 additions and 45 deletions

3346
App_local.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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
<!-- 在场次管理之后,演播厅选择之前添加 -->
<div class="am-form-group" style="background: #f0f8ff; padding: 12px; border: 1px solid #b0d4f1; border-radius: 4px; margin-bottom:15px;">
<label style="font-weight:bold;">
观影人信息设置
<span style="color:#999; font-size:12px; font-weight:normal; margin-left:8px;">(票务实名制选项)</span>
</label>
<div style="margin-top:10px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer" />
<span style="margin-left:5px;">需要填写观影人信息</span>
</label>
</div>
<div v-show="config.viewer_config.require_viewer" style="margin-left:20px; margin-top:10px; padding-left:15px; border-left:3px solid #ddd;">
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_mobile" disabled checked />
<span style="margin-left:5px;">必填手机号</span>
<small style="color:#999; margin-left:8px;">(核心字段,默认必填)</small>
</label>
</div>
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_name" />
<span style="margin-left:5px;">必填姓名</span>
</label>
</div>
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_idcard" />
<span style="margin-left:5px;">必填身份证号</span>
<small style="color:#999; margin-left:8px;">(预留功能)</small>
</label>
</div>
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_per_seat" disabled />
<span style="margin-left:5px;">每个座位独立填写观影人</span>
<small style="color:#999; margin-left:8px;">阶段2功能暂不可用</small>
</label>
</div>
</div>
</div>
```
**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
<view class="section-card viewer-card" v-if="viewerConfig.require_viewer">
<view class="section-title">观影人信息</view>
<input
v-if="viewerConfig.require_viewer_name"
v-model="viewerName"
placeholder="请输入姓名"
:class="{'required': viewerConfig.require_viewer_name}"
/>
<input
v-if="viewerConfig.require_viewer_mobile"
v-model="viewerMobile"
placeholder="请输入手机号"
type="number"
maxlength="11"
:class="{'required': viewerConfig.require_viewer_mobile}"
/>
<!-- 阶段1暂不展示身份证号 -->
<!-- <input v-if="viewerConfig.require_viewer_idcard" v-model="viewerIdcard" placeholder="请输入身份证号" /> -->
<!-- 微信小程序快速获取手机号 -->
<button
v-if="viewerConfig.require_viewer_mobile"
open-type="getPhoneNumber"
@getphonenumber="onGetPhoneNumber"
class="quick-fill-btn"
>
快速填写手机号
</button>
</view>
<script>
export default {
data() {
return {
viewerName: '',
viewerMobile: '',
viewerIdcard: '',
viewerConfig: {
require_viewer: 0,
require_viewer_per_seat: 0,
require_viewer_mobile: 0,
require_viewer_name: 0,
require_viewer_idcard: 0
}
}
},
onLoad() {
// 从商品详情获取观影人配置
this.viewerConfig = this.goodsInfo.vr_ticket_config || {};
// 自动填充:从用户信息读取手机号
if (this.userInfo.mobile) {
this.viewerMobile = this.userInfo.mobile;
}
},
methods: {
// 微信小程序快速获取手机号
onGetPhoneNumber(e) {
if (e.detail.code) {
// 调用后端接口解密手机号
uni.request({
url: '/api/wechat/decrypt_phone',
data: { code: e.detail.code },
success: (res) => {
if (res.data.code === 0) {
this.viewerMobile = res.data.data.phone;
}
}
});
}
},
submitOrder() {
// 阶段1组装单个观影人数组
const viewerData = [{
name: this.viewerName,
mobile: this.viewerMobile,
idcard: this.viewerIdcard
}];
const params = {
goods_data: this.goodsData,
payment_id: this.paymentId,
viewer_data: viewerData // 数组结构
};
// 调用订单提交接口
uni.request({
url: '/api/buy/add',
method: 'POST',
data: params,
success: (res) => {
// 处理结果
}
});
}
}
}
</script>
<style scoped>
.viewer-card .required::after {
content: '*';
color: red;
}
.quick-fill-btn {
margin-top: 10px;
background: #07c160;
color: white;
}
</style>
```
**说明**
- 阶段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
**修订原因**:根据用户反馈,分阶段实施,优先完成核心功能

View File

@ -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

View File

@ -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 => {

View File

@ -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
});
}
},