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

19 KiB
Raw Blame 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 结构:

{
  "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传统页面

若均未找到,返回:

{
  "code": 401,
  "msg": "请先登录",
  "data": []
}

2.2 核销员身份鉴权

以下 API 除了要求 C 端登录外,还需验证用户是否为授权核销员:

// 未授权(非核销员)
{
  "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

{
  "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订单详情页 - 查看当前订单的所有票据

// 优化前(低效)- 全量拉取后前端过滤
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票夹页 - 分页加载

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批量订单查询

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 > 0limit 参数时自动启用瀑布流模式
  • 否则使用传统分页模式(向后兼容)

新增参数

参数 类型 必填 默认值 说明
last_id int 0 游标 ID0 表示首次加载
limit int 20 每次拉取数量(最小 1最大 100
order_by string desc 排序方向:desc(降序,历史方向)/ asc(升序,新数据方向)

响应格式

{
  "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首次加载最新票据

GET /list?limit=20&order_by=desc

响应

{
  "tickets": [{id: 100, ...}, {id: 99, ...}, ...],
  "has_more": true,
  "last_id": 81,
  "count": 20
}

场景 2下拉加载更多历史数据

GET /list?last_id=81&limit=20&order_by=desc

响应

{
  "tickets": [{id: 80, ...}, {id: 79, ...}, ...],
  "has_more": true,
  "last_id": 61,
  "count": 20
}

场景 3上拉刷新新数据

GET /list?last_id=100&limit=20&order_by=asc

响应

{
  "tickets": [{id: 101, ...}, {id: 102, ...}],
  "has_more": false,
  "last_id": 102,
  "count": 2
}

UniApp 集成示例

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 < ? + LIMITOFFSET 高效)

排序说明

  • 使用 id 字段排序(主键索引,性能最优)
  • id 单调递增,确保顺序唯一性
  • issued_at 已添加索引,支持未来按发放时间筛选

4.2 获取票详情(含 QR Payload

GET ...&pluginsaction=detail&id={ticket_id}

参数:

参数 类型 必填 说明
id int 票 ID

成功响应 data:

{
  "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

失败响应:

// 票不存在或无权访问
{ "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:

{
  "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):

{
  "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:

{
  "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
  • 示例:000ca1b2goods_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 核销时间戳