vr-shopxo-plugin/docs/api/VR_TICKET_WALLET_VERIFY_API.md

737 lines
19 KiB
Markdown
Raw Normal View History

# VR票务插件 C端 API 文档
> **版本**: 1.3.0
> **最后更新**: 2026-06-22
> **更新内容**: 新增瀑布流参数支持last_id + limit + order_by
本文档描述了 VR 票务插件(`vr_ticket`)的 C 端 UniApp API涵盖票夹、核销、核销记录三类接口适用于移动端用户及授权核销员使用。
---
## 一、概述
### 1.1 核心机制
| 机制 | 说明 |
|------|------|
| **动态 QR Payload** | QR 内容含 HMAC-SHA256 签名,默认 **30 分钟** 有效期,前端应动态刷新 |
| **悲观锁防并发** | 核销接口使用 `FOR UPDATE` 事务锁,杜绝重复核销 |
| **双重码格式** | 长码UUID v4与短码Base36 + Feistel 混淆API 自动识别 |
| **核销员白名单** | C 端用户必须存在于 `vr_verifiers` 表且 `status=1` 才可执行核销操作 |
### 1.2 响应标准格式
所有 API 均返回以下 JSON 结构:
```json
{
"code": 0,
"msg": "success",
"data": { ... }
}
```
---
## 二、认证机制
### 2.1 请求头认证
所有 C 端 API 均需携带用户身份令牌。
```
Header Key: X-Token
Header Value: {user_token}
```
兜底方案(按优先级):
1. `X-Token` / `Authorization: Bearer {token}`App 登录)
2. `user_info` Cookie 解码Web 登录)
3. Session传统页面
若均未找到,返回:
```json
{
"code": 401,
"msg": "请先登录",
"data": []
}
```
### 2.2 核销员身份鉴权
以下 API 除了要求 C 端登录外,还需验证用户是否为授权核销员:
```json
// 未授权(非核销员)
{
"code": -403,
"msg": "你不是授权核销员,无权核销",
"data": []
}
```
核销员身份由 `vr_verifiers` 表决定,管理员可在 ShopXO 后台添加。普通用户(非核销员)可正常使用票夹 API但不能执行核销。
---
## 三、通用错误码
| code | 说明 |
|------|------|
| `0` | 成功 |
| `-1` | 通用失败 / 参数错误 |
| `-2` | 该票已核销 |
| `-3` | 该票已退款 |
| `-4` | QR 数据异常 |
| `-403` | 无核销权限(不是授权核销员) |
| `-404` | 票不存在或无权访问 |
| `-999` | 系统异常(事务失败) |
| `401` | 未登录 / 未授权 |
---
## 四、票夹 API
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction={action}`
> **认证**: C 端登录态(无需核销员身份)
### 4.1 获取用户票列表 ⭐ **新增功能**
```
GET ...&pluginsaction=list
GET ...&pluginsaction=tickets (别名)
```
#### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `order_id` | int | ❌ | - | **【新增】** 按单个订单 ID 精准筛选 |
| `order_ids` | string | ❌ | - | **【新增】** 按多个订单 ID 批量筛选,逗号分隔,如 `41,42,43` |
| `goods_id` | int | ❌ | - | **【新增】** 按商品 ID 筛选 |
| `status` | int | ❌ | - | **【新增】** 按核销状态筛选:`0`=未核销,`1`=已核销,`2`=已退款 |
| `page` | int | ❌ | `1` | **【新增】** 页码 |
| `page_size` | int | ❌ | `20` | **【新增】** 每页数量(最小 1最大 100 |
> **注意**:
> - 所有筛选参数均为**可选**。不同参数之间是 **AND** 关系。
> - 例如同时传 `order_id=41&goods_id=119` 表示「订单 41 中商品 ID 为 119 的票据」。
#### 成功响应 `data`
```json
{
"tickets": [
{
"id": 123,
"order_id": 41,
"goods_id": 456,
"goods_title": "周杰伦演唱会-北京站",
"goods_image": "https://...jpg",
"seat_info": "2026-06-01 20:00|国家体育馆|主厅|A区|A1",
"seat_number": "A1",
"session_time": "2026-06-01 20:00",
"venue_name": "国家体育馆",
"real_name": "张三",
"phone": "138****5678",
"verify_status": 0,
"issued_at": 1716307200,
"short_code": "000ca1b2"
}
],
"total": 50,
"page": 1,
"page_size": 20,
"pages": 3
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `tickets` | array | 票据列表数组 |
| `total` | int | **【新增】** 总记录数 |
| `page` | int | **【新增】** 当前页码 |
| `page_size` | int | **【新增】** 每页数量 |
| `pages` | int | **【新增】** 总页数 |
| `order_id` | int | **【新增】** 订单 ID票据对象内 |
| `verify_status` | int | `0`=未核销 `1`=已核销 `2`=已退款 |
| `short_code` | string | 8-9位短码可供核销员扫码 |
| `seat_info` | string | 完整 5 维坐席信息,`场次\|场馆\|演播室\|分区\|座位号` |
#### 使用场景
**场景 1订单详情页 - 查看当前订单的所有票据**
```javascript
// 优化前(低效)- 全量拉取后前端过滤
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
success: function(res) {
var allTickets = res.data.data.tickets || [];
var orderTickets = allTickets.filter(function(t) {
return goodsIds.indexOf(t.goods_id) !== -1;
});
}
});
// 优化后(高效)- 后端直接筛选
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
order_id: orderId, // 直接传订单ID
page_size: 100 // 订单票据通常不会太多
},
success: function(res) {
var orderTickets = res.data.data.tickets || [];
// 无需前端过滤,直接使用
}
});
```
**场景 2票夹页 - 分页加载**
```javascript
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
page: 1,
page_size: 20,
status: 0 // 只看未核销的票
},
success: function(res) {
var data = res.data.data;
console.log('总票数:', data.total);
console.log('总页数:', data.pages);
console.log('当前页:', data.page);
}
});
```
**场景 3批量订单查询**
```javascript
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
order_ids: '41,42,43', // 一次查询多个订单
page_size: 50
});
```
---
### 4.1.1 瀑布流模式 ⭐ **v1.3.0 新增**
瀑布流模式专为移动端无限滚动场景设计,基于 ID 游标实现高性能连续加载。
#### 触发条件
- 传入 `last_id > 0``limit` 参数时自动启用瀑布流模式
- 否则使用传统分页模式(向后兼容)
#### 新增参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `last_id` | int | ❌ | `0` | 游标 ID`0` 表示首次加载 |
| `limit` | int | ❌ | `20` | 每次拉取数量(最小 1最大 100 |
| `order_by` | string | ❌ | `desc` | 排序方向:`desc`(降序,历史方向)/ `asc`(升序,新数据方向) |
#### 响应格式
```json
{
"tickets": [
{
"id": 69,
"order_id": 41,
"goods_id": 456,
"goods_title": "周杰伦演唱会-北京站",
"seat_info": "2026-06-01 20:00|国家体育馆|主厅|A区|A1",
"session_time": "2026-06-01 20:00",
"venue_name": "国家体育馆",
"verify_status": 0,
"issued_at": 1716307200,
"short_code": "000ca1b2"
}
],
"has_more": true,
"last_id": 65,
"count": 3
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `tickets` | array | 票据列表数组 |
| `has_more` | bool | 是否还有更多数据 |
| `last_id` | int | 本次返回的最后一条 ID下次请求的游标 |
| `count` | int | 本次返回数量 |
#### 使用场景
**场景 1首次加载最新票据**
```bash
GET /list?limit=20&order_by=desc
```
**响应**
```json
{
"tickets": [{id: 100, ...}, {id: 99, ...}, ...],
"has_more": true,
"last_id": 81,
"count": 20
}
```
**场景 2下拉加载更多历史数据**
```bash
GET /list?last_id=81&limit=20&order_by=desc
```
**响应**
```json
{
"tickets": [{id: 80, ...}, {id: 79, ...}, ...],
"has_more": true,
"last_id": 61,
"count": 20
}
```
**场景 3上拉刷新新数据**
```bash
GET /list?last_id=100&limit=20&order_by=asc
```
**响应**
```json
{
"tickets": [{id: 101, ...}, {id: 102, ...}],
"has_more": false,
"last_id": 102,
"count": 2
}
```
#### UniApp 集成示例
```javascript
data() {
return {
tickets: [],
lastId: 0,
hasMore: true,
loading: false
}
},
methods: {
// 首次加载
loadTickets() {
this.loading = true;
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
limit: 20,
order_by: 'desc'
},
success: (res) => {
const data = res.data.data;
this.tickets = data.tickets;
this.hasMore = data.has_more;
this.lastId = data.last_id;
},
complete: () => {
this.loading = false;
}
});
},
// 下拉加载更多
loadMore() {
if (!this.hasMore || this.loading) return;
this.loading = true;
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
last_id: this.lastId,
limit: 20,
order_by: 'desc'
},
success: (res) => {
const data = res.data.data;
this.tickets = this.tickets.concat(data.tickets);
this.hasMore = data.has_more;
this.lastId = data.last_id;
},
complete: () => {
this.loading = false;
}
});
},
// 上拉刷新(获取新票)
refresh() {
if (this.tickets.length === 0) {
this.loadTickets();
return;
}
const firstId = this.tickets[0].id;
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
last_id: firstId,
limit: 20,
order_by: 'asc' // 升序获取新数据
},
success: (res) => {
const data = res.data.data;
if (data.count > 0) {
// 新数据插入到列表前面
this.tickets = data.tickets.reverse().concat(this.tickets);
uni.showToast({ title: `新增 ${data.count} 张票` });
} else {
uni.showToast({ title: '已是最新' });
}
}
});
}
},
onLoad() {
this.loadTickets();
},
onReachBottom() {
this.loadMore();
},
onPullDownRefresh() {
this.refresh();
setTimeout(() => {
uni.stopPullDownRefresh();
}, 1000);
}
```
#### 技术优势
**vs 传统分页**
- ✅ 无缝滚动体验(无页码概念)
- ✅ 支持双向拉取(下拉历史,上拉新数据)
- ✅ 数据变化时不会重复/遗漏(基于 ID 游标)
- ✅ 性能更好(`WHERE id < ?` + `LIMIT``OFFSET` 高效)
**排序说明**
- 使用 `id` 字段排序(主键索引,性能最优)
- `id` 单调递增,确保顺序唯一性
- `issued_at` 已添加索引,支持未来按发放时间筛选
---
### 4.2 获取票详情(含 QR Payload
```
GET ...&pluginsaction=detail&id={ticket_id}
```
**参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | int | ✅ | 票 ID |
**成功响应 `data`**:
```json
{
"ticket": {
"id": 123,
"order_id": 41,
"goods_id": 456,
"goods_title": "周杰伦演唱会-北京站",
"goods_image": "https://...jpg",
"seat_info": "2026-06-01 20:00|国家体育馆|主厅|A区|A1",
"session_time": "2026-06-01 20:00",
"venue_name": "国家体育馆",
"seat_number": "A1",
"real_name": "张三",
"phone": "138****5678",
"verify_status": 0,
"verify_time": 0,
"issued_at": 1716307200,
"short_code": "000ca1b2",
"qr_payload": "eyJpZCI6MTIzLCJnIjo0NTYsImNvZGUiOiI4OTM1...",
"qr_expires_at": 1716309000,
"qr_expires_in": 1800
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `qr_payload` | string | 二维码内容,含 HMAC-SHA256 签名,有效期 30 分钟 |
| `qr_expires_at` | int | 过期时间戳(秒) |
| `qr_expires_in` | int | 剩余有效期(秒),固定 1800 |
**失败响应**:
```json
// 票不存在或无权访问
{ "code": -404, "msg": "票不存在或无权访问", "data": [] }
// 票已核销(不返回 QR
{ "code": -2, "msg": "该票已核销", "data": [] }
// 票已退款
{ "code": -3, "msg": "该票已退款", "data": [] }
```
---
### 4.3 强制刷新二维码
重新生成 QR Payload重置 30 分钟有效期。
```
GET ...&pluginsaction=refreshQr&id={ticket_id}
```
**参数**: 同 `detail`
**成功响应**: 与 `detail` 完全一致(`qr_payload` 为新生成)。
---
### 4.4 检测核销员身份
轻量接口,用于前端快速判断是否展示核销员入口。
```
GET ...&pluginsaction=checkVerifier
```
**成功响应 `data`**:
```json
{
"is_verifier": true,
"verifier_id": 3,
"verifier_name": "张三"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `is_verifier` | bool | 是否为授权核销员 |
| `verifier_id` | int | 核销员 ID未授权时为 `0` |
| `verifier_name` | string | 核销员名称,未授权时为空字符串 |
---
## 五、核销 APIUniApp 授权核销员专用)
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=verify`
> **认证**: C 端登录态 **+** 必须是 `vr_verifiers` 表中 `status=1` 的授权核销员
### 5.1 扫码核销
```
POST ...&pluginsaction=verify
Content-Type: application/x-www-form-urlencoded
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `ticket_code` | string | ✅ | 扫码得到的票码(短码或 UUID |
**自动识别规则**:
- 长度 < 20 且不含连字符 `-` 短码(Base36 + Feistel 混淆)
- 包含连字符 `-` → UUID v4 长码
**成功响应** (`code: 0`):
```json
{
"code": 0,
"msg": "核销成功",
"data": {
"seat_info": "2026-06-01 20:00|国家体育馆|主厅|A区|A1",
"real_name": "张三",
"goods_name": "周杰伦演唱会-北京站"
}
}
```
**失败响应**:
| code | msg | 场景 |
|------|-----|------|
| `-1` | `票码不存在` | 短码解码失败或票不存在 |
| `-2` | `该票已核销` | 重复核销 |
| `-3` | `该票已退款` | 票已退款 |
| `-403` | `你不是授权核销员,无权核销` | C 端用户不在核销员白名单 |
| `401` | `请先登录` | 未登录 |
| `-999` | `核销失败,请重试` | 数据库事务异常 |
---
## 六、核销记录 APIUniApp 授权核销员专用)
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=myVerifications`
> **认证**: C 端登录态 **+** 必须是 `vr_verifiers` 表中 `status=1` 的授权核销员
### 6.1 我的核销记录
```
GET ...&pluginsaction=myVerifications
```
**请求参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `page` | int | ❌ | `1` | 页码 |
| `page_size` | int | ❌ | `20` | 每页条数(最小 1最大 100 |
**成功响应 `data`**:
```json
{
"list": [
{
"id": 1,
"ticket_id": 123,
"ticket_code": "8935b3a3-a7b4-4e3d-8c1f-9b7e2a6f5d4c",
"seat_info": "2026-06-01 20:00|国家体育馆|主厅|A区|A1",
"real_name": "张三",
"goods_title": "周杰伦演唱会-北京站",
"created_at": 1716307200
}
],
"total": 50,
"page": 1,
"page_size": 20,
"pages": 3
}
```
**失败响应**:
| code | msg | 场景 |
|------|-----|------|
| `-403` | `你不是授权核销员` | 非授权核销员 |
| `401` | `请先登录` | 未登录 |
---
## 七、QR 二维码前端最佳实践
### 7.1 动态刷新策略
1. **倒计时渲染**:使用 `qr_expires_in` 启动本地倒计时器
2. **过期遮罩**:剩余时间 ≤ 0 时显示模糊/遮罩 + "点击刷新"按钮
3. **静默刷新**:过期前 30 秒自动调用 `refreshQr` 重新获取并渲染
### 7.2 短码编码规则
短码结构:`【4位 goods_id 明文 base36】【变长 ticket_id 混淆 base36】`
- 解码 O(1):前 4 位直接取 goods_id剩余部分用 Feistel 解密得到 ticket_id
- 示例:`000ca1b2` → `goods_id=0`, `ticket_id=12345`
- 核销员 App 扫描时应获取完整短码字符串进行提交
---
## 八、变更日志与 Bug 修复
### v1.2.0 (2026-06-22)
#### 新增功能
1. **订单筛选支持**
- `order_id`: 单订单精准筛选
- `order_ids`: 批量订单筛选(逗号分隔)
- `goods_id`: 商品筛选
- `status`: 核销状态筛选
2. **分页支持**
- `page`: 页码控制
- `page_size`: 每页数量1-100
- 响应增加 `total`, `page`, `page_size`, `pages` 字段
3. **响应格式优化**
- 票据对象增加 `order_id` 字段,便于前端业务关联
#### Bug 修复
**🐛 Bug #1: 查询条件重置Critical**
- **现象**: 传入筛选参数后,接口返回全表数据,未生效任何筛选条件(包括 `user_id`),存在跨用户数据暴露风险。
- **根因**: `WalletService::getUserTicketsPaginated` 中,构建完 Query Builder 实例后执行 `$db->count()`。在 ThinkPHP ORM 机制下,终端查询方法(如 `count()`)执行完成后会清空当前 query 实例中的 `where` 条件。导致后续 `$db->select()` 在没有任何 where 约束的情况下执行了全表查询。
- **解决**: 放弃复用 Query Builder 实例,改用 `$where` 数组存储条件,在 `count()``select()` 中分别独立传入执行,彻底避免状态污染与越权隐患。
**🐛 Bug #2: page_size 最小值限制**
- **现象**: 传入 `page_size=1` 却被后端强制改为 `page_size=10`
- **根因**: API 控制器中使用了 `max(10, intval(input('page_size', 20)))`,导致最小值被强制锁死为 10。
- **解决**: 将最小值限制修改为 `max(1, ...)`,允许客户端根据实际需求精准获取单条数据。
---
## 附录:数据字典
### vr_tickets 电子票表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | 票 ID自增 |
| `goods_id` | int | 关联商品 ID |
| `order_id` | int | 关联订单 ID |
| `user_id` | int | 购票用户 ID |
| `ticket_code` | string | UUID v4 长码 |
| `qr_data` | string | 格式 `短码\|payload`,内部缓存 |
| `seat_info` | string | `场次\|场馆\|演播室\|分区\|座位号` |
| `verify_status` | int | `0`=未核销 `1`=已核销 `2`=已退款 |
| `verify_time` | int | 核销时间戳 |
| `verifier_id` | int | 执行核销的核销员 ID |
| `real_name` | string | 观演人姓名 |
| `phone` | string | 观演人手机 |
| `issued_at` | int | 票发放时间戳 |
| `created_at` | int | 创建时间戳 |
### vr_verifiers 核销员表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | 核销员 ID自增 |
| `user_id` | int | 关联的 C 端用户 IDShopXO 会员 ID |
| `name` | string | 核销员名称(后台显示用) |
| `status` | int | `1`=启用 `0`=禁用 |
| `created_at` | int | 创建时间戳 |
> **注意**: `user_id` 关联的是 C 端用户 ID而非后台管理员 ID。C 端用户只需在 `vr_verifiers` 表中存在且 `status=1` 即可使用 UniApp 核销功能。
### vr_verifications 核销记录表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | 记录 ID自增 |
| `ticket_id` | int | 票 ID |
| `ticket_code` | string | 票长码快照 |
| `verifier_id` | int | 执行核销的核销员 ID |
| `verifier_name` | string | 核销员名称快照 |
| `goods_id` | int | 商品 ID |
| `created_at` | int | 核销时间戳 |