feat(phase4.3): 完成 C端票夹

新增文件:
- api/Ticket.php: C端票夹API控制器(list/detail/refreshQr)
- service/WalletService.php: 票夹核心服务
- view/goods/ticket_card.html: 票卡片共享组件
- view/goods/ticket_wallet.html: 票夹列表页

修改文件:
- Hook.php: 注册订单详情页注入钩子(plugins_service_order_detail_page_info)
- install.sql: 添加 qr_issued_at 字段

数据库变更:
- ALTER TABLE vr_tickets ADD qr_issued_at INT UNSIGNED
feat/phase-b-verification
Council 2026-04-23 13:44:48 +08:00
parent 840157ca9d
commit 8b15283376
8 changed files with 1603 additions and 1 deletions

View File

@ -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/CSSqrcode.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: 联调测试

View File

@ -1,7 +1,8 @@
# Phase 4 规划:发票 · 核销 · 票夹
> 规划日期2026-04-22
> 状态:**规划完成,待启动调研**
> 最后更新2026-04-23算法变更Feistel-8 → HMAC-XORBUG修复
> 状态:**Phase 4.1/4.2 已完成B端核销进行中**
---

View File

@ -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 .= '<div class="vr-ticket-card" data-ticket-id="' . $ticket['id'] . '">' .
'<div class="vr-ticket-card-header">' .
'<div class="vr-ticket-goods-title">电子票</div>' .
'<div class="vr-ticket-status ' . $status['class'] . '">' . $status['text'] . '</div>' .
'</div>' .
'<div class="vr-ticket-info">' .
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">💺</span><span>' . htmlspecialchars($ticket['seat_info'] ?? '') . '</span></div>' .
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">👤</span><span>' . htmlspecialchars($ticket['real_name'] ?? '') . '</span></div>' .
'</div>' .
'<div class="vr-ticket-footer">' .
'<div class="vr-ticket-short-code">短码: ' . htmlspecialchars($shortCode) . '</div>' .
'<a href="javascript:;" class="vr-ticket-view-btn" onclick="VrTicketWallet.viewTicket(' . $ticket['id'] . ')">查看票码 →</a>' .
'</div>' .
'</div>';
}
$style = '<style>
.vr-ticket-card { background: #fff; border-radius: 12px; padding: 16px; margin: 12px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; }
.vr-ticket-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.vr-ticket-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.vr-ticket-goods-title { font-size: 16px; font-weight: 600; color: #333; }
.vr-ticket-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.vr-ticket-status.unverified { background: #e6f7ff; color: #1890ff; }
.vr-ticket-status.verified { background: #f6ffed; color: #52c41a; }
.vr-ticket-status.refunded { background: #fff1f0; color: #ff4d4f; }
.vr-ticket-info { font-size: 13px; color: #666; line-height: 1.6; }
.vr-ticket-info-row { display: flex; align-items: center; margin-bottom: 4px; }
.vr-ticket-info-icon { width: 16px; color: #999; margin-right: 6px; }
.vr-ticket-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
.vr-ticket-short-code { font-size: 14px; font-family: "Courier New", monospace; color: #333; font-weight: 600; letter-spacing: 1px; }
.vr-ticket-view-btn { font-size: 13px; color: #1890ff; text-decoration: none; }
.vr-ticket-view-btn:hover { text-decoration: underline; }
</style>';
$ticketHtml = '<div class="vr-order-ticket-section">' .
'<div style="font-size:16px;font-weight:600;margin-bottom:12px;">📋 我的电子票</div>' .
$ticketCardsHtml .
'</div>';
$params['page_data']['ticket_section'] = $ticketHtml;
$params['page_data']['ticket_css'] = $style;
// JS
$js = '<script>
(function() {
var apiBase = "' . $hostUrl . '?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=";
var token = "' . htmlspecialchars($token) . '";
window.VrTicketWallet = {
viewTicket: function(ticketId) {
var modal = document.getElementById("vrTicketModal") || createModal();
modal.classList.add("active");
var body = document.getElementById("vrTicketModalBody");
body.innerHTML = \'<div style="text-align:center;padding:40px;">加载中...</div>\';
$.ajax({
url: apiBase + "detail&id=" + ticketId,
headers: token ? {"X-Token": token} : {},
success: function(res) {
if (res.code === 0 && res.data.ticket) {
var t = res.data.ticket;
var statusMap = {0:{text:"未核销",class:"unverified"},1:{text:"已核销",class:"verified"},2:{text:"已退款",class:"refunded"}};
var status = statusMap[t.verify_status] || statusMap[0];
body.innerHTML = \'<div style="text-align:center;padding:20px;background:#fafafa;border-radius:12px;"><div id="vrQrcodeBox"></div></div>\' +
\'<div style="text-align:center;margin:16px 0;padding:12px;background:#f5f5f5;border-radius:8px;">\' +
\'<div style="font-size:12px;color:#999;margin-bottom:4px;">短码(人工核销)</div>\' +
\'<div style="font-size:20px;font-family:monospace;font-weight:700;letter-spacing:2px;">\' + t.short_code + \'</div></div>\' +
\'<div style="text-align:center;"><span class="vr-ticket-status \' + status.class + \'">\' + status.text + \'</span></div>\';
if (t.qr_payload) {
$("#vrQrcodeBox").qrcode({text: atob(t.qr_payload), width: 180, height: 180});
}
}
}
});
},
closeModal: function() {
var modal = document.getElementById("vrTicketModal");
if (modal) modal.classList.remove("active");
}
};
function createModal() {
var html = \'<div id="vrTicketModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:none;align-items:center;justify-content:center;">\' +
\'<div style="background:#fff;border-radius:16px;width:90%;max-width:400px;padding:24px;"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">\' +
\'<div style="font-size:18px;font-weight:600;">电子票</div><button onclick="VrTicketWallet.closeModal()" style="width:28px;height:28px;border-radius:50%;background:#f0f0f0;border:none;cursor:pointer;">×</button></div>\' +
\'<div id="vrTicketModalBody"></div></div></div>\';
document.body.insertAdjacentHTML("beforeend", html);
var modal = document.getElementById("vrTicketModal");
modal.addEventListener("click", function(e) { if (e.target === modal) VrTicketWallet.closeModal(); });
return modal;
}
})();
</script>';
$params['page_data']['ticket_js'] = $js;
}
}
?>

View File

@ -0,0 +1,212 @@
<?php
/**
* VR票务插件 - C端票夹API控制器
*
* 路由机制PluginsService::PluginsApiCall:
* URL: ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
* pluginsname=vr_ticket, pluginscontrol=ticket, pluginsaction=list
* class = \app\plugins\vr_ticket\api\Ticket (ucfirst('ticket') = 'Ticket')
* method = ucfirst('list') = 'list'
* app/plugins/vr_ticket/api/Ticket.php::list()
*
* @package vr_ticket\api
*/
namespace app\plugins\vr_ticket\api;
use app\plugins\vr_ticket\service\WalletService;
/**
* C端票夹 API
*/
class Ticket
{
/**
* 获取当前登录用户ID
*
* ShopXO 使用 X-Token Authorization
* @return int|null
*/
private static function getUserId()
{
// 方式1从 header 获取(推荐)
$token = request()->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;
}
/**
* 解析 TokenJWT格式
*
* @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());
}
}
}

View File

@ -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`),

View File

@ -0,0 +1,269 @@
<?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分钟
/**
* QR 刷新阈值(秒)
* 剩余有效期 > 此值时返回缓存
*/
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);
}
}

View File

@ -0,0 +1,677 @@
<!-- VR票务 - 票卡片共享组件 -->
<!-- 被 ticket_wallet.html 和订单详情页 include -->
<style>
/* 票卡片样式 */
.vr-ticket-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
cursor: pointer;
transition: all 0.2s ease;
}
.vr-ticket-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
transform: translateY(-2px);
}
.vr-ticket-card.verified {
opacity: 0.7;
background: #f5f5f5;
}
.vr-ticket-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.vr-ticket-goods-title {
font-size: 16px;
font-weight: 600;
color: #333;
flex: 1;
margin-right: 12px;
}
.vr-ticket-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.vr-ticket-status.unverified {
background: #e6f7ff;
color: #1890ff;
}
.vr-ticket-status.verified {
background: #f6ffed;
color: #52c41a;
}
.vr-ticket-status.refunded {
background: #fff1f0;
color: #ff4d4f;
}
.vr-ticket-info {
font-size: 13px;
color: #666;
line-height: 1.6;
}
.vr-ticket-info-row {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.vr-ticket-info-icon {
width: 16px;
color: #999;
margin-right: 6px;
}
.vr-ticket-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.vr-ticket-short-code {
font-size: 14px;
font-family: 'Courier New', monospace;
color: #333;
font-weight: 600;
letter-spacing: 1px;
}
.vr-ticket-view-btn {
font-size: 13px;
color: #1890ff;
text-decoration: none;
}
.vr-ticket-view-btn:hover {
text-decoration: underline;
}
/* 票详情弹窗 */
.vr-ticket-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
}
.vr-ticket-modal.active {
display: flex;
}
.vr-ticket-modal-content {
background: #fff;
border-radius: 16px;
width: 90%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
}
.vr-ticket-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.vr-ticket-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.vr-ticket-modal-close {
width: 28px;
height: 28px;
border-radius: 50%;
background: #f0f0f0;
border: none;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.vr-ticket-qr-section {
text-align: center;
padding: 20px;
background: #fafafa;
border-radius: 12px;
margin-bottom: 16px;
}
.vr-ticket-qr-wrapper {
display: inline-block;
padding: 12px;
background: #fff;
border-radius: 8px;
border: 1px solid #eee;
}
.vr-ticket-qr-expire {
font-size: 12px;
color: #999;
margin-top: 8px;
}
.vr-ticket-short-code-display {
text-align: center;
margin: 16px 0;
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
}
.vr-ticket-short-code-label {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.vr-ticket-short-code-value {
font-size: 20px;
font-family: 'Courier New', monospace;
font-weight: 700;
color: #333;
letter-spacing: 2px;
}
.vr-ticket-detail-row {
display: flex;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.vr-ticket-detail-label {
width: 80px;
font-size: 13px;
color: #999;
}
.vr-ticket-detail-value {
flex: 1;
font-size: 13px;
color: #333;
}
.vr-ticket-verified-badge {
display: inline-block;
padding: 4px 12px;
background: #52c41a;
color: #fff;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
}
.vr-ticket-refresh-btn {
display: block;
width: 100%;
padding: 12px;
background: #1890ff;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
margin-top: 12px;
}
.vr-ticket-refresh-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
<!-- 票卡片模板 -->
<template id="vr-ticket-card-tpl">
<div class="vr-ticket-card" data-ticket-id="{{id}}">
<div class="vr-ticket-card-header">
<div class="vr-ticket-goods-title">{{goods_title}}</div>
<div class="vr-ticket-status {{status_class}}">{{status_text}}</div>
</div>
<div class="vr-ticket-info">
<div class="vr-ticket-info-row">
<span class="vr-ticket-info-icon">📅</span>
<span>{{session_time}}</span>
</div>
<div class="vr-ticket-info-row">
<span class="vr-ticket-info-icon">📍</span>
<span>{{venue_name}}</span>
</div>
<div class="vr-ticket-info-row">
<span class="vr-ticket-info-icon">💺</span>
<span>{{seat_info}}</span>
</div>
<div class="vr-ticket-info-row">
<span class="vr-ticket-info-icon">👤</span>
<span>{{real_name}} {{phone}}</span>
</div>
</div>
<div class="vr-ticket-footer">
<div class="vr-ticket-short-code">短码: {{short_code}}</div>
<a href="javascript:;" class="vr-ticket-view-btn" onclick="VrTicketWallet.viewTicket({{id}})">查看票码 →</a>
</div>
</div>
</template>
<!-- 票详情弹窗 -->
<div class="vr-ticket-modal" id="vrTicketModal">
<div class="vr-ticket-modal-content">
<div class="vr-ticket-modal-header">
<div class="vr-ticket-modal-title">电子票</div>
<button class="vr-ticket-modal-close" onclick="VrTicketWallet.closeModal()">×</button>
</div>
<div id="vrTicketModalBody">
<!-- 动态内容 -->
</div>
</div>
</div>
<script>
/**
* VR票务 - 票夹核心JS
*
* 使用方式:
* 1. 引入此文件
* 2. 调用 VrTicketWallet.init(userId) 初始化
* 3. 调用 VrTicketWallet.loadTickets() 加载票列表
*/
var VrTicketWallet = (function() {
var apiBase = '<?php echo Config("shopxo.host_url"); ?>?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=';
var token = '';
var tickets = [];
var currentTicket = null;
/**
* 初始化
* @param {string} userToken - 用户登录Token
*/
function init(userToken) {
token = userToken || '';
// 绑定点击空白关闭弹窗
document.addEventListener('click', function(e) {
var modal = document.getElementById('vrTicketModal');
if (e.target === modal) {
closeModal();
}
});
// 绑定 ESC 关闭弹窗
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
}
});
}
/**
* 加载票列表
* @param {string} containerId - 票列表容器ID
* @param {function} callback - 加载完成回调
*/
function loadTickets(containerId, callback) {
containerId = containerId || 'vrTicketList';
var container = document.getElementById(containerId);
if (!container) {
console.error('VrTicketWallet: 容器 #' + containerId + ' 不存在');
return;
}
container.innerHTML = '<div class="vr-ticket-loading" style="text-align:center;padding:40px;color:#999;">加载中...</div>';
$.ajax({
url: apiBase + 'list',
type: 'GET',
dataType: 'json',
headers: token ? {'X-Token': token} : {},
success: function(res) {
if (res.code === 0) {
tickets = res.data.tickets || [];
renderTickets(container);
if (typeof callback === 'function') {
callback(tickets);
}
} else if (res.code === 401) {
container.innerHTML = '<div class="vr-ticket-empty" style="text-align:center;padding:40px;color:#999;">请先登录后查看我的票</div>';
} else {
container.innerHTML = '<div class="vr-ticket-error" style="text-align:center;padding:40px;color:#f56c6c;">' + (res.msg || '加载失败') + '</div>';
}
},
error: function() {
container.innerHTML = '<div class="vr-ticket-error" style="text-align:center;padding:40px;color:#f56c6c;">网络错误,请稍后重试</div>';
}
});
}
/**
* 渲染票列表
*/
function renderTickets(container) {
if (tickets.length === 0) {
container.innerHTML = '<div class="vr-ticket-empty" style="text-align:center;padding:40px;color:#999;">暂无电子票</div>';
return;
}
var html = '';
tickets.forEach(function(ticket) {
var statusMap = {
0: {text: '未核销', class: 'unverified'},
1: {text: '已核销', class: 'verified'},
2: {text: '已退款', class: 'refunded'}
};
var status = statusMap[ticket.verify_status] || statusMap[0];
html += '<div class="vr-ticket-card' + (ticket.verify_status > 0 ? ' ' + ticket.verify_status : '') + '" data-ticket-id="' + ticket.id + '">' +
'<div class="vr-ticket-card-header">' +
'<div class="vr-ticket-goods-title">' + escapeHtml(ticket.goods_title) + '</div>' +
'<div class="vr-ticket-status ' + status.class + '">' + status.text + '</div>' +
'</div>' +
'<div class="vr-ticket-info">' +
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">📅</span><span>' + escapeHtml(ticket.session_time) + '</span></div>' +
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">📍</span><span>' + escapeHtml(ticket.venue_name) + '</span></div>' +
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">💺</span><span>' + escapeHtml(ticket.seat_info) + '</span></div>' +
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">👤</span><span>' + escapeHtml(ticket.real_name) + ' ' + escapeHtml(ticket.phone) + '</span></div>' +
'</div>' +
'<div class="vr-ticket-footer">' +
'<div class="vr-ticket-short-code">短码: ' + escapeHtml(ticket.short_code) + '</div>' +
'<a href="javascript:;" class="vr-ticket-view-btn" onclick="VrTicketWallet.viewTicket(' + ticket.id + ')">查看票码 →</a>' +
'</div>' +
'</div>';
});
container.innerHTML = html;
}
/**
* 查看单个票详情
*/
function viewTicket(ticketId) {
// 查找本地缓存
var ticket = tickets.find(function(t) { return t.id === ticketId; });
var modalBody = document.getElementById('vrTicketModalBody');
modalBody.innerHTML = '<div style="text-align:center;padding:40px;">加载中...</div>';
document.getElementById('vrTicketModal').classList.add('active');
if (ticket) {
// 先显示基本信息,再加载 QR
showTicketBasic(ticket);
loadQrPayload(ticketId);
} else {
// 本地没有,从 API 加载
$.ajax({
url: apiBase + 'detail&id=' + ticketId,
type: 'GET',
dataType: 'json',
headers: token ? {'X-Token': token} : {},
success: function(res) {
if (res.code === 0 && res.data.ticket) {
currentTicket = res.data.ticket;
showTicketDetail(currentTicket);
} else {
modalBody.innerHTML = '<div style="text-align:center;padding:40px;color:#f56c6c;">' + (res.msg || '加载失败') + '</div>';
}
},
error: function() {
modalBody.innerHTML = '<div style="text-align:center;padding:40px;color:#f56c6c;">网络错误</div>';
}
});
}
}
/**
* 显示票基本信息
*/
function showTicketBasic(ticket) {
var statusMap = {
0: {text: '未核销', class: 'unverified'},
1: {text: '已核销', class: 'verified'},
2: {text: '已退款', class: 'refunded'}
};
var status = statusMap[ticket.verify_status] || statusMap[0];
var modalBody = document.getElementById('vrTicketModalBody');
modalBody.innerHTML =
'<div class="vr-ticket-qr-section">' +
'<div class="vr-ticket-qr-wrapper" id="vrQrcodeBox"></div>' +
'<div class="vr-ticket-qr-expire" id="vrQrExpire"></div>' +
'</div>' +
'<div class="vr-ticket-short-code-display">' +
'<div class="vr-ticket-short-code-label">短码(人工核销)</div>' +
'<div class="vr-ticket-short-code-value">' + escapeHtml(ticket.short_code) + '</div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">状态</div>' +
'<div class="vr-ticket-detail-value"><span class="vr-ticket-status ' + status.class + '">' + status.text + '</span></div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">场次</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.session_time) + '</div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">场馆</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.venue_name) + '</div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">座位</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.seat_info) + '</div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">观演人</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.real_name) + ' ' + escapeHtml(ticket.phone) + '</div>' +
'</div>' +
'<button class="vr-ticket-refresh-btn" id="vrRefreshBtn" onclick="VrTicketWallet.refreshQr(' + ticket.id + ')">刷新二维码</button>';
}
/**
* 显示票详细信息(含 QR
*/
function showTicketDetail(ticket) {
var statusMap = {
0: {text: '未核销', class: 'unverified'},
1: {text: '已核销', class: 'verified'},
2: {text: '已退款', class: 'refunded'}
};
var status = statusMap[ticket.verify_status] || statusMap[0];
var modalBody = document.getElementById('vrTicketModalBody');
var verifiedBadge = ticket.verify_status === 1
? '<div class="vr-ticket-verified-badge">✓ 已核销</div>'
: '';
modalBody.innerHTML =
'<div class="vr-ticket-qr-section">' +
'<div class="vr-ticket-qr-wrapper" id="vrQrcodeBox"></div>' +
'<div class="vr-ticket-qr-expire" id="vrQrExpire"></div>' +
'</div>' +
'<div class="vr-ticket-short-code-display">' +
'<div class="vr-ticket-short-code-label">短码(人工核销)</div>' +
'<div class="vr-ticket-short-code-value">' + escapeHtml(ticket.short_code) + '</div>' +
'</div>' +
verifiedBadge +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">场次</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.session_time) + '</div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">场馆</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.venue_name) + '</div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">座位</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.seat_info) + '</div>' +
'</div>' +
'<div class="vr-ticket-detail-row">' +
'<div class="vr-ticket-detail-label">观演人</div>' +
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.real_name) + ' ' + escapeHtml(ticket.phone) + '</div>' +
'</div>' +
(ticket.verify_status === 0 ? '<button class="vr-ticket-refresh-btn" id="vrRefreshBtn" onclick="VrTicketWallet.refreshQr(' + ticket.id + ')">刷新二维码</button>' : '');
// 渲染 QR 码
if (ticket.qr_payload) {
renderQrCode(ticket.qr_payload, ticket.qr_expires_in);
} else {
document.getElementById('vrQrcodeBox').innerHTML = '<div style="color:#999;">QR加载中...</div>';
}
}
/**
* 加载 QR payload
*/
function loadQrPayload(ticketId) {
var cacheKey = 'vr_qr_' + ticketId;
var cached = localStorage.getItem(cacheKey);
var now = Math.floor(Date.now() / 1000);
if (cached) {
try {
var data = JSON.parse(cached);
var remaining = data.expires_at - now;
// 缓存有效且剩余 > 15 分钟,直接使用
if (remaining > 900) {
renderQrCode(data.payload, remaining);
return;
}
} catch (e) {
// 缓存损坏,清除
localStorage.removeItem(cacheKey);
}
}
// 需要刷新
$.ajax({
url: apiBase + 'detail&id=' + ticketId,
type: 'GET',
dataType: 'json',
headers: token ? {'X-Token': token} : {},
success: function(res) {
if (res.code === 0 && res.data.ticket) {
var ticket = res.data.ticket;
var expiresIn = ticket.qr_expires_in || 0;
// 缓存到 localStorage
localStorage.setItem(cacheKey, JSON.stringify({
payload: ticket.qr_payload,
expires_at: now + expiresIn
}));
renderQrCode(ticket.qr_payload, expiresIn);
}
},
error: function() {
document.getElementById('vrQrcodeBox').innerHTML = '<div style="color:#f56c6c;">QR加载失败</div>';
}
});
}
/**
* 渲染 QR 码
*/
function renderQrCode(payload, expiresIn) {
var qrBox = document.getElementById('vrQrcodeBox');
var expireEl = document.getElementById('vrQrExpire');
// 解码 payload
try {
var json = atob(payload);
var data = JSON.parse(json);
// 渲染 QR
qrBox.innerHTML = '';
$(qrBox).qrcode({
text: json,
width: 200,
height: 200
});
// 显示过期时间
if (expiresIn > 0) {
var minutes = Math.floor(expiresIn / 60);
expireEl.textContent = '有效期: ' + minutes + ' 分钟';
}
} catch (e) {
qrBox.innerHTML = '<div style="color:#f56c6c;">QR解析失败</div>';
}
}
/**
* 刷新 QR
*/
function refreshQr(ticketId) {
var btn = document.getElementById('vrRefreshBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '刷新中...';
}
$.ajax({
url: apiBase + 'refreshQr&id=' + ticketId,
type: 'GET',
dataType: 'json',
headers: token ? {'X-Token': token} : {},
success: function(res) {
if (res.code === 0 && res.data.ticket) {
var ticket = res.data.ticket;
// 更新缓存
var cacheKey = 'vr_qr_' + ticketId;
var now = Math.floor(Date.now() / 1000);
localStorage.setItem(cacheKey, JSON.stringify({
payload: ticket.qr_payload,
expires_at: now + ticket.qr_expires_in
}));
// 重新渲染 QR
renderQrCode(ticket.qr_payload, ticket.qr_expires_in);
if (btn) {
btn.textContent = '已刷新';
setTimeout(function() {
btn.textContent = '刷新二维码';
btn.disabled = false;
}, 2000);
}
} else {
alert(res.msg || '刷新失败');
if (btn) {
btn.disabled = false;
btn.textContent = '刷新二维码';
}
}
},
error: function() {
alert('网络错误');
if (btn) {
btn.disabled = false;
btn.textContent = '刷新二维码';
}
}
});
}
/**
* 关闭弹窗
*/
function closeModal() {
document.getElementById('vrTicketModal').classList.remove('active');
}
/**
* HTML 转义
*/
function escapeHtml(str) {
if (!str) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 导出公开接口
return {
init: init,
loadTickets: loadTickets,
viewTicket: viewTicket,
refreshQr: refreshQr,
closeModal: closeModal
};
})();
</script>

View File

@ -0,0 +1,38 @@
<?php echo ModuleInclude('public/header'); ?>
<!-- VR票务样式 -->
<link rel="stylesheet" type="text/css"
href="<?php echo Config('shopxo.host_url'); ?>plugins/vr_ticket/static/css/ticket.css?v=<?php echo time(); ?>" />
<!-- 页面内容 -->
<div class="vr-ticket-wallet-page" id="vrTicketWalletApp">
<div class="vr-wallet-header">
<div class="vr-wallet-title">我的电子票</div>
<div class="vr-wallet-subtitle"><span id="vrTicketCount">0</span> 张票</div>
</div>
<!-- 票列表容器 -->
<div id="vrTicketList" class="vr-ticket-list">
<!-- 由 JS 动态渲染 -->
</div>
</div>
<?php echo ModuleInclude('public/footer'); ?>
<!-- 引入票卡片组件 -->
<?php echo ModuleInclude('../../goods/ticket_card'); ?>
<script>
$(function() {
// 获取用户 Token
var token = '<?php echo session("user_token") ?: ""; ?>';
// 初始化票夹
VrTicketWallet.init(token);
// 加载票列表
VrTicketWallet.loadTickets('vrTicketList', function(tickets) {
$('#vrTicketCount').text(tickets.length);
});
});
</script>