vr-shopxo-plugin/docs/03_VERIFICATION_SYSTEM.md

22 KiB
Raw Permalink Blame History

核销系统设计

调研时间2026-04-14 关键参考:realstore/check/check.vue(自提点核销页)、sxo_order_extraction_code


一、系统概述

核销系统解决:用户持电子票 QR 码 → 工作人员验证 → 标记已入场。

1.1 核销模式

模式 说明 适用场景
扫码核销 B 端扫用户 QR 码 演唱会入口、活动签到
手动输入 B 端输入票码 网络不稳定场景
双重核销 用户扫码 + B 端扫码 高安全要求

1.2 核销粒度

粒度 说明 实现难度
按订单 一个订单一个码 最简单
按座位 每个座位一个码 推荐

推荐:按座位核销(每人一个 QR 码),与演唱会场景完全匹配。


二、QR 票生成

2.1 触发时机

支付成功回调时plugins_service_buy_order_insert_success 钩子)

2.2 QR 码内容设计

{
  "id": 12345,           // vr_attendees.id
  "code": "UUID-v4",     // ticket_code唯一标识
  "event_id": 8,
  "session_id": 15,
  "seat": "A区-3排-15座",
  "exp": 1735689600      // 过期时间(时间戳)
}

2.3 加密方式

