# 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 | 核销员名称,未授权时为空字符串 | --- ## 五、核销 API(UniApp 授权核销员专用) > **基础路由**: `/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` | `核销失败,请重试` | 数据库事务异常 | --- ## 六、核销记录 API(UniApp 授权核销员专用) > **基础路由**: `/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 端用户 ID(ShopXO 会员 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 | 核销时间戳 |