vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/WalletService.php

517 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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 游标ID0 表示首次加载
* @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),
];
}
}