diff --git a/docs/PHASE_4_3_IMPLEMENTATION_PLAN.md b/docs/PHASE_4_3_IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..ceb0a24
--- /dev/null
+++ b/docs/PHASE_4_3_IMPLEMENTATION_PLAN.md
@@ -0,0 +1,273 @@
+# Phase 4.3 实现计划:C端票夹
+
+> 创建日期:2026-04-23
+> 状态:**待实现**
+> 依赖:Phase 4.1 (HMAC-XOR) + Phase 4.2 (issueTicket) 已完成
+
+---
+
+## 一、文件清单
+
+| 步骤 | 文件 | 类型 | 说明 |
+|------|------|------|------|
+| 1 | `api/Ticket.php` | 新建 | C端 API 控制器 |
+| 2 | `service/WalletService.php` | 新建 | 票夹核心服务 |
+| 3 | `view/goods/ticket_card.html` | 新建 | 票卡片共享组件 |
+| 4 | `view/goods/ticket_wallet.html` | 新建 | 票夹列表页 |
+| 5 | `Hook.php` | 修改 | 注册 C 端挂载点钩子 |
+
+---
+
+## 二、API 设计
+
+### 2.1 获取用户票列表
+
+```
+GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
+
+Headers:
+ X-Token: {user_token}
+
+Response:
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "tickets": [
+ {
+ "id": 1,
+ "goods_id": 118,
+ "goods_title": "周杰伦演唱会",
+ "seat_info": "A区-3排-15座",
+ "session_time": "2026-06-01 20:00",
+ "venue_name": "国家体育馆",
+ "real_name": "张三",
+ "verify_status": 0,
+ "issued_at": 1745286000,
+ "short_code": "003a2hgmgety"
+ }
+ ],
+ "count": 1
+ }
+}
+```
+
+### 2.2 获取票详情(含 QR payload)
+
+```
+GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=detail&id={ticket_id}
+
+Headers:
+ X-Token: {user_token}
+
+Response:
+{
+ "code": 0,
+ "msg": "success",
+ "data": {
+ "ticket": {
+ "id": 1,
+ "goods_id": 118,
+ "goods_title": "周杰伦演唱会",
+ "seat_info": "A区-3排-15座",
+ "session_time": "2026-06-01 20:00",
+ "venue_name": "国家体育馆",
+ "real_name": "张三",
+ "phone": "138****1234",
+ "verify_status": 0,
+ "short_code": "003a2hgmgety",
+ "qr_payload": "eyJpZCI6MSwiZyI6MTE4LCJpYXQiOjE3NDUyODY2MDAsImV4cCI6MTc0NTI4NzIwMCwic2lnIjoiQTNGOUIyQzEifQ==",
+ "qr_expires_at": 1745287200,
+ "qr_expires_in": 1800
+ }
+ }
+}
+```
+
+### 2.3 刷新 QR payload
+
+```
+GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=refreshQr&id={ticket_id}
+
+Headers:
+ X-Token: {user_token}
+
+Response: 同 2.2
+```
+
+---
+
+## 三、数据流程
+
+```
+用户访问票夹页
+ ↓
+Hook 注入票夹入口(或直接在商品详情页显示)
+ ↓
+ticket_wallet.html 加载
+ ↓
+JS 调用 /ticket/list API 获取票列表
+ ↓
+渲染 ticket_card 列表
+ ↓
+点击单个票 → 调用 /ticket/detail API → 展示 QR + 短码
+```
+
+---
+
+## 四、实现步骤
+
+### Step 1: WalletService.php
+
+```php
+// service/WalletService.php
+namespace app\plugins\vr_ticket\service;
+
+class WalletService extends BaseService
+{
+ /**
+ * 获取用户所有票
+ */
+ public static function getUserTickets(int $userId): array
+
+ /**
+ * 获取单个票详情
+ */
+ public static function getTicketDetail(int $ticketId, int $userId): ?array
+
+ /**
+ * 生成 QR payload(含缓存逻辑)
+ *
+ * 缓存策略:
+ * - QR 有效期 30 分钟
+ * - 剩余有效期 > 15 分钟:返回缓存
+ * - 剩余有效期 ≤ 15 分钟:刷新
+ */
+ public static function getQrPayload(int $ticketId): array
+}
+```
+
+### Step 2: api/Ticket.php
+
+```php
+// api/Ticket.php
+namespace app\plugins\vr_ticket\api;
+
+class Ticket
+{
+ /**
+ * 获取用户票列表
+ * GET ?s=api/plugins/...&pluginsaction=list
+ */
+ public function list(): Json
+
+ /**
+ * 获取票详情
+ * GET ?s=api/plugins/...&pluginsaction=detail&id=X
+ */
+ public function detail(): Json
+
+ /**
+ * 刷新 QR payload
+ * GET ?s=api/plugins/...&pluginsaction=refreshQr&id=X
+ */
+ public function refreshQr(): Json
+}
+```
+
+**路由验证**:
+- `pluginsname=vr_ticket` → `vr_ticket` 目录
+- `pluginscontrol=ticket` → `api/Ticket.php` (ucfirst('ticket') = 'Ticket')
+- `pluginsaction=list` → `Ticket::list()`
+
+### Step 3: ticket_card.html
+
+票卡片组件,包含:
+- 商品信息(标题、场次、座位)
+- 观演人信息
+- QR 码展示区(懒加载)
+- 短码展示
+- 核销状态标识
+
+### Step 4: ticket_wallet.html
+
+票夹页面:
+- 加载 JS/CSS(qrcode.js、jQuery)
+- 调用 `/ticket/list` 获取票列表
+- 渲染票卡片列表
+- 空状态提示
+
+### Step 5: Hook.php 修改
+
+注册 C 端挂载点钩子:
+```php
+// 在 handle() 方法中添加
+case 'plugins_service_order_detail_page_info':
+ $this->InjectTicketCard($params);
+ break;
+```
+
+---
+
+## 五、QR 码展示逻辑
+
+```javascript
+// ticket_card.html 伪代码
+function loadQrCode(ticketId) {
+ // 1. 检查 localStorage 缓存
+ const cached = localStorage.getItem('vr_qr_' + ticketId);
+ if (cached) {
+ const data = JSON.parse(cached);
+ if (data.expires_at > Date.now() / 1000) {
+ // 缓存有效,剩余 > 15 分钟则直接展示
+ const remaining = data.expires_at - Date.now() / 1000;
+ if (remaining > 900) {
+ renderQr(data.payload);
+ return;
+ }
+ }
+ }
+
+ // 2. 调用 API 获取新 QR
+ $.get('/api/plugins/...&pluginsaction=detail&id=' + ticketId, function(res) {
+ if (res.code === 0) {
+ // 3. 缓存到 localStorage
+ localStorage.setItem('vr_qr_' + ticketId, JSON.stringify({
+ payload: res.data.ticket.qr_payload,
+ expires_at: res.data.ticket.qr_expires_at
+ }));
+ // 4. 渲染 QR
+ renderQr(res.data.ticket.qr_payload);
+ }
+ });
+}
+
+function renderQr(base64Payload) {
+ const payload = atob(base64Payload);
+ $('#qrcode').qrcode({ text: payload });
+}
+```
+
+---
+
+## 六、测试用例
+
+| 用例 | 预期结果 |
+|------|----------|
+| 未登录访问 | 返回 401 |
+| 无票用户 | 返回空列表 |
+| 有票用户 | 返回票列表 |
+| 点击票卡片 | 展示 QR + 短码 |
+| QR 过期前刷新 | 获取新 QR |
+| 核销后展示 | 显示已核销状态 |
+
+---
+
+## 七、进度记录
+
+- [ ] Step 1: WalletService.php
+- [ ] Step 2: api/Ticket.php
+- [ ] Step 3: ticket_card.html
+- [ ] Step 4: ticket_wallet.html
+- [ ] Step 5: Hook.php
+- [ ] Step 6: 联调测试
diff --git a/docs/PHASE_4_PLAN.md b/docs/PHASE_4_PLAN.md
index 31e2897..0fb45a4 100644
--- a/docs/PHASE_4_PLAN.md
+++ b/docs/PHASE_4_PLAN.md
@@ -1,7 +1,8 @@
# Phase 4 规划:发票 · 核销 · 票夹
> 规划日期:2026-04-22
-> 状态:**规划完成,待启动调研**
+> 最后更新:2026-04-23(算法变更:Feistel-8 → HMAC-XOR,BUG修复)
+> 状态:**Phase 4.1/4.2 已完成,B端核销进行中**
---
diff --git a/shopxo/app/plugins/vr_ticket/Hook.php b/shopxo/app/plugins/vr_ticket/Hook.php
index ebe98a9..b81d7b5 100644
--- a/shopxo/app/plugins/vr_ticket/Hook.php
+++ b/shopxo/app/plugins/vr_ticket/Hook.php
@@ -22,6 +22,11 @@ class Hook
$ret = TicketService::onOrderPaid($params);
break;
+ case 'plugins_service_order_detail_page_info':
+ // C端订单详情页注入票夹入口
+ $ret = $this->InjectTicketCard($params);
+ break;
+
case 'plugins_service_order_delete_success':
// 如果有删除拦截等
break;
@@ -106,5 +111,131 @@ class Hook
]
];
}
+
+ /**
+ * C端订单详情页注入票卡片
+ */
+ public function InjectTicketCard(&$params)
+ {
+ $order = $params['order'] ?? [];
+ if (empty($order) || ($order['pay_status'] ?? 0) != 1) {
+ return;
+ }
+
+ $userId = session('user_id');
+ if (empty($userId)) {
+ return;
+ }
+
+ $tickets = \think\facade\Db::name('vr_tickets')
+ ->where('order_id', $order['id'])
+ ->select()
+ ->toArray();
+
+ if (empty($tickets)) {
+ return;
+ }
+
+ $token = session('user_token') ?: '';
+ $hostUrl = \think\facade\Config::get('shopxo.host_url');
+
+ $ticketCardsHtml = '';
+ foreach ($tickets as $ticket) {
+ $shortCode = \app\plugins\vr_ticket\service\BaseService::shortCodeEncode($ticket['goods_id'], $ticket['id']);
+ $statusMap = [0 => ['text' => '未核销', 'class' => 'unverified'], 1 => ['text' => '已核销', 'class' => 'verified'], 2 => ['text' => '已退款', 'class' => 'refunded']];
+ $status = $statusMap[$ticket['verify_status']] ?? $statusMap[0];
+
+ $ticketCardsHtml .= '
' .
+ '' .
+ '
' .
+ '
💺' . htmlspecialchars($ticket['seat_info'] ?? '') . '
' .
+ '
👤' . htmlspecialchars($ticket['real_name'] ?? '') . '
' .
+ '
' .
+ '' .
+ '
';
+ }
+
+ $style = '';
+
+ $ticketHtml = '' .
+ '
📋 我的电子票
' .
+ $ticketCardsHtml .
+ '
';
+
+ $params['page_data']['ticket_section'] = $ticketHtml;
+ $params['page_data']['ticket_css'] = $style;
+
+ // JS
+ $js = '';
+ $params['page_data']['ticket_js'] = $js;
+ }
}
?>
\ No newline at end of file
diff --git a/shopxo/app/plugins/vr_ticket/api/Ticket.php b/shopxo/app/plugins/vr_ticket/api/Ticket.php
new file mode 100644
index 0000000..a99e20a
--- /dev/null
+++ b/shopxo/app/plugins/vr_ticket/api/Ticket.php
@@ -0,0 +1,212 @@
+header('X-Token') ?: request()->header('Authorization', '');
+ if (!empty($token)) {
+ $token = str_replace('Bearer ', '', $token);
+ $user = self::parseToken($token);
+ if (!empty($user['id'])) {
+ return intval($user['id']);
+ }
+ }
+
+ // 方式2:从 session 获取
+ $userId = session('user_id');
+ if (!empty($userId)) {
+ return intval($userId);
+ }
+
+ return null;
+ }
+
+ /**
+ * 解析 Token(JWT格式)
+ *
+ * @param string $token
+ * @return array
+ */
+ private static function parseToken(string $token): array
+ {
+ $parts = explode('.', $token);
+ if (count($parts) !== 3) {
+ return [];
+ }
+
+ $payload = base64_decode(strtr($parts[1], '-_', '+/'));
+ if ($payload === false) {
+ return [];
+ }
+
+ $data = json_decode($payload, true);
+ return is_array($data) ? $data : [];
+ }
+
+ /**
+ * 返回未登录错误
+ *
+ * @return Json
+ */
+ private static function unauthorized(string $msg = '请先登录'): Json
+ {
+ return json([
+ 'code' => 401,
+ 'msg' => $msg,
+ 'data' => [],
+ ]);
+ }
+
+ /**
+ * 返回成功响应
+ *
+ * @param mixed $data
+ * @param string $msg
+ * @return Json
+ */
+ private static function success($data = [], string $msg = 'success'): Json
+ {
+ return json([
+ 'code' => 0,
+ 'msg' => $msg,
+ 'data' => $data,
+ ]);
+ }
+
+ /**
+ * 返回错误响应
+ *
+ * @param string $msg
+ * @param int $code
+ * @return Json
+ */
+ private static function error(string $msg = '请求失败', int $code = -1): Json
+ {
+ return json([
+ 'code' => $code,
+ 'msg' => $msg,
+ 'data' => [],
+ ]);
+ }
+
+ /**
+ * 获取用户票列表
+ *
+ * GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
+ *
+ * @return Json
+ */
+ public function list(): Json
+ {
+ $userId = self::getUserId();
+ if (empty($userId)) {
+ return self::unauthorized();
+ }
+
+ try {
+ $tickets = WalletService::getUserTickets($userId);
+
+ return self::success([
+ 'tickets' => $tickets,
+ 'count' => count($tickets),
+ ]);
+ } catch (\Exception $e) {
+ return self::error('获取票列表失败: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * 获取票详情(含 QR payload)
+ *
+ * GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=detail&id=X
+ *
+ * @return Json
+ */
+ public function detail(): Json
+ {
+ $userId = self::getUserId();
+ if (empty($userId)) {
+ return self::unauthorized();
+ }
+
+ $ticketId = input('id', 0, 'intval');
+ if ($ticketId <= 0) {
+ return self::error('参数错误:票ID无效');
+ }
+
+ try {
+ $ticket = WalletService::getTicketDetail($ticketId, $userId);
+
+ if (empty($ticket)) {
+ return self::error('票不存在或无权访问', -404);
+ }
+
+ return self::success([
+ 'ticket' => $ticket,
+ ]);
+ } catch (\Exception $e) {
+ return self::error('获取票详情失败: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * 强制刷新 QR payload
+ *
+ * GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=refreshQr&id=X
+ *
+ * @return Json
+ */
+ public function refreshQr(): Json
+ {
+ $userId = self::getUserId();
+ if (empty($userId)) {
+ return self::unauthorized();
+ }
+
+ $ticketId = input('id', 0, 'intval');
+ if ($ticketId <= 0) {
+ return self::error('参数错误:票ID无效');
+ }
+
+ try {
+ $ticket = WalletService::refreshQrPayload($ticketId, $userId);
+
+ if (empty($ticket)) {
+ return self::error('票不存在或无权访问', -404);
+ }
+
+ return self::success([
+ 'ticket' => $ticket,
+ ]);
+ } catch (\Exception $e) {
+ return self::error('刷新QR失败: ' . $e->getMessage());
+ }
+ }
+}
diff --git a/shopxo/app/plugins/vr_ticket/install.sql b/shopxo/app/plugins/vr_ticket/install.sql
index 4fc6db1..72b8165 100644
--- a/shopxo/app/plugins/vr_ticket/install.sql
+++ b/shopxo/app/plugins/vr_ticket/install.sql
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}vr_tickets` (
`verify_time` INT UNSIGNED DEFAULT 0 COMMENT '核销时间',
`verifier_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '核销员ID',
`issued_at` INT UNSIGNED DEFAULT 0 COMMENT '票发放时间',
+ `qr_issued_at` INT UNSIGNED DEFAULT 0 COMMENT 'QR发放时间戳',
`created_at` INT UNSIGNED DEFAULT 0 COMMENT '创建时间',
`updated_at` INT UNSIGNED DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
diff --git a/shopxo/app/plugins/vr_ticket/service/WalletService.php b/shopxo/app/plugins/vr_ticket/service/WalletService.php
new file mode 100644
index 0000000..7d7709e
--- /dev/null
+++ b/shopxo/app/plugins/vr_ticket/service/WalletService.php
@@ -0,0 +1,269 @@
+ 此值时返回缓存
+ */
+ const QR_REFRESH_THRESHOLD = 900; // 15分钟
+
+ /**
+ * 获取用户所有票
+ *
+ * @param int $userId 用户ID
+ * @return array
+ */
+ public static function getUserTickets(int $userId): array
+ {
+ // 查询该用户的所有票(关联订单)
+ $tickets = \think\facade\Db::name('vr_tickets')
+ ->alias('t')
+ ->join('order o', 't.order_id = o.id', 'LEFT')
+ ->where('o.user_id', $userId)
+ ->where('o.pay_status', 1) // 已支付
+ ->where('o.status', '<>', 3) // 未删除
+ ->field('t.*')
+ ->order('t.issued_at', 'desc')
+ ->select()
+ ->toArray();
+
+ if (empty($tickets)) {
+ return [];
+ }
+
+ // 批量获取商品信息
+ $goodsIds = array_filter(array_column($tickets, 'goods_id'));
+ $goodsMap = [];
+ if (!empty($goodsIds)) {
+ $goodsMap = \think\facade\Db::name('Goods')
+ ->where('id', 'in', $goodsIds)
+ ->column('title', 'id');
+ }
+
+ // 格式化数据
+ $result = [];
+ foreach ($tickets as $ticket) {
+ // 生成短码
+ $shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']);
+
+ // 解析座位信息(从 seat_info 中提取场次/场馆)
+ $seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
+
+ $result[] = [
+ 'id' => $ticket['id'],
+ 'goods_id' => $ticket['goods_id'],
+ 'goods_title' => $goodsMap[$ticket['goods_id']] ?? '已下架商品',
+ 'seat_info' => $ticket['seat_info'] ?? '',
+ 'session_time' => $seatInfo['session'] ?? '',
+ 'venue_name' => $seatInfo['venue'] ?? '',
+ 'real_name' => $ticket['real_name'] ?? '',
+ 'phone' => self::maskPhone($ticket['phone'] ?? ''),
+ 'verify_status' => $ticket['verify_status'],
+ 'issued_at' => $ticket['issued_at'],
+ 'short_code' => $shortCode,
+ // 是否需要刷新 QR
+ 'qr_need_refresh' => self::qrNeedsRefresh($ticket['qr_issued_at'] ?? 0),
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * 获取票详情
+ *
+ * @param int $ticketId 票ID
+ * @param int $userId 用户ID(用于权限校验)
+ * @return array|null
+ */
+ public static function getTicketDetail(int $ticketId, int $userId): ?array
+ {
+ $ticket = \think\facade\Db::name('vr_tickets')
+ ->alias('t')
+ ->join('order o', 't.order_id = o.id', 'LEFT')
+ ->where('t.id', $ticketId)
+ ->where('o.user_id', $userId)
+ ->field('t.*')
+ ->find();
+
+ if (empty($ticket)) {
+ return null;
+ }
+
+ // 获取商品信息
+ $goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']);
+ $seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
+
+ // 生成短码
+ $shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']);
+
+ // 生成 QR payload
+ $qrData = self::getQrPayload($ticket);
+
+ return [
+ 'id' => $ticket['id'],
+ 'goods_id' => $ticket['goods_id'],
+ 'goods_title' => $goods['title'] ?? '已下架商品',
+ 'goods_image' => $goods['images'] ?? '',
+ 'seat_info' => $ticket['seat_info'] ?? '',
+ 'session_time' => $seatInfo['session'] ?? '',
+ 'venue_name' => $seatInfo['venue'] ?? '',
+ 'real_name' => $ticket['real_name'] ?? '',
+ 'phone' => self::maskPhone($ticket['phone'] ?? ''),
+ 'verify_status' => $ticket['verify_status'],
+ 'verify_time' => $ticket['verify_time'] ?? 0,
+ 'issued_at' => $ticket['issued_at'],
+ 'short_code' => $shortCode,
+ 'qr_payload' => $qrData['payload'],
+ 'qr_expires_at' => $qrData['expires_at'],
+ 'qr_expires_in' => $qrData['expires_in'],
+ ];
+ }
+
+ /**
+ * 生成 QR payload
+ *
+ * 缓存策略:
+ * - QR 有效期 30 分钟
+ * - 剩余有效期 > 15 分钟:返回缓存
+ * - 剩余有效期 ≤ 15 分钟:刷新
+ *
+ * @param array $ticket 票数据
+ * @return array ['payload' => string, 'expires_at' => int, 'expires_in' => int]
+ */
+ public static function getQrPayload(array $ticket): array
+ {
+ $now = time();
+ $issuedAt = $ticket['qr_issued_at'] ?? 0;
+ $expiresAt = $issuedAt + self::QR_TTL;
+
+ // 检查是否需要刷新
+ $needsRefresh = ($issuedAt == 0) || (($expiresAt - $now) <= self::QR_REFRESH_THRESHOLD);
+
+ if ($needsRefresh) {
+ // 生成新 QR
+ $issuedAt = $now;
+ $expiresAt = $now + self::QR_TTL;
+
+ $payload = [
+ 'id' => $ticket['id'],
+ 'g' => $ticket['goods_id'],
+ 'iat' => $issuedAt,
+ 'exp' => $expiresAt,
+ ];
+
+ $encoded = self::signQrPayload($payload);
+
+ // 回写数据库(更新 qr_issued_at)
+ \think\facade\Db::name('vr_tickets')
+ ->where('id', $ticket['id'])
+ ->update(['qr_issued_at' => $issuedAt]);
+ } else {
+ // 返回缓存的 payload
+ // 重新构建 payload(从数据库读取 iat)
+ $payload = [
+ 'id' => $ticket['id'],
+ 'g' => $ticket['goods_id'],
+ 'iat' => $issuedAt,
+ 'exp' => $expiresAt,
+ ];
+ $encoded = self::signQrPayload($payload);
+ }
+
+ return [
+ 'payload' => $encoded,
+ 'expires_at' => $expiresAt,
+ 'expires_in' => max(0, $expiresAt - $now),
+ ];
+ }
+
+ /**
+ * 强制刷新 QR payload
+ *
+ * @param int $ticketId 票ID
+ * @param int $userId 用户ID
+ * @return array|null
+ */
+ public static function refreshQrPayload(int $ticketId, int $userId): ?array
+ {
+ // 先清零 qr_issued_at,强制刷新
+ \think\facade\Db::name('vr_tickets')
+ ->where('id', $ticketId)
+ ->update(['qr_issued_at' => 0]);
+
+ return self::getTicketDetail($ticketId, $userId);
+ }
+
+ /**
+ * 检查 QR 是否需要刷新
+ *
+ * @param int $qrIssuedAt QR 发放时间戳
+ * @return bool
+ */
+ public static function qrNeedsRefresh(int $qrIssuedAt): bool
+ {
+ if ($qrIssuedAt == 0) {
+ return true;
+ }
+
+ $now = time();
+ $expiresAt = $qrIssuedAt + self::QR_TTL;
+
+ return (($expiresAt - $now) <= self::QR_REFRESH_THRESHOLD);
+ }
+
+ /**
+ * 解析座位信息
+ *
+ * seat_info 格式:场次|场馆|演播室|分区|座位号
+ * 例如:2026-06-01 20:00|国家体育馆|主要展厅|A区|A1
+ *
+ * @param string $seatInfo
+ * @return array
+ */
+ private static function parseSeatInfo(string $seatInfo): array
+ {
+ $parts = explode('|', $seatInfo);
+
+ return [
+ 'session' => $parts[0] ?? '',
+ 'venue' => $parts[1] ?? '',
+ 'room' => $parts[2] ?? '',
+ 'section' => $parts[3] ?? '',
+ 'seat' => $parts[4] ?? '',
+ ];
+ }
+
+ /**
+ * 手机号脱敏
+ *
+ * @param string $phone
+ * @return string
+ */
+ private static function maskPhone(string $phone): string
+ {
+ if (empty($phone) || strlen($phone) < 7) {
+ return $phone;
+ }
+
+ return substr($phone, 0, 3) . '****' . substr($phone, -4);
+ }
+}
diff --git a/shopxo/app/plugins/vr_ticket/view/goods/ticket_card.html b/shopxo/app/plugins/vr_ticket/view/goods/ticket_card.html
new file mode 100644
index 0000000..067dc96
--- /dev/null
+++ b/shopxo/app/plugins/vr_ticket/view/goods/ticket_card.html
@@ -0,0 +1,677 @@
+
+
+
+
+
+
+
+
+
+
+
+ 📅
+ {{session_time}}
+
+
+ 📍
+ {{venue_name}}
+
+
+ 💺
+ {{seat_info}}
+
+
+ 👤
+ {{real_name}} {{phone}}
+
+
+
+
+
+
+
+
+
+
diff --git a/shopxo/app/plugins/vr_ticket/view/goods/ticket_wallet.html b/shopxo/app/plugins/vr_ticket/view/goods/ticket_wallet.html
new file mode 100644
index 0000000..3ff9e9d
--- /dev/null
+++ b/shopxo/app/plugins/vr_ticket/view/goods/ticket_wallet.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+