2026-04-23 05:44:48 +00:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* VR票务插件 - 票夹服务(C端)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 核心功能:
|
|
|
|
|
|
* 1. 获取用户票列表
|
|
|
|
|
|
* 2. 获取票详情
|
|
|
|
|
|
* 3. 生成/缓存 QR payload
|
|
|
|
|
|
*
|
|
|
|
|
|
* @package vr_ticket\service
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
namespace app\plugins\vr_ticket\service;
|
|
|
|
|
|
|
|
|
|
|
|
class WalletService extends BaseService
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* QR 有效期(秒)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const QR_TTL = 1800; // 30分钟
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取用户所有票
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $userId 用户ID
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getUserTickets(int $userId): array
|
|
|
|
|
|
{
|
2026-04-24 14:09:08 +00:00
|
|
|
|
// 直接查询 tickets 表(user_id 已存在)
|
2026-04-23 05:44:48 +00:00
|
|
|
|
$tickets = \think\facade\Db::name('vr_tickets')
|
2026-04-24 14:09:08 +00:00
|
|
|
|
->where('user_id', $userId)
|
|
|
|
|
|
->order('issued_at', 'desc')
|
2026-04-23 05:44:48 +00:00
|
|
|
|
->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']);
|
|
|
|
|
|
|
2026-04-24 14:09:08 +00:00
|
|
|
|
// 优先从 seat_info 解析(5维 pipe 格式),兜底从 goods_snapshot 解析
|
2026-04-23 05:44:48 +00:00
|
|
|
|
$seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
|
2026-04-24 14:09:08 +00:00
|
|
|
|
$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'] ?? '';
|
2026-04-23 05:44:48 +00:00
|
|
|
|
|
|
|
|
|
|
$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,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取票详情
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $ticketId 票ID
|
|
|
|
|
|
* @param int $userId 用户ID(用于权限校验)
|
|
|
|
|
|
* @return array|null
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getTicketDetail(int $ticketId, int $userId): ?array
|
|
|
|
|
|
{
|
2026-04-24 14:09:08 +00:00
|
|
|
|
// 直接查询 tickets 表(包含 user_id)
|
2026-04-23 05:44:48 +00:00
|
|
|
|
$ticket = \think\facade\Db::name('vr_tickets')
|
2026-04-24 14:09:08 +00:00
|
|
|
|
->where('id', $ticketId)
|
|
|
|
|
|
->where('user_id', $userId)
|
2026-04-23 05:44:48 +00:00
|
|
|
|
->find();
|
|
|
|
|
|
|
|
|
|
|
|
if (empty($ticket)) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取商品信息
|
|
|
|
|
|
$goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']);
|
|
|
|
|
|
$seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
|
2026-04-24 14:09:08 +00:00
|
|
|
|
$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'] ?? '';
|
2026-04-23 05:44:48 +00:00
|
|
|
|
|
|
|
|
|
|
// 生成短码
|
|
|
|
|
|
$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
|
|
|
|
|
|
*
|
2026-04-23 06:37:10 +00:00
|
|
|
|
* QR 有效期 30 分钟,动态生成,不存储
|
2026-04-23 05:44:48 +00:00
|
|
|
|
*
|
|
|
|
|
|
* @param array $ticket 票数据
|
|
|
|
|
|
* @return array ['payload' => string, 'expires_at' => int, 'expires_in' => int]
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getQrPayload(array $ticket): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$now = time();
|
2026-04-23 06:37:10 +00:00
|
|
|
|
$expiresAt = $now + self::QR_TTL;
|
2026-04-23 05:44:48 +00:00
|
|
|
|
|
2026-04-23 06:37:10 +00:00
|
|
|
|
$payload = [
|
|
|
|
|
|
'id' => $ticket['id'],
|
|
|
|
|
|
'g' => $ticket['goods_id'],
|
|
|
|
|
|
'iat' => $now,
|
|
|
|
|
|
'exp' => $expiresAt,
|
|
|
|
|
|
];
|
2026-04-23 05:44:48 +00:00
|
|
|
|
|
2026-04-23 06:37:10 +00:00
|
|
|
|
$encoded = self::signQrPayload($payload);
|
2026-04-23 05:44:48 +00:00
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'payload' => $encoded,
|
|
|
|
|
|
'expires_at' => $expiresAt,
|
2026-04-23 06:37:10 +00:00
|
|
|
|
'expires_in' => self::QR_TTL,
|
2026-04-23 05:44:48 +00:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 强制刷新 QR payload
|
2026-04-23 06:37:10 +00:00
|
|
|
|
* 重新生成一个新的 QR payload(有效期重新计算)
|
2026-04-23 05:44:48 +00:00
|
|
|
|
*
|
|
|
|
|
|
* @param int $ticketId 票ID
|
|
|
|
|
|
* @param int $userId 用户ID
|
|
|
|
|
|
* @return array|null
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function refreshQrPayload(int $ticketId, int $userId): ?array
|
|
|
|
|
|
{
|
2026-04-23 06:37:10 +00:00
|
|
|
|
// 直接调用 getTicketDetail,它会重新生成 QR
|
2026-04-23 05:44:48 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|