where('user_id', $userId) ->order('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 解析(5维 pipe 格式),兜底从 goods_snapshot 解析 $seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? ''); $snapshot = json_decode($ticket['goods_snapshot'] ?? '{}', true); $snapshotKeys = array_filter(['session' => $snapshot['session'] ?? '', 'venue' => $snapshot['venue'] ?? '', 'studio' => $snapshot['studio'] ?? '', 'section' => $snapshot['section'] ?? '', 'seat' => $snapshot['seat'] ?? '']); if (empty($seatInfo['session']) && !empty($snapshotKeys)) { $seatInfo = array_merge($seatInfo, $snapshotKeys); } // goods_snapshot 里没有 session/venue 时,从商品表补全 if (empty($seatInfo['session']) || empty($seatInfo['venue'])) { $goodsTitle = $goodsMap[$ticket['goods_id']] ?? '已下架商品'; $goods = \think\facade\Db::name('Goods')->where('id', $ticket['goods_id'])->find(); $vrConfig = json_decode($goods['vr_goods_config'] ?? '', true); if (!empty($vrConfig[0]['template_id'])) { $template = \think\facade\Db::name('vr_seat_templates') ->where('id', $vrConfig[0]['template_id'])->find(); $seatMap = json_decode($template['seat_map'] ?? '{}', true); if (empty($seatInfo['venue'])) $seatInfo['venue'] = $template['name'] ?? ''; if (empty($seatInfo['session'])) { $sessions = $vrConfig[0]['sessions'] ?? []; $seatInfo['session'] = !empty($sessions[0]['start']) && !empty($sessions[0]['end']) ? ($sessions[0]['start'] . '-' . $sessions[0]['end']) : ''; } } } if (empty($seatInfo['venue'])) $seatInfo['venue'] = $snapshot['venue'] ?? ''; if (empty($seatInfo['session'])) $seatInfo['session'] = $snapshot['session'] ?? ''; $result[] = [ 'id' => $ticket['id'], 'goods_id' => $ticket['goods_id'], 'goods_title' => $goodsMap[$ticket['goods_id']] ?? '已下架商品', 'seat_info' => $ticket['seat_info'] ?? '', // 完整 5 维(保留) 'seat_number' => self::parseSeatNumber($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, 'seat_number' => self::parseSeatNumber($ticket['seat_info'] ?? ''), ]; } return $result; } /** * 提取座位号(seat_info 最后一个 | 分段) * @param string $seatInfo 完整 5 维坐席信息 * @return string 仅座位号 */ public static function parseSeatNumber(string $seatInfo): string { if (empty($seatInfo)) return ''; $parts = explode('|', $seatInfo); return end($parts) ?: ''; } /** * 获取票详情 * * @param int $ticketId 票ID * @param int $userId 用户ID(用于权限校验) * @return array|null */ public static function getTicketDetail(int $ticketId, int $userId): ?array { // 直接查询 tickets 表(包含 user_id) $ticket = \think\facade\Db::name('vr_tickets') ->where('id', $ticketId) ->where('user_id', $userId) ->find(); if (empty($ticket)) { return null; } // 获取商品信息 $goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']); $seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? ''); $snapshot = json_decode($ticket['goods_snapshot'] ?? '{}', true); // 兜底补全:从 snapshot 补 seat_info 缺失字段 if (empty($seatInfo['venue']) || empty($seatInfo['session'])) { $vrConfig = json_decode($goods['vr_goods_config'] ?? '', true); if (!empty($vrConfig[0]['template_id'])) { $template = \think\facade\Db::name('vr_seat_templates') ->where('id', $vrConfig[0]['template_id'])->find(); if (empty($seatInfo['venue'])) $seatInfo['venue'] = $template['name'] ?? ''; if (empty($seatInfo['session'])) { $sessions = $vrConfig[0]['sessions'] ?? []; $seatInfo['session'] = !empty($sessions[0]['start']) && !empty($sessions[0]['end']) ? ($sessions[0]['start'] . '-' . $sessions[0]['end']) : ''; } } } if (empty($seatInfo['venue'])) $seatInfo['venue'] = $snapshot['venue'] ?? ''; if (empty($seatInfo['session'])) $seatInfo['session'] = $snapshot['session'] ?? ''; // 生成短码 $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, 'seat_number' => self::parseSeatNumber($ticket['seat_info'] ?? ''), 'qr_payload' => $qrData['payload'], 'qr_expires_at' => $qrData['expires_at'], 'qr_expires_in' => $qrData['expires_in'], ]; } /** * 生成 QR payload * * QR 有效期 30 分钟,动态生成,不存储 * * @param array $ticket 票数据 * @return array ['payload' => string, 'expires_at' => int, 'expires_in' => int] */ public static function getQrPayload(array $ticket): array { $now = time(); $expiresAt = $now + self::QR_TTL; $payload = [ 'id' => $ticket['id'], 'g' => $ticket['goods_id'], 'iat' => $now, 'exp' => $expiresAt, ]; $encoded = self::signQrPayload($payload); return [ 'payload' => $encoded, 'expires_at' => $expiresAt, 'expires_in' => self::QR_TTL, ]; } /** * 强制刷新 QR payload * 重新生成一个新的 QR payload(有效期重新计算) * * @param int $ticketId 票ID * @param int $userId 用户ID * @return array|null */ public static function refreshQrPayload(int $ticketId, int $userId): ?array { // 直接调用 getTicketDetail,它会重新生成 QR return self::getTicketDetail($ticketId, $userId); } /** * 解析座位信息 * * 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); } }