Compare commits

...

5 Commits

Author SHA1 Message Date
Council 6a2f9ed061 docs: 添加 Phase 4 API 文档(已实现部分) 2026-04-23 15:59:45 +08:00
Council c3261e553d fix: 添加 shortCodeDecode 缺失的 return 语句和闭合括号 2026-04-23 14:44:19 +08:00
Council ac676d00be refactor: 移除 qr_issued_at 字段
QR payload 改为实时生成,不存储发放时间。
前端 localStorage 自行管理缓存。
2026-04-23 14:37:10 +08:00
Council 6903522b5a fix: 修复 BaseService.php 语法错误(shortCodeDecode 缺失 return + docblock) 2026-04-23 14:26:05 +08:00
Council 8b15283376 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
2026-04-23 13:44:48 +08:00
10 changed files with 1964 additions and 29 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: 联调测试

413
docs/PHASE_4_API.md Normal file
View File

@ -0,0 +1,413 @@
# Phase 4 API 文档(已实现部分)
> 状态:截至 2026-04-23Phase 4.1/4.2 已完成B端核销进行中
> 目的:记录已实现 API避免后续 agent 重复发明轮子
---
## 一、数据库表结构
### 1.1 vr_tickets电子票
**用途**:存储用户已购票,每行 = 一张票。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT UNSIGNED AUTO_INCREMENT | 票ID主键 |
| `order_id` | BIGINT UNSIGNED | 关联订单ID |
| `order_no` | CHAR(60) | 订单号 |
| `goods_id` | BIGINT UNSIGNED | 票务商品ID |
| `goods_snapshot` | TEXT | 商品快照JSON含名称/规格/价格) |
| `user_id` | BIGINT UNSIGNED | 持有用户ID |
| `ticket_code` | CHAR(36) UNIQUE | UUID v4 票码(核销主键) |
| `qr_data` | TEXT | 格式:`短码\|QR_payload`(例:`003a2hgmgety\|eyJ...` |
| `seat_info` | VARCHAR(255) | 座位描述(如 `A区-3排-15座` |
| `spec_base_id` | BIGINT UNSIGNED | spec_base_id5维规格维度ID |
| `real_name` | VARCHAR(60) | 观演人姓名 |
| `phone` | CHAR(15) | 观演人手机 |
| `id_card` | CHAR(18) | 观演人身份证 |
| `verify_status` | TINYINT UNSIGNED | 0=未核销1=已核销2=已退款 |
| `verify_time` | INT UNSIGNED | 核销时间戳 |
| `verifier_id` | BIGINT UNSIGNED | 核销员ID |
| `issued_at` | INT UNSIGNED | 票发放时间 |
| `created_at` | INT UNSIGNED | 创建时间 |
| `updated_at` | INT UNSIGNED | 更新时间 |
**索引**`idx_user_id`、`idx_goods_id`、`idx_verify_status`、`idx_created_at`、`idx_spec_base_id`
**qr_data 格式详解**
```
short_code|base64(payload)
例如003a2hgmgety|eyJpZCI6NDgyODE1LCJnIjoxMTgsImlhdCI6MTc0NTI4...
payload = base64(json({
"id": 482815, // ticket_id
"g": 118, // goods_id
"iat": 1745286000,// 签发时间
"exp": 1745287800,// 过期时间iat+1800s=30min
"sig": "A3F9B2C1" // HMAC-SHA256签名前8字符
}))
```
---
### 1.2 vr_verifiers核销员
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT UNSIGNED | 核销员ID |
| `user_id` | BIGINT UNSIGNED | 关联的 ShopXO 用户ID |
| `name` | VARCHAR(60) | 核销员名称 |
| `status` | TINYINT UNSIGNED | 1=启用0=禁用 |
| `created_at` | INT UNSIGNED | 创建时间 |
---
### 1.3 vr_verifications核销记录
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT UNSIGNED | 记录ID |
| `ticket_id` | BIGINT UNSIGNED | 关联票ID |
| `ticket_code` | CHAR(36) | 票码(冗余) |
| `verifier_id` | BIGINT UNSIGNED | 核销员ID |
| `verifier_name` | VARCHAR(60) | 核销员名称(冗余) |
| `goods_id` | BIGINT UNSIGNED | 商品ID |
| `created_at` | INT UNSIGNED | 核销时间 |
---
### 1.4 vr_audit_log审计日志
| 字段 | 类型 | 说明 |
|------|------|------|
| `action` | VARCHAR(60) | 操作类型(如 `verify`, `refund`, `export` |
| `operator_id` | BIGINT UNSIGNED | 操作人ID后台 admin_id |
| `operator_name` | VARCHAR(90) | 操作人名称 |
| `target_type` | VARCHAR(60) | 对象类型(`ticket`, `verifier`, `seat_template` |
| `target_id` | BIGINT UNSIGNED | 对象ID |
| `target_desc` | VARCHAR(255) | 对象描述(冗余,人工可读) |
| `client_ip` | VARCHAR(45) | 客户端IP |
| `user_agent` | VARCHAR(512) | User-Agent |
| `request_id` | VARCHAR(64) | 请求追踪ID同一次 HTTP 请求内的多个操作共用) |
| `extra` | LONGTEXT | 附加数据JSON |
| `created_at` | INT UNSIGNED | 操作时间 |
---
## 二、Service 层 API内部调用
所有方法均在 `app\plugins\vr_ticket\service\` 下。
### 2.1 BaseService
**命名空间**`app\plugins\vr_ticket\service\BaseService`
#### 短码编解码
**`shortCodeEncode(int $goods_id, int $ticket_id): string`**
- 输入goods_id0-1679615ticket_id正整数
- 输出:短码(小写字母,格式:`4位goods_id明文 + 变长混淆ticket_id`
- 示例:`shortCodeEncode(118, 1)` → `003a2hgmgety`
**`shortCodeDecode(string $code, ?int $goods_id_hint = null): array`**
- 输入:短码字符串,可选 goods_id_hint校验用可省略
- 输出:`['goods_id' => int, 'ticket_id' => int]`
- 示例:`shortCodeDecode('003a2hgmgety')` → `['goods_id' => 118, 'ticket_id' => 1]`
- 特性:**O(1) 解码**无需暴力搜索前4位直接取 goods_id
#### QR 签名
**`signQrPayload(array $payload): string`**
- 输入:`['id' => int, 'g' => int, 'iat' => int, 'exp' => int]`
- 输出base64 编码字符串payload + `sig` 字段)
- 算法:`sig = substr(HMAC-SHA256("{$id}.{$g}.{$iat}.{$exp}", secret), 0, 8)`
- 密钥:`VR_TICKET_SECRET` 环境变量(或硬编码测试密钥 `8935b3...`
**`verifyQrPayload(string $encoded): array|null`**
- 输入base64 编码字符串
- 输出:验证失败返回 `null`;成功返回 `['id' => int, 'g' => int, 'exp' => int]`
- 验证内容:过期检查(`exp < now`+ HMAC-SHA256
#### 其他工具方法
**`generateUuid(): string`** — 生成 UUID v4 票码
**`encryptQrData(array $data, ?int $expire = null): string`** — AES-256-CBC 加密
**`decryptQrData(string $encoded): array|null`** — 解密
**`isTicketGoods(int $goods_id): bool`** — 判断商品是否为票务商品
**`table(string $name): string`** — 返回带前缀的表名 `vr_` + $name
**`now(): int`** — 当前时间戳
**`getGoodsKey(int $goods_id): string`** — 派生 per-goods keyHMAC-SHA256前16字节
**`getVrSecret(): string`** — 获取主密钥(需配置 `VR_TICKET_SECRET`
**`AdminPowerMenu(): array`** — 返回后台权限菜单配置
---
### 2.2 TicketService
**命名空间**`app\plugins\vr_ticket\service\TicketService`(继承 BaseService
#### 出票链路
**`onOrderPaid(array $params): bool`** ⚡ 核心入口
`plugins_service_order_pay_success_handle_end` Hook 触发。
- 触发时机:微信支付成功回调,`pay_status=1` 已写入 DB 后
- `$params` 结构:
```php
[
'business_id' => 123, // 订单ID
'business_ids' => [123], // 批量订单ID数组
'user_id' => 456,
'order' => [...], // 订单完整数据(含 extension_data
'order_id' => 123,
]
```
- 处理流程:
1. 判断是否为票务商品(`isTicketGoods`
2. 查询订单明细解析5维 spec`$vr-场次/$vr-场馆/$vr-演播室/$vr-分区/$vr-座位号`
3. 逐行调用 `issueTicket()` 生成票
- 返回成功发放票数0表示跳过
**`issueTicket(array $order, array $og): int`** ⚡ 单票发放
内部方法,供 `onOrderPaid` 调用。
- 参数:`$order`(订单数据),`$og`(订单商品行,已解析 `_parsed_spec_name` 等字段)
- 流程:
1. 幂等保护:同一 `order_id + seat_info` 已有票则跳过
2. 生成 UUID `ticket_code`
3. `INSERT` 占位 `qr_data=''` → 获取 `ticket_id`
4. 生成短码:`shortCodeEncode(goods_id, ticket_id)`
5. 生成 QR payload`signQrPayload(['id' => ticket_id, ...])`
6. `UPDATE qr_data = short_code + '|' + payload`
7. 写入观演人信息(`real_name/phone/id_card`
- 返回ticket_id失败返回 0
#### 核销
**`verifyTicket(string $ticket_code, int $verifier_id): array`** ⚡ UUID核销
- 参数UUID 票码(`vr_tickets.ticket_code`核销员ID
- 内部使用 `FOR UPDATE` 悲观锁
- 返回:`['code' => 0, 'msg' => '核销成功', 'data' => ['seat_info' => ..., 'real_name' => ..., 'goods_name' => ...]]`
- 错误码:`code=-1` 票不存在,`code=-2` 已核销,`code=-3` 已退款,`code=-999` 系统异常
**`verifyByShortCode(string $short_code, int $verifier_id): array`** ⚡ 短码核销
- 参数:短码(如 `003a2hgmgety`核销员ID
- 流程:`shortCodeDecode(code)` → 解析 `{goods_id, ticket_id}` → DB查询 → `verifyTicketById()`
- 特点:**无需知道 goods_id**从短码前4位直接解析
**`verifyTicketById(int $ticket_id, int $verifier_id): array`** — 内部方法ticket_id 核销
#### QR 数据
**`getQrData(int $ticket_id, int $user_id): array`** — 获取用户票的 QR 数据
- 校验:票必须属于该用户(`user_id` 匹配)
- 返回:
```php
// 有效期 > 15分钟900s返回缓存
['code' => 0, 'data' => [
'short_code' => '003a2hgmgety',
'payload' => 'eyJ...',
'cached' => true,
'expires_in' => 1724, // 秒
]]
// 有效期 ≤ 15分钟刷新后返回
['code' => 0, 'data' => [
'short_code' => '003a2hgmgety',
'payload' => 'eyJ...(new)', // 新签名
'cached' => false,
'expires_in' => 1800,
]]
```
- 错误:`code=-1` 票不存在,`code=-2` 已核销,`code=-3` 已退款
**`getQrCodeUrl(string $ticket_code): string`** — 生成 QR 码图片 URL
- 调用 ShopXO 内置 `/?s=index/qrcode/index&content=...&size=8&level=H`
- 内容base64(JSON(`{type: 'vr_ticket', code: UUID}`))
#### 用户票查询
**`getUserTickets(int $user_id, ?int $status = null): array`**
- 返回用户所有票(可筛选核销状态)
---
### 2.3 AuditService
**命名空间**`app\plugins\vr_ticket\service\AuditService`
**`log(string $action, string $targetType, int $targetId, array $extra = [], string $targetDesc = ''): int|false`**
记录审计日志,异常不阻断主流程。
- `$action` 常量:`ACTION_VERIFY` / `ACTION_REFUND` / `ACTION_EXPORT` / `ACTION_DISABLE_VERIFIER` / `ACTION_ENABLE_VERIFIER` / `ACTION_DELETE_VERIFIER` / `ACTION_DELETE_TEMPLATE`
- `$targetType` 常量:`TARGET_TICKET` / `TARGET_VERIFIER` / `TARGET_TEMPLATE` / `TARGET_GOODS`
- 返回日志ID 或 `false`(失败时)
**`logVerify(int $ticketId, string $ticketCode, int $verifierId, string $verifierName, string $result, int $oldStatus)`** — 便捷包装
**`logExport(int $goodsId, array $filter, int $count)`** — 记录导出操作
**`search(array $params): array`** — 查询审计日志(分页)
- 支持:`action`, `operator_id`, `target_type`, `target_id`, `date_from`, `date_to`, `page`, `limit`
---
## 三、后台管理 APIAdmin
所有 Admin 方法在 `app\plugins\vr_ticket\admin\Admin.php`(继承 `Common`)。
### 3.1 电子票管理
| 方法 | URL | 说明 | 鉴权 |
|------|-----|------|------|
| `TicketList()` | `GET /plugins/vr_ticket/admin/ticketList` | 票列表(支持关键词/状态/商品筛选) | Admin |
| `TicketDetail()` | `GET /plugins/vr_ticket/admin/ticketDetail?id=X` | 票详情 | Admin |
| `TicketVerify()` | `POST /plugins/vr_ticket/admin/ticketVerify` | 手动核销JSON API | Admin |
| `TicketExport()` | `POST /plugins/vr_ticket/admin/ticketExport` | 导出票列表 CSV | Admin |
**`TicketVerify` 请求**
```json
POST /plugins/vr_ticket/admin/ticketVerify
Body: { "ticket_code": "uuid-xxx", "verifier_id": 1 }
```
---
### 3.2 核销员管理
| 方法 | URL | 说明 |
|------|-----|------|
| `VerifierList()` | `GET /plugins/vr_ticket/admin/verifierList` | 核销员列表 |
| `VerifierSave()` | `GET/POST /plugins/vr_ticket/admin/verifierSave` | 添加/编辑核销员 |
| `VerifierDelete()` | `POST /plugins/vr_ticket/admin/verifierDelete` | 禁用核销员 |
---
### 3.3 核销记录
| 方法 | URL | 说明 |
|------|-----|------|
| `VerificationList()` | `GET /plugins/vr_ticket/admin/verificationList` | 核销记录列表(支持核销员/日期筛选) |
---
### 3.4 场馆/座位模板
| 方法 | URL | 说明 |
|------|-----|------|
| `VenueList()` | `GET /plugins/vr_ticket/admin/venueList` | 场馆列表seat_map v3 格式) |
| `VenueSave()` | `GET/POST /plugins/vr_ticket/admin/venueSave` | 添加/编辑场馆(支持 Base64 传输 seat_map JSON |
| `VenueDelete()` | `POST /plugins/vr_ticket/admin/venueDelete` | 删除场馆(支持硬删除 `value=hard` |
| `VenueEnable()` | `POST /plugins/vr_ticket/admin/venueEnable` | 启用场馆 |
---
### 3.5 座位模板Phase 2 遗留)
| 方法 | URL | 说明 |
|------|-----|------|
| `SeatTemplateList()` | `GET /plugins/vr_ticket/admin/seatTemplateList` | 座位模板列表 |
| `SeatTemplateSave()` | `GET/POST /plugins/vr_ticket/admin/seatTemplateSave` | 添加/编辑模板 |
| `SeatTemplateDelete()` | `POST /plugins/vr_ticket/admin/seatTemplateDelete` | 删除模板 |
| `SeatTemplateEnable()` | `POST /plugins/vr_ticket/admin/seatTemplateEnable` | 启用模板 |
---
### 3.6 辅助 API
**`SoldSeats()`** `GET /plugins/vr_ticket/admin/soldSeats?goods_id=X&spec_base_id=Y`
- 返回:`{"code":0,"data":{"sold_seats":[]}}`
- 当前版本返回空数组TODO接入真实已售座位查询
---
## 四、Hook 触发点
Hook 在 `app\plugins\vr_ticket\Hook.php` 中注册。
| Hook 名称 | 触发时机 | 处理方法 |
|-----------|---------|---------|
| `plugins_service_order_pay_success_handle_end` | 订单支付成功pay_status=1 | `TicketService::onOrderPaid($params)` |
| `plugins_service_admin_menu_data` | 后台菜单渲染 | `Hook::AdminSidebarInit()` 注入 VR票务菜单 |
---
## 五、QR 验证流程(核销机)
### 5.1 QR 本地验证(离线可行)
```php
// 步骤1解析 base64 payload
$payload = json_decode(base64_decode($qr_string), true);
// 步骤2检查过期
if ($payload['exp'] < time()) {
return '已过期,拒绝';
}
// 步骤3验签防篡改
$sign_str = "{$payload['id']}.{$payload['g']}.{$payload['iat']}.{$payload['exp']}";
$expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
if (!hash_equals($expected_sig, $payload['sig'])) {
return '伪造,拒绝';
}
// 步骤4联网执行 DB 状态检查(防重放)
// SELECT verify_status FROM vr_tickets WHERE id=? AND ticket_code=? FOR UPDATE
// verify_status == 0 → 放行
// verify_status == 1 → 已核销
// verify_status == 2 → 已退款
```
### 5.2 短码核销流程
```
核销员输入/扫码003a2hgmgety
shortCodeDecode('003a2hgmgety')
→ goods_id=118, ticket_id=1
DB: SELECT * FROM vr_tickets WHERE id=1 AND goods_id=118 FOR UPDATE
→ 命中 → verify_status==0? → 更新 verify_status=1, verifier_id=X
返回:核销成功 + 票面信息
```
---
## 六、关键配置
| 配置项 | 值 | 说明 |
|--------|---|------|
| `VR_TICKET_SECRET` | 环境变量(.env | QR签名 + 短码混淆主密钥 |
| `VR_TICKET_QR_SECRET` | 环境变量(.env | AES-256-CBC QR加密密钥尚未启用 |
| 表前缀 | `vrt_` | ShopXO 配置的表前缀 |
| QR 有效期 | 1800s30min | `signQrPayload``exp = iat + 1800` |
| 缓存阈值 | 900s15min | `getQrData()` 刷新阈值 |
| Feistel 轮数 | 8 | HMAC-XOR 混淆轮数 |
---
## 七、待完成 API避免重复实现
| 功能 | 文件 | 状态 |
|------|------|------|
| C端票列表 API | `api/controller/Ticket.php` | ❌ 待建 |
| C端票夹页面 | `index/Ticket.php` | ❌ 待建 |
| C端票夹 HTML | `view/goods/ticket_wallet.html` | ❌ 待建 |
| B端扫码核销页 | `admin/view/ticket/verify.html` | ⏳ 进行中 |
| B端核销 API含短码路由 | `admin/controller/Ticket.php` | ⏳ 进行中 |
| 票夹聚合查询服务 | `service/WalletService.php` | ❌ 待建 |
---
## 八、已验证可行的方案
- **ShopXO 插件路由**无需手动声明Hooks → 自动映射
- **admin/Admin.php 单文件控制器**:所有后台方法在此文件,统一继承 `Common`(含 IsLogin/IsPower/ViewInit
- **自动建表**Admin 控制器 `initialize()` 检查并从 `install.sql` 执行建表
- **session('admin')**:后台鉴权从 session 读取管理员信息
- **Base64 绕过净化**`VenueSave` 支持 `seat_map_raw` 字段Base64 传输,避免 HTML 净化破坏 JSON
- **JsBarcode**:已内置 `public/static/common/lib/JsBarcode/JsBarcode.all.min.js`v3.11.5
- **QR码**:前端用 `$('.view-qrcode-init').qrcode({text: value})`,后端用 `/?s=index/qrcode/index`

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

@ -13,34 +13,6 @@
return array (
'listen' =>
array (
'plugins_service_admin_menu_data' =>
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
),
'plugins_service_order_pay_success_handle_end' =>
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
),
'plugins_service_order_delete_success' =>
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
),
'plugins_view_admin_goods_save' =>
array (
0 => 'app\\plugins\\vr_ticket\\hook\\AdminGoodsSave',
),
'plugins_service_goods_save_handle' =>
array (
0 => 'app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle',
),
'plugins_service_goods_save_thing_end' =>
array (
0 => 'app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle',
),
'plugins_css_data' =>
array (
0 => 'app\\plugins\\vr_ticket\\hook\\ViewGoodsCss',
),
),
);
?>

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

@ -474,6 +474,12 @@ class BaseService
// 转回 base36 字符串(不填充)
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
return [
'goods_id' => $goods_id,
'ticket_id' => $ticket_id,
];
}
/**
* 签名 QR payloadHMAC-SHA256 防篡改)
*

View File

@ -0,0 +1,212 @@
<?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
{
// 查询该用户的所有票(关联订单)
$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,
];
}
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 分钟,动态生成,不存储
*
* @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);
}
}

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>