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

15 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