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 .= '
' . + '
' . + '
电子票
' . + '
' . $status['text'] . '
' . + '
' . + '
' . + '
💺' . 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 @@ + + + + + + + + + +
+
+
+
电子票
+ +
+
+ +
+
+
+ + 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 @@ + + + + + + +
+
+
我的电子票
+
0 张票
+
+ + +
+ +
+
+ + + + + + +