不加密(明文 QR

  • 优点:调试方便,可人工识别
  • 缺点:可伪造
  • 适用:低安全要求、内部活动

加密 QR(推荐):

$qr_data = json_encode([...]);
$encrypted = base64_encode(
    openssl_encrypt($qr_data, 'AES-256-CBC', $secret_key, OPENSSL_RAW_DATA, $iv)
);

核销时解密验证:

$decrypted = openssl_decrypt(
    base64_decode($encrypted), 'AES-256-CBC',
    $secret_key, OPENSSL_RAW_DATA, $iv
);

2.4 QR 码生成

使用 ShopXO 内置 \base\Qrcode 类:

// 生成展示用 QR 码 URL
$ticket_code = $attendee->ticket_code;
$qr_url = MyUrl('index/qrcode/index', [
    'content' => urlencode(base64_encode($ticket_code)),
    'size'   => 8,
    'level'  => 'H',  // 高容错率,扫码成功率高
    'mr'     => 2,
]);

// 生成文件(用于发送邮件/消息)
$qr_path = (new \base\Qrcode())->Create([
    'content'  => $ticket_code,
    'path'     => 'static/upload/tickets/' . date('Y/md') . '/',
    'filename' => $ticket_code . '.png',
    'level'    => 'H',
    'size'     => 10,
    'mr'       => 2,
]);

三、数据存储

3.1 扩展方案 vs 独立表

方案 Asxo_order.extension_data

{
  "item_type": "ticket",
  "event_id": 8,
  "session_id": 15,
  "tickets": [
    {
      "attendee_id": 101,
      "ticket_code": "uuid-xxx",
      "seat": "A区-3排-15座",
      "verify_status": 0,
      "verify_time": null
    },
    {
      "attendee_id": 102,
      "ticket_code": "uuid-yyy",
      "seat": "A区-3排-16座",
      "verify_status": 0,
      "verify_time": null
    }
  ]
}

优点:无需新建表,查询方便 缺点JSON 更新需要整体替换

方案 B新建 vr_tickets(推荐)

CREATE TABLE `vr_tickets` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `order_id` int UNSIGNED NOT NULL COMMENT '订单ID',
  `order_no` char(60) NOT NULL COMMENT '订单号',
  `goods_id` int UNSIGNED NOT NULL COMMENT '商品ID',
  `user_id` int UNSIGNED NOT NULL COMMENT '用户ID',
  `event_id` int UNSIGNED NOT NULL COMMENT '活动ID',
  `session_id` int UNSIGNED NOT NULL COMMENT '场次ID',
  `ticket_code` char(36) NOT NULL COMMENT '票码(UUID)',
  `qr_data` text COMMENT '加密QR内容',
  `seat_info` varchar(255) COMMENT '座位信息',
  `real_name` varchar(60) COMMENT '观演人姓名',
  `id_card` char(20) COMMENT '身份证号',
  `phone` char(15) COMMENT '手机号',
  `verify_status` tinyint DEFAULT 0 COMMENT '核销状态0未核销, 1已核销',
  `verify_time` int UNSIGNED DEFAULT 0 COMMENT '核销时间',
  `verifier_id` int UNSIGNED DEFAULT 0 COMMENT '核销员ID',
  `issued_at` int UNSIGNED DEFAULT 0 COMMENT '发放时间',
  `created_at` int UNSIGNED DEFAULT 0,
  `updated_at` int UNSIGNED DEFAULT 0,
  UNIQUE KEY `ticket_code` (`ticket_code`),
  KEY `order_id` (`order_id`),
  KEY `user_id` (`user_id`),
  KEY `event_id` (`event_id`),
  KEY `session_id` (`session_id`),
  KEY `verify_status` (`verify_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='VR电子票表';

3.2 核销记录表

CREATE TABLE `vr_verifications` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `ticket_id` int UNSIGNED NOT NULL COMMENT '票ID',
  `ticket_code` char(36) NOT NULL COMMENT '票码',
  `verifier_id` int UNSIGNED NOT NULL COMMENT '核销员ID',
  `verifier_name` varchar(60) COMMENT '核销员名称',
  `event_id` int UNSIGNED COMMENT '活动ID',
  `session_id` int UNSIGNED COMMENT '场次ID',
  `ip_address` varchar(45) COMMENT '核销IP',
  `location` varchar(255) COMMENT '核销地点备注',
  `created_at` int UNSIGNED DEFAULT 0,
  KEY `ticket_id` (`ticket_id`),
  KEY `verifier_id` (`verifier_id`),
  KEY `created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='核销记录表';

3.3 核销员表

CREATE TABLE `vr_verifiers` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `user_id` int UNSIGNED NOT NULL COMMENT '关联用户IDShopXO user.id',
  `name` varchar(60) NOT NULL COMMENT '核销员名称',
  `mobile` char(15) COMMENT '手机号',
  `event_ids` varchar(255) COMMENT '可核销的活动ID列表逗号分隔',
  `status` tinyint DEFAULT 1 COMMENT '状态0禁用, 1启用',
  `created_at` int UNSIGNED DEFAULT 0,
  KEY `user_id` (`user_id`),
  KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='核销员表';

四、B 端核销页面

4.1 参考实现

直接 fork pages/plugins/realstore/check/check.vue

4.2 页面 UI 设计

<template>
  <view class="page padding-main">
    <!-- 标题栏 -->
    <view class="verify-header bg-white padding radius">
      <view class="fw-b text-size-lg">VR演唱会票务核销</view>
      <view class="cr-grey text-size-sm margin-top-xs">
        当前活动{{ current_event?.name || '全部活动' }}
      </view>
    </view>

    <!-- 扫码/输入区 -->
    <view class="verify-input bg-white padding radius margin-top">
      <view class="flex-row align-c">
        <!-- #ifndef H5 -->
        <view class="scan-btn" @tap="scan_event">
          <uni-icons type="scan" size="56rpx" color="#2196F3"></uni-icons>
        </view>
        <!-- #endif -->
        <input
          type="text"
          class="flex-1 margin-left"
          placeholder="扫描二维码或输入核销码"
          v-model="check_value"
          @confirm="verify_submit"
        />
      </view>
    </view>

    <!-- 提交按钮 -->
    <view class="padding margin-top">
      <button
        type="primary"
        :loading="verify_loading"
        :disabled="!check_value || verify_loading"
        @tap="verify_submit"
      >{{ verify_loading ? '核销中...' : '确认核销' }}</button>
    </view>

    <!-- 结果展示 -->
    <view class="result-area padding margin-top">
      <!-- 成功 -->
      <view v-if="result_type === 'success'" class="success-box">
        <uni-icons type="checkmark-filled" size="60" color="#4CAF50"></uni-icons>
        <view class="fw-b text-size-xl margin-top">核销成功</view>
        <view class="margin-top">
          <view>活动:{{ result.event_name }}</view>
          <view>场次:{{ result.session_time }}</view>
          <view>座位:{{ result.seat_info }}</view>
          <view>观演人:{{ result.real_name }}</view>
        </view>
      </view>

      <!-- 失败 -->
      <view v-else-if="result_type === 'error'" class="error-box">
        <uni-icons type="close-filled" size="60" color="#F44336"></uni-icons>
        <view class="fw-b text-size-xl margin-top">核销失败</view>
        <view class="cr-red margin-top">{{ error_msg }}</view>
      </view>
    </view>

    <!-- 统计栏 -->
    <view class="stats-bar fixed-bottom padding">
      <view class="flex-row jc-sb">
        <view class="stat-item">
          <view class="cr-grey text-size-xs">今日核销</view>
          <view class="fw-b text-size-xl cr-green">{{ stats.today }}</view>
        </view>
        <view class="stat-item">
          <view class="cr-grey text-size-xs">待核销</view>
          <view class="fw-b text-size-xl cr-yellow">{{ stats.pending }}</view>
        </view>
        <view class="stat-item">
          <view class="cr-grey text-size-xs">已核销</view>
          <view class="fw-b text-size-xl">{{ stats.verified }}</view>
        </view>
      </view>
    </view>
  </view>
</template>

4.3 API 调用

verify_submit() {
    if (!this.check_value) return;

    uni.showLoading({ title: '核销中...' });
    this.verify_loading = true;

    uni.request({
        url: app.globalData.get_request_url('verify', 'ticket', 'vrticket'),
        method: 'POST',
        data: {
            ticket_code: this.check_value,
            event_id: this.current_event?.id || 0,
        },
        success: (res) => {
            uni.hideLoading();
            if (res.data.code == 0) {
                this.result_type = 'success';
                this.result = res.data.data;
                this.stats.today++;
                this.stats.pending--;
            } else {
                this.result_type = 'error';
                this.error_msg = res.data.msg;
            }
            // 清空输入框,支持连续扫描
            this.check_value = '';
        },
        fail: () => {
            uni.hideLoading();
            this.result_type = 'error';
            this.error_msg = '网络错误,请重试';
        },
        complete: () => {
            this.verify_loading = false;
        }
    });
}

五、后端核销 API

5.1 接口定义

POST /?s=admin/vrticket/verify
Content-Type: application/json

{
    "ticket_code": "uuid-xxx",  // 票码
    "event_id": 8              // 活动ID可选
}

5.2 返回格式

成功

{
    "code": 0,
    "msg": "核销成功",
    "data": {
        "ticket_id": 101,
        "ticket_code": "uuid-xxx",
        "event_name": "周杰伦2026巡回演唱会",
        "session_time": "2026-06-01 20:00",
        "seat_info": "A区-3排-15座",
        "real_name": "张三",
        "verify_time": 1745328000
    }
}

失败

{
    "code": -1,
    "msg": "该票已核销"
}

5.3 核销逻辑实现

// app/plugins/vr_ticket/service/TicketService.php

public static function VerifyTicket($ticket_code, $verifier_id, $event_id = 0)
{
    // 1. 查询票
    $ticket = Db::name('vr_tickets')
        ->where('ticket_code', $ticket_code)
        ->find();
    if (!$ticket) {
        return DataReturn('票码不存在', -1);
    }

    // 2. 检查活动匹配
    if ($event_id > 0 && $ticket['event_id'] != $event_id) {
        return DataReturn('该票不属于当前活动', -1);
    }

    // 3. 检查是否已核销
    if ($ticket['verify_status'] == 1) {
        return DataReturn('该票已核销(' . date('Y-m-d H:i', $ticket['verify_time']) . '', -1);
    }

    // 4. 解密验证(如果加密了)
    if (!empty($ticket['qr_data'])) {
        $decrypted = openssl_decrypt(
            base64_decode($ticket['qr_data']),
            'AES-256-CBC',
            MyC('vrticket_secret_key'),
            OPENSSL_RAW_DATA,
            substr(md5($ticket['ticket_code']), 0, 16)
        );
        $qr_content = json_decode($decrypted, true);

        // 检查过期
        if (!empty($qr_content['exp']) && $qr_content['exp'] < time()) {
            return DataReturn('票已过期', -1);
        }
    }

    // 5. 执行核销(事务)
    Db::startTrans();
    try {
        // 更新票状态
        Db::name('vr_tickets')->where('id', $ticket['id'])->update([
            'verify_status' => 1,
            'verify_time'  => time(),
            'verifier_id'  => $verifier_id,
            'updated_at'   => time(),
        ]);

        // 写入核销记录
        Db::name('vr_verifications')->insert([
            'ticket_id'     => $ticket['id'],
            'ticket_code'   => $ticket_code,
            'verifier_id'  => $verifier_id,
            'verifier_name' => self::GetVerifierName($verifier_id),
            'event_id'     => $ticket['event_id'],
            'session_id'   => $ticket['session_id'],
            'created_at'   => time(),
        ]);

        Db::commit();
    } catch (\Exception $e) {
        Db::rollback();
        return DataReturn('核销失败:' . $e->getMessage(), -1);
    }

    // 6. 返回票信息
    $ticket['verify_status'] = 1;
    $ticket['verify_time'] = time();
    return DataReturn('核销成功', 0, self::FormatTicketInfo($ticket));
}

六、C 端票夹(用户查看已购票)

6.1 页面入口

通过用户中心钩子注入:plugins_view_user_various_inside_top

6.2 页面内容

显示用户所有已支付订单中的票务商品,每张票一行:

┌─────────────────────────────────────┐
│ 🎵 周杰伦2026巡回演唱会              │
│ 📅 2026-06-01 20:00                 │
│ 📍 国家体育馆                        │
│ 💺 A区-3排-15座                     │
│                                     │
│  ┌─────────┐   状态:               │
│  │ QR CODE │   ✅ 已核销 / ⏳ 待使用 │
│  └─────────┘                       │
└─────────────────────────────────────┘

6.3 核销状态实时更新(可选)

如果需要实时更新核销状态:

  • 方案 A推荐用户进入票夹时刷新状态
  • 方案 BWebSocket 推送ShopXO 无内置 WebSocket
  • 方案 C页面可见时通过 onShow 刷新

七、部署形态

7.1 B 端核销页面部署

形态 说明 推荐度
uni-app B 端插件页 pages/plugins/vr-ticket-verify/ 最佳
ShopXO H5 管理后台 /admin/ticket-verify/ ⚠️ 个人小程序无法内嵌
独立微信小程序 单独注册 B 端小程序 ⚠️ 需额外资质

7.2 个人主体小程序限制

  • 禁止 web-view无法内嵌 H5
  • 可以新建另一个小程序(独立 AppID
  • 可以用 uni.scanCode 做纯小程序核销页

7.3 推荐方案

演唱会现场:用 ShopXO 的 uni-app 新建 vr-ticket-verify 插件页面。工作人员用自己的微信账号(授权为核销员)登录 ShopXO 小程序,访问核销页面。

部署

  1. vr-ticket-verify 作为 ShopXO uni-app 的插件发布
  2. 工作人员在小程序中访问该页面
  3. 手机对着票 QR 码扫描

八、核销统计

8.1 实时统计 API

GET /?s=admin/vrticket/stats&event_id=8

返回:

{
    "code": 0,
    "data": {
        "total_tickets": 500,
        "verified": 320,
        "pending": 180,
        "today": 45
    }
}

8.2 核销大屏

可在演唱会入口放置一个大屏电视,显示实时核销进度:

  • 总票数 / 已核销数 / 待核销数
  • 每分钟核销速度
  • 最近核销记录滚动列表

实现方式:轮询 stats API + 数字动画CountUp.js


九、防超卖机制

本章节为架构性补充,是编码前的 阻断性要求。 核销的完整性依赖于:只有合法持票人能核销,且每张票只能核销一次。

9.1 购票时序与座位锁定

座位锁定贯穿整个购票流程,分三阶段:

[阶段1: 选座]          [阶段2: 提交订单]         [阶段3: 支付成功]
用户选座 → 前端展示     提交订单 → 后端锁座        支付回调 → 出票确认
        ↓                      ↓                        ↓
   前端乐观锁          Redis/Db悲观锁              释放锁/永久锁定
   (前端禁止选已选座)    (禁止其他人选同座)           (vr_tickets写入)

阶段1 — 前端乐观锁

  • 用户选座时,前端实时请求 GET /?s=api/session/seats?session_id=X 获取已锁定座位列表
  • 已被锁或已售座位在座位图上置灰CSS pointer-events: none
  • 纯前端锁无法防并发攻击,必须配合后端锁

阶段2 — 后端悲观锁(关键) 用户点击"提交订单"时,后端在事务内完成:

Db::startTrans();
try {
    // ① 检查座位是否已被锁定(检查 vr_seat_locks 表)
    $locked = Db::name('vr_seat_locks')
        ->where('session_id', $session_id)
        ->where('seat_code', $seat_code)
        ->where('expire_at', '>', time())
        ->find();
    if ($locked) {
        Db::rollback();
        return DataReturn('座位已被锁定,请重新选择', -1);
    }

    // ② 原子扣减库存vr_sessions.stock - 1
    $affected = Db::name('vr_sessions')
        ->where('id', $session_id)
        ->where('stock', '>', 0)   // 原子条件:库存 > 0
        ->dec('stock')
        ->update();
    if (!$affected) {
        Db::rollback();
        return DataReturn('库存不足', -1);
    }

    // ③ 写入座位锁15分钟过期与订单超时一致
    Db::name('vr_seat_locks')->insert([
        'session_id' => $session_id,
        'seat_code'  => $seat_code,
        'order_no'   => $order_no,
        'user_id'    => $user_id,
        'locked_at'  => time(),
        'expire_at'  => time() + 900,   // 15分钟
    ]);

    Db::commit();
} catch (\Exception $e) {
    Db::rollback();
    return DataReturn('系统错误:' . $e->getMessage(), -1);
}

阶段3 — 支付成功出票

  • 支付回调触发后,TicketService::OnOrderPaid()vr_seat_locks 中的锁转为永久票
  • 锁记录 status 标记为 confirmedexpire_at 设为 NULL
  • 如果用户15分钟内未支付锁自动过期定时任务或懒检查

9.2 座位锁表设计

CREATE TABLE `vr_seat_locks` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `session_id` int UNSIGNED NOT NULL COMMENT '场次ID',
  `seat_code` char(20) NOT NULL COMMENT '座位编码(如 A-3-15',
  `order_no` char(60) NOT NULL COMMENT '关联订单号',
  `user_id` int UNSIGNED NOT NULL COMMENT '锁定用户ID',
  `status` tinyint DEFAULT 0 COMMENT '状态0锁定中, 1已确认, 2已取消',
  `locked_at` int UNSIGNED NOT NULL COMMENT '锁定时间',
  `expire_at` int UNSIGNED COMMENT '过期时间NULL=永久)',
  UNIQUE KEY `session_seat` (`session_id`, `seat_code`),
  KEY `order_no` (`order_no`),
  KEY `expire_at` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='座位锁表';

关键设计点

  • UNIQUE KEY (session_id, seat_code):数据库层强制唯一,避免并发插入两条锁
  • expire_at 字段NULL = 永久锁定(已支付),有值 = 临时锁(未支付)
  • 定时任务每分钟清理过期锁并回补库存:
    -- 清理过期锁并回补库存
    UPDATE vr_sessions s
    JOIN vr_seat_locks l ON s.id = l.session_id
    SET s.stock = s.stock + 1, l.status = 2  -- 2=已取消
    WHERE l.status = 0 AND l.expire_at < UNIX_TIMESTAMP();
    

9.3 并发控制策略

方案 A数据库乐观锁推荐用于低并发场景

// vr_sessions 表加 version 字段,每次更新自增
Db::name('vr_sessions')
    ->where('id', $session_id)
    ->where('stock', '>=', 1)
    ->where('version', $current_version)  // 乐观锁
    ->dec('stock')
    ->inc('version')
    ->update();
  • 优点:无需额外锁资源
  • 缺点:并发高时大量重试,响应延迟

方案 BRedis 分布式锁(推荐用于高并发演唱会抢票)

$lock_key = "seat_lock:{$session_id}:{$seat_code}";
$lock = Cache::store('redis')->set($lock_key, $order_no, ['nx', 'px' => 15000]);
if (!$lock) {
    return DataReturn('座位已被锁定', -1);
}
try {
    // 执行业务逻辑...
} finally {
    Cache::store('redis')->rm($lock_key);
}
  • 优点:高性能,支持过期自动释放
  • 缺点:需要 Redis锁粒度细每个座位一把锁

ShopXO 环境建议ShopXO 默认不带 Redis如需可装低峰期用 MySQL 悲观锁方案A变体足够;如预判高并发(万人演唱会开票),建议额外引入 Redis。

9.4 核销时的超卖防御

核销阶段不存在超卖(每张票只能核销一次),但需防御:

  1. 幂等核销vr_tickets.verify_status = 1 是唯一性约束,重复核销返回"已核销"而非报错
  2. 事务隔离VerifyTicket() 在事务内完成status 更新和记录写入原子执行
  3. QR 时效exp 字段确保过期票不会被核销(即使数据库状态异常)
  4. 核销员权限:核销前验证 verifier_id 是否在 vr_verifiers 表中且 status = 1

9.5 API 路径统一说明

路由 权限验证 说明
C 端(票夹/扫码页) /?s=api/ticket/verify 用户登录态 用户查自己票状态
B 端(核销人员) /?s=admin/vrticket/verify Admin 登录态 + 核销员白名单 核销人员扫码验证

Vue 页面使用 app.globalData.get_request_url('verify', 'ticket', 'vrticket') 生成的是 C 端 API,需在 B 端核销页改为 admin 命名空间路径,或新建独立 B 端核销 API。

9.6 AES IV 设计说明

AES-256-CBC 使用 IV = substr(MD5(ticket_code), 0, 16) 而非随机 IV 的原因:

设计意图

  • ticket_code 是 UUID-v4每次生成票码时独立随机产生
  • MD5(ticket_code) 是 ticket_code 的确定性函数:同一 ticket_code 永远映射到同一 IV
  • 解密方只需知道 ticket_code从 URL 参数传入或扫码获取)即可还原 IV无需额外传输 IV 值

安全性评估

  • ticket_code 的熵足够122位随机数MD5 的输出是确定的但不可预测(单向函数)
  • 攻击者不知道 ticket_code 无法派生正确 IV暴力破解 MD5 不现实
  • 这是 ticket-bound IV 模式,在票码系统场景下是合理设计

如需更高安全:使用随机 IV 并附加在密文头部(ciphertext = IV || ciphertextShopXO 无需修改即可兼容。