517 lines
19 KiB
PHP
517 lines
19 KiB
PHP
<?php
|
||
/**
|
||
* VR票务插件 - 票夹服务(C端)
|
||
*
|
||
* 核心功能:
|
||
* 1. 获取用户票列表
|
||
* 2. 获取票详情
|
||
* 3. 生成/缓存 QR payload
|
||
*
|
||
* @package vr_ticket\service
|
||
*/
|
||
|
||
namespace app\plugins\vr_ticket\service;
|
||
|
||
require_once __DIR__ . '/BaseService.php';
|
||
|
||
class WalletService extends BaseService
|
||
{
|
||
/**
|
||
* QR 有效期(秒)
|
||
*/
|
||
const QR_TTL = 1800; // 30分钟
|
||
|
||
/**
|
||
* 获取用户所有票
|
||
*
|
||
* @param int $userId 用户ID
|
||
* @return array
|
||
*/
|
||
public static function getUserTickets(int $userId): array
|
||
{
|
||
// 直接查询 tickets 表(user_id 已存在)
|
||
$tickets = \think\facade\Db::name('vr_tickets')
|
||
->where('user_id', $userId)
|
||
->order('issued_at', 'desc')
|
||
->select()
|
||
->toArray();
|
||
|
||
if (empty($tickets)) {
|
||
return [];
|
||
}
|
||
|
||
// 批量获取商品信息
|
||
$goodsIds = array_filter(array_column($tickets, 'goods_id'));
|
||
$goodsMap = [];
|
||
$goodsImageMap = [];
|
||
if (!empty($goodsIds)) {
|
||
$goodsList = \think\facade\Db::name('Goods')
|
||
->where('id', 'in', $goodsIds)
|
||
->field('id, title, images')
|
||
->select()
|
||
->toArray();
|
||
foreach ($goodsList as $goods) {
|
||
$goodsMap[$goods['id']] = $goods['title'] ?? '';
|
||
$goodsImageMap[$goods['id']] = $goods['images'] ?? '';
|
||
}
|
||
}
|
||
|
||
// 格式化数据
|
||
$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']] ?? '已下架商品',
|
||
'goods_image' => $goodsImageMap[$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,
|
||
];
|
||
}
|
||
|
||
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'],
|
||
'code' => $ticket['ticket_code'],
|
||
'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);
|
||
}
|
||
|
||
/**
|
||
* 格式化票据列表数据(统一格式化逻辑,供 getUserTicketsPaginated 复用)
|
||
*
|
||
* @param array $tickets 原始票据数组
|
||
* @return array 格式化后的票据列表
|
||
*/
|
||
private static function formatTickets(array $tickets): array
|
||
{
|
||
if (empty($tickets)) {
|
||
return [];
|
||
}
|
||
|
||
// 批量获取商品信息(减少 N+1 查询)
|
||
$goodsIds = array_filter(array_column($tickets, 'goods_id'));
|
||
$goodsMap = [];
|
||
$goodsImageMap = [];
|
||
if (!empty($goodsIds)) {
|
||
$goodsList = \think\facade\Db::name('Goods')
|
||
->where('id', 'in', $goodsIds)
|
||
->field('id, title, images')
|
||
->select()
|
||
->toArray();
|
||
foreach ($goodsList as $goods) {
|
||
$goodsMap[$goods['id']] = $goods['title'] ?? '';
|
||
$goodsImageMap[$goods['id']] = $goods['images'] ?? '';
|
||
}
|
||
}
|
||
|
||
$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();
|
||
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'],
|
||
'order_id' => $ticket['order_id'], // 新增:便于前端按订单筛选
|
||
'goods_id' => $ticket['goods_id'],
|
||
'goods_title' => $goodsMap[$ticket['goods_id']] ?? '已下架商品',
|
||
'goods_image' => $goodsImageMap[$ticket['goods_id']] ?? '',
|
||
'seat_info' => $ticket['seat_info'] ?? '',
|
||
'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,
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 获取用户票列表(支持分页 + 订单/商品筛选)
|
||
*
|
||
* @param int $userId 用户ID
|
||
* @param int|null $orderId 单个订单ID
|
||
* @param array|null $orderIds 多个订单ID数组
|
||
* @param int|null $goodsId 商品ID
|
||
* @param int|null $status 核销状态 (0=未核销, 1=已核销, 2=已退款)
|
||
* @param int $page 页码
|
||
* @param int $pageSize 每页数量
|
||
* @return array ['list' => [], 'total' => int, 'page' => int, 'page_size' => int, 'pages' => int]
|
||
*/
|
||
public static function getUserTicketsPaginated(
|
||
int $userId,
|
||
?int $orderId = null,
|
||
?array $orderIds = null,
|
||
?int $goodsId = null,
|
||
?int $status = null,
|
||
int $page = 1,
|
||
int $pageSize = 20
|
||
): array {
|
||
$where = [
|
||
['user_id', '=', $userId]
|
||
];
|
||
|
||
// 按单个订单ID筛选
|
||
if ($orderId > 0) {
|
||
$where[] = ['order_id', '=', $orderId];
|
||
}
|
||
|
||
// 按多个订单ID批量筛选
|
||
if (!empty($orderIds) && is_array($orderIds)) {
|
||
$where[] = ['order_id', 'in', $orderIds];
|
||
}
|
||
|
||
// 按商品ID筛选
|
||
if ($goodsId > 0) {
|
||
$where[] = ['goods_id', '=', $goodsId];
|
||
}
|
||
|
||
// 按核销状态筛选
|
||
if ($status !== null) {
|
||
$where[] = ['verify_status', '=', $status];
|
||
}
|
||
|
||
// 计算总数
|
||
$total = \think\facade\Db::name('vr_tickets')->where($where)->count();
|
||
$pages = $pageSize > 0 ? ceil($total / $pageSize) : 1;
|
||
|
||
// 分页查询
|
||
$offset = ($page - 1) * $pageSize;
|
||
$tickets = \think\facade\Db::name('vr_tickets')
|
||
->where($where)
|
||
->order('issued_at', 'desc')
|
||
->limit($offset, $pageSize)
|
||
->select()
|
||
->toArray();
|
||
|
||
// 格式化返回数据
|
||
$formatted = self::formatTickets($tickets);
|
||
|
||
return [
|
||
'list' => $formatted,
|
||
'total' => $total,
|
||
'page' => $page,
|
||
'page_size' => $pageSize,
|
||
'pages' => $pages,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 获取用户票列表(瀑布流模式)
|
||
*
|
||
* @param int $userId 用户ID
|
||
* @param int $lastId 游标ID,0 表示首次加载
|
||
* @param string $orderBy 排序方向:'desc' 降序(历史)/ 'asc' 升序(新数据)
|
||
* @param int $limit 每次拉取数量
|
||
* @param int|null $orderId 单个订单ID
|
||
* @param array|null $orderIds 多个订单ID数组
|
||
* @param int|null $goodsId 商品ID
|
||
* @param int|null $status 核销状态
|
||
* @return array ['list' => [], 'has_more' => bool, 'last_id' => int, 'count' => int]
|
||
*/
|
||
public static function getUserTicketsWaterfall(
|
||
int $userId,
|
||
int $lastId = 0,
|
||
string $orderBy = 'desc',
|
||
int $limit = 20,
|
||
?int $orderId = null,
|
||
?array $orderIds = null,
|
||
?int $goodsId = null,
|
||
?int $status = null
|
||
): array {
|
||
$where = [
|
||
['user_id', '=', $userId]
|
||
];
|
||
|
||
// 按单个订单ID筛选
|
||
if ($orderId > 0) {
|
||
$where[] = ['order_id', '=', $orderId];
|
||
}
|
||
|
||
// 按多个订单ID批量筛选
|
||
if (!empty($orderIds) && is_array($orderIds)) {
|
||
$where[] = ['order_id', 'in', $orderIds];
|
||
}
|
||
|
||
// 按商品ID筛选
|
||
if ($goodsId > 0) {
|
||
$where[] = ['goods_id', '=', $goodsId];
|
||
}
|
||
|
||
// 按核销状态筛选
|
||
if ($status !== null) {
|
||
$where[] = ['verify_status', '=', $status];
|
||
}
|
||
|
||
// 游标筛选
|
||
if ($lastId > 0) {
|
||
if ($orderBy === 'asc') {
|
||
// 升序:获取 ID > last_id 的数据(新数据)
|
||
$where[] = ['id', '>', $lastId];
|
||
} else {
|
||
// 降序:获取 ID < last_id 的数据(历史数据)
|
||
$where[] = ['id', '<', $lastId];
|
||
}
|
||
}
|
||
|
||
// 查询数据(limit + 1 用于判断 has_more)
|
||
$tickets = \think\facade\Db::name('vr_tickets')
|
||
->where($where)
|
||
->order('id', $orderBy === 'asc' ? 'asc' : 'desc')
|
||
->limit($limit + 1)
|
||
->select()
|
||
->toArray();
|
||
|
||
// 判断是否还有更多数据
|
||
$hasMore = count($tickets) > $limit;
|
||
if ($hasMore) {
|
||
array_pop($tickets); // 移除多查询的一条
|
||
}
|
||
|
||
// 获取本次返回的最后一条 ID
|
||
$newLastId = !empty($tickets) ? end($tickets)['id'] : $lastId;
|
||
|
||
// 格式化返回数据
|
||
$formatted = self::formatTickets($tickets);
|
||
|
||
return [
|
||
'list' => $formatted,
|
||
'has_more' => $hasMore,
|
||
'last_id' => $newLastId,
|
||
'count' => count($formatted),
|
||
];
|
||
}
|
||
}
|