Compare commits
5 Commits
840157ca9d
...
6a2f9ed061
| Author | SHA1 | Date |
|---|---|---|
|
|
6a2f9ed061 | |
|
|
c3261e553d | |
|
|
ac676d00be | |
|
|
6903522b5a | |
|
|
8b15283376 |
|
|
@ -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/CSS(qrcode.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: 联调测试
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
# Phase 4 API 文档(已实现部分)
|
||||
|
||||
> 状态:截至 2026-04-23,Phase 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_id(5维规格维度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_id(0-1679615),ticket_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 key(HMAC-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`
|
||||
|
||||
---
|
||||
|
||||
## 三、后台管理 API(Admin)
|
||||
|
||||
所有 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 有效期 | 1800s(30min) | `signQrPayload` 中 `exp = iat + 1800` |
|
||||
| 缓存阈值 | 900s(15min) | `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`
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
# Phase 4 规划:发票 · 核销 · 票夹
|
||||
|
||||
> 规划日期:2026-04-22
|
||||
> 状态:**规划完成,待启动调研**
|
||||
> 最后更新:2026-04-23(算法变更:Feistel-8 → HMAC-XOR,BUG修复)
|
||||
> 状态:**Phase 4.1/4.2 已完成,B端核销进行中**
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
?>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Token(JWT格式)
|
||||
*
|
||||
* @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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 payload(HMAC-SHA256 防篡改)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue