# vr-shopxo-plugin 代码深度审议报告(Round 2 终稿) > 审议人:BackendArchitect > 日期:2026-04-15 > 审议范围:vr_ticket 插件全部核心代码(EventListener.php、TicketService.php、BaseService.php、ticket_detail.html、001_vr_tables.sql、admin/controllers、plugin.json) > 视角:Backend Architect / PHP / 数据库 / 架构完整性 / 并发安全 --- ## 执行摘要 vr-shopxo-plugin 是一个基于 ShopXO 扩展的票务插件,功能链路覆盖:座位模板管理 → 用户选座购票 → 订单支付 → 电子票发放 → QR 码核销。经过逐文件审议,共发现**5 个严重问题、7 个中等风险、4 个轻微缺陷、5 项改进建议**。 本报告与 SecurityEngineer 的安全审计报告高度互补——两者均独立识别了 `onOrderPaid` 幂等性缺失、`verifyTicket` TOCTOU 竞态、`|raw` XSS、QR 密钥硬编码回退等严重问题。本报告在此基础上补充了**数据库 Schema 规范性**、**Admin 接口鉴权缺口**、**座位超卖机制缺失**等架构层面的深度分析。 --- ## 一、插件架构(EventListener.php / plugin.json) ### 1.1 Enable/Disable 生命周期钩子完全缺失 ⚠️ 严重 **文件:** `EventListener.php` / `plugin.json` ShopXO 插件规范定义了完整的生命周期钩子,但当前实现仅覆盖 install 和 upgrade: | 钩子函数 | 状态 | 说明 | |---|---|---| | `vr_ticket_install()` | ✅ 已实现 | 建表、添加 item_type 字段 | | `vr_ticket_uninstall()` | ⚠️ 空实现 | 仅 return true,数据不清也不删 | | `vr_ticket_upgrade()` | ⚠️ 空实现 | 无版本迁移框架 | | `vr_ticket_enable()` | ❌ 缺失 | 插件启用时无响应 | | `vr_ticket_disable()` | ❌ 缺失 | 插件停用时无响应 | **影响:** - 启用插件后菜单/权限可能重复注册(重启 ShopXO 后) - 停用插件后 `vr_tickets` 等表数据残留在数据库,但插件状态不可见 - `plugin.json` 中的 `menus` 注册依赖 ShopXO 自动加载,但无显式 enable/disable 控制 ### 1.2 `plugins_service_order_delete_success` 钩子声明但未实现 ⚠️ 中等 **文件:** `plugin.json:23-24` ```json "hooks": [ "plugins_service_order_pay_success_handle_end", "plugins_service_order_delete_success" // 声明了但无处理函数 ] ``` `EventListener.php` 中没有 `vr_ticket_order_delete()` 或类似函数。订单删除后,`vr_tickets` 表中的票记录仍保留(状态不变),导致: - 已删除订单的票仍可被核销入场 - `vr_tickets.order_id` 成为孤儿记录,关联查询失效 ### 1.3 ALTER TABLE 兼容性判断逻辑错误 ⚠️ 中等 **文件:** `EventListener.php:100-103` ```php $cols = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'"); if (empty($cols)) { $db->query("ALTER TABLE `{$prefix}goods` ADD COLUMN `item_type` ..."); } ``` `$db->query()` 在 ShopXO 中返回的是结果集对象(PDOStatement 或 mysqli_result),而非布尔值。`empty($cols)` 对对象始终返回 `false`,**导致条件永不成立,`ALTER TABLE` 永远不会被执行**。也就是说 `item_type` 字段实际上从未被添加到 goods 表,`isTicketGoods()` 的第二条件 `($goods['item_type'] ?? '') === 'ticket'` 永远无法触发。 实际应改为: ```php $col_exists = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'")->rowCount() > 0; if (!$col_exists) { ... } ``` ### 1.4 Upgrade 框架缺失 ⚠️ 建议 `vr_ticket_upgrade($old_version)` 为空实现。当前版本号 `1.0.0` 写死在 plugin.json,若未来需要: - 新增 `refund_status` 字段 - 修改 QR payload 结构 - 拆分 `seat_map` JSON schema 没有任何迁移路径。建议建立 `vr_plugin_versions` 表或迁移脚本目录。 --- ## 二、票务核心(TicketService.php / BaseService.php) ### 2.1 `onOrderPaid()` 无幂等性保护,可导致重复发票 ⚠️ 严重 **文件:** `TicketService.php:23-68` ```php public static function onOrderPaid($params = []) { $order_id = $params['business_id'] ?? ($params['business_ids'][0] ?? 0); // ... 无任何幂等检查 ... foreach ($order_goods as $og) { $ticket_id = self::issueTicket($order, $og); } } ``` ShopXO 的 `plugins_service_order_pay_success_handle_end` 钩子通过 HTTP 请求触发。在以下场景中,同一订单会触发多次 `onOrderPaid`: 1. **支付渠道重试机制**:微信/支付宝网关在未收到回调确认时会重复发送通知 2. **用户多设备操作**:同一用户在手机和 PC 端同时查看订单状态 3. **ShopXO 多实例部署**:Nginx 负载均衡下两个 PHP-FPM 进程同时处理同一通知 **攻击后果**:同一张票可以被生成多次(`ticket_code` 不同,但 order_id + spec_base_id 相同),每张票都可独立入场核销,实际等同于**免费多次入场**。 **修复方案:** ```php // 在 foreach 前增加幂等锁 $existing_tickets = \Db::name(BaseService::table('tickets')) ->where('order_id', $order['id']) ->column('spec_base_id', 'id'); if (!empty($existing_tickets)) { BaseService::log('onOrderPaid: already issued, skipping', ['order_id' => $order_id], 'info'); return true; } // 已发放则跳过,未发放则继续发放 ``` ### 2.2 `verifyTicket()` TOCTOU 竞态条件 ⚠️ 严重 **文件:** `TicketService.php:138-196` ```php // Step 1: 读取票状态 $ticket = \Db::name(BaseService::table('tickets')) ->where('ticket_code', $ticket_code) ->find(); // Step 2: 判断状态(检查) if ($ticket['verify_status'] == 1) { return ... } // Step 3: 更新状态 \Db::name(BaseService::table('tickets')) ->where('id', $ticket['id']) ->update(['verify_status' => 1, 'verifier_id' => $verifier_id, ...]); ``` 这是经典的 **Time-of-Check to Time-of-Use (TOCTOU)** 竞态。假设核销员 A 和 B 同时扫描同一张票: | 时间 | 核销员 A | 核销员 B | |---|---|---| | T1 | SELECT 查到 verify_status=0 | | | T2 | | SELECT 查到 verify_status=0 | | T3 | UPDATE set verify_status=1 (成功) | | | T4 | 返回"核销成功" | UPDATE set verify_status=1 (覆盖成功) | | T5 | | 返回"核销成功" | 结果:同一张票被两个核销员成功核销,产生两条核销记录,入场人数统计翻倍。 **修复方案(原子更新):** ```php $affected = \Db::name(BaseService::table('tickets')) ->where('id', $ticket['id']) ->where('verify_status', 0) // 原子条件:只有在状态仍为 0 时才更新 ->update([ 'verify_status' => 1, 'verify_time' => $now, 'verifier_id' => $verifier_id, 'updated_at' => $now, ]); if ($affected === 0) { // 说明已被其他人先一步核销 $current = \Db::name(BaseService::table('tickets'))->find($ticket['id']); if ($current['verify_status'] == 1) { return ['code' => -2, 'msg' => '该票已核销']; } return ['code' => -3, 'msg' => '该票已退款']; } ``` ### 2.3 `issueTicket()` 二次写入时序问题 ⚠️ 中等 **文件:** `TicketService.php:96-126` ```php // 第一次写入:QR payload 中 id=0 $ticket_id = \Db::name(...)->insertGetId([ 'qr_data' => BaseService::encryptQrData([ 'id' => 0, // 占位 'code' => $ticket_code, ... ]), ... ]); // 第二次写入:用真实 ticket_id 重新加密 if ($ticket_id > 0) { $qr_payload['id'] = $ticket_id; $qr_data_updated = BaseService::encryptQrData($qr_payload); \Db::name(...)->where('id', $ticket_id)->update(['qr_data' => $qr_data_updated]); } ``` 在两次写入之间,数据库中存储的是 `id=0` 的无效 QR payload。如果核销接口在这段时间被调用(极端低概率但存在),`decryptQrData` 会返回 `id=0` 的数据,与真实票记录产生不一致。 **根本原因**:依赖插入后自增 ID,而非使用预生成的 UUID 作为 QR payload 的主键标识。 **修复方案**:在调用 `insertGetId` 前就生成内部关联 UUID: ```php $internal_ref = BaseService::generateUuid(); // 预生成 $qr_payload['ref'] = $internal_ref; $ticket_id = \Db::name(...)->insertGetId([...]); // 无需二次更新 ``` ### 2.4 `getQrCodeUrl()` 明文暴露票码 ⚠️ 中等 **文件:** `TicketService.php:220-228` ```php public static function getQrCodeUrl($ticket_code) { $content = base64_encode(json_encode([ 'type' => 'vr_ticket', 'code' => $ticket_code, // 未经加密,直接 base64 ])); return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($content) ... } ``` QR 码内容仅为 `base64(json_encode({type, code}))`,**无需任何解密即可读出 ticket_code**。这意味着: 1. **票码可枚举**:攻击者扫描 QR 码或抓包获取 URL 后,可提取 `ticket_code` 并尝试批量核销 2. **隐私泄露**:任何人拿到 QR 码图片后,无需破解加密即可获取票码 3. **重放攻击**:QR URL 无时间戳或一次性验证,可被截图复用 **修复方案**:QR URL 应包含加密 payload: ```php // 不暴露明文 code $qr_data = BaseService::encryptQrData([ 'code' => $ticket_code, 'event' => $goods_id, 'seat' => $seat_info, ]); return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($qr_data); ``` ### 2.5 AES-256-CBC 无 HMAC 可检测密文篡改 ⚠️ 中等 **文件:** `BaseService.php:56-60` ```php $iv = random_bytes(16); $encrypted = openssl_encrypt($payload, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv); return base64_encode($iv . $encrypted); // 无 HMAC ``` AES-CBC 模式下,如果攻击者修改密文的某个字节,解密后的 padding 可能看起来有效(CBC 特性导致错误传播到下一块,但最终 JSON 解码可能恰好成功)。更现实的场景是:**中间人修改 `exp` 时间戳使票"永不过期"**。 **修复方案(AEAD 模式,推荐):** ```php // 使用 AES-GCM(AES-256-GCM)自动包含认证标签 $encrypted = openssl_encrypt($payload, 'AES-256-GCM', $secret, OPENSSL_RAW_DATA, $iv, $tag); return base64_encode($iv . $encrypted . $tag); ``` ### 2.6 `getQrSecret()` 硬编码默认值回退 ⚠️ 严重 **文件:** `BaseService.php:98-107` ```php private static function getQrSecret() { $secret = env('VR_TICKET_QR_SECRET', ''); if (!empty($secret)) { return $secret; } return config('shopxo.app_key', 'shopxo_default_secret_change_me'); } ``` 三个问题: 1. `env()` 在 PHP 中取值依赖 `getenv()`,ShopXO 环境变量机制未必与标准 Laravel 一致 2. `'shopxo_default_secret_change_me'` 是明确的已知默认值,若环境变量读取失败(配置错误),系统以不安全密钥运行 3. 未验证密钥长度是否满足 AES-256 要求(32 字节) **修复方案:** 环境变量缺失时主动抛出异常,而非静默回退: ```php private static function getQrSecret() { $secret = env('VR_TICKET_QR_SECRET', ''); if (empty($secret)) { throw new \RuntimeException('[vr_ticket] VR_TICKET_QR_SECRET must be set. QR codes are not secure without a dedicated secret key.'); } if (strlen($secret) < 32) { throw new \RuntimeException('[vr_ticket] VR_TICKET_QR_SECRET must be at least 32 characters for AES-256.'); } return $secret; } ``` --- ## 三、前端票务详情页(ticket_detail.html) ### 3.1 `{$goods.simple_desc|raw}` 直接输出 HTML 导致 XSS ⚠️ 严重 **文件:** `ticket_detail.html:125` ```html
{$goods.simple_desc|default=''|raw}
``` `simple_desc` 来自商品表字段,由商家后台输入。`{|raw}` 完全绕过 ThinkPHP 的自动 HTML 转义。攻击者在商品副标题输入: ```html ``` 即可窃取任意访问商品页用户的 session cookie。 ### 3.2 `{$goods.content|raw}` 商品详情富文本 XSS ⚠️ 严重 **文件:** `ticket_detail.html:164` ```html
{$goods.content|raw}
``` `goods.content` 通常为商家编辑的富文本(包含图片、样式),`{|raw}` 等同于信任所有内容。虽然这是 ShopXO 标准做法,但 VR 票务插件独立使用此模板,放大了风险面。若 ShopXO 后台的内容过滤器存在绕过,此处直接受影响。 ### 3.3 购票参数全由客户端计算,无服务端验签 ⚠️ 严重 **文件:** `ticket_detail.html:384-422` ```javascript submit: function() { var goodsParams = JSON.stringify([{ goods_id: this.goodsId, spec_base_id: this.sessionSpecId, stock: this.selectedSeats.length, // JS 计算 extension_data: extensionData // JS 构造,含价格 }]); location.href = checkoutUrl + '&goods_params=' + encodeURIComponent(goodsParams); } ``` **攻击路径:** 1. 用户选择票价 ¥680 的座位 2. 在浏览器 DevTools 中将 `stock` 改为 `0`,或将价格相关参数改为 `1` 3. 跳转到结算页时携带修改后的 `goods_params` 4. 服务端未重新校验价格,直接使用参数创建订单 这是**价格篡改漏洞**的典型客户端绕过。ShopXO 的标准商品流程有服务端价格校验,但此插件扩展了 `extension_data` 机制,若 ShopXO 内核未对此字段验签,则完全由前端控制。 ### 3.4 `seatInfo.classes` 直接插入 HTML class 属性 ⚠️ 中等 **文件:** `ticket_detail.html:271` ```javascript rowsHtml += '
'; ``` `seatInfo.classes` 来自 JSON 配置(`$vr_seat_template.seat_map`),若配置被攻击者篡改(admin account 被入侵),可注入 `" onclick="evil()"` 破坏属性边界。不过由于这是商家后台操作的座位模板,XSS 触发需要 admin 权限,风险较 `simple_desc` 低,但仍属于**存储型 XSS 的潜在入口**。 ### 3.5 `loadSoldSeats()` 完全未实现 ⚠️ 中等 **文件:** `ticket_detail.html:370-378` ```javascript loadSoldSeats: function() { // TODO: 从后端加载已售座位 // $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {...}); } ``` 已售座位状态全部由前端维护在 `soldSeats: {}` 空对象中。这意味着: - 用户看不到哪些座位已被购买 - 可能出现"选了座位但提交时被告知已售"的糟糕体验(乐观锁失败) - 座位超卖问题完全取决于 ShopXO 的 stock 机制,而非座位级别锁 ### 3.6 `renderSessions()` 中 spec_base_id 赋值错误 ⚠️ 轻微 **文件:** `ticket_detail.html:207` ```javascript data-spec-base-id="'+spec.spec_id+'" // 两次赋值为 spec_id ``` 代码将 `spec_id` 同时赋给了 `data-spec-id` 和 `data-spec-base-id`,两者值相同。若 ShopXO 中 `spec_id` 和 `spec_base_id` 是不同概念(规格ID vs 规格基价ID),则选座时传递给后端的是错误的 `spec_base_id`。 --- ## 四、数据库 Schema(001_vr_tables.sql / EventListener.php) ### 4.1 `vr_tickets` 缺少 `spec_base_id` 独立索引 ⚠️ 建议 **文件:** `EventListener.php:60` ```sql KEY `idx_order_id` (`order_id`), KEY `idx_user_id` (`user_id`), KEY `idx_goods_id` (`goods_id`), KEY `idx_verify_status` (`verify_status`) -- 缺少 KEY `idx_spec_base_id` (`spec_base_id`) ``` `spec_base_id` 用于关联具体座位规格,但查询(如按 spec_base_id 查已售座位数)需要全表扫描。建议添加: ```sql KEY `idx_spec_base_id` (`spec_base_id`) ``` ### 4.2 `vr_seat_templates.category_id` UNIQUE 约束限制过死 ⚠️ 建议 **文件:** `EventListener.php:31` ```sql UNIQUE KEY `uk_category_id` (`category_id`) ``` 一个分类下只允许一个座位模板。若某演出分类需要支持多个场次(每个场次座位布局不同),必须复用同一模板或改代码。建议改为普通索引,或添加 `event_date` 等字段支持多模板。 ### 4.3 `vr_tickets.seat_info` VARCHAR(255) 可能溢出 ⚠️ 轻微 **文件:** `EventListener.php:47` 座位信息(如"VIP区 A排 15座")若由多规格组合,255 字符可能不足。建议改为 VARCHAR(500) 或 TEXT。 ### 4.4 字符集混用 ⚠️ 轻微 EventListener.php 建表使用 `utf8mb4_general_ci`,ShopXO 官方表通常使用 `utf8mb4_unicode_ci`。混用 COLLATE 可能导致 JOIN 查询排序结果不一致。 ### 4.5 缺少退款后自动更新票状态的处理 ⚠️ 中等 `plugin.json` 声明了 `plugins_service_order_delete_success` 钩子但无实现函数。更关键的是:**退款成功后,`vr_tickets.verify_status` 不会被自动更新为 2(已退款)**。这意味着已退款订单的票仍处于"未核销"状态,可能被再次使用(如果退款后又重新发放了票的话)。需要在 `vr_ticket_order_refund_success()` 钩子中处理票状态变更。 --- ## 五、Admin 接口安全性 ### 5.1 `Verification.php` 和 `Ticket.php` 缺少权限校验 ⚠️ 中等 **文件:** `admin/controller/Verification.php` / `admin/controller/Ticket.php` 两个控制器均无 `__construct()` 或方法级别的权限检查(如 `Auth::check()`)。任何已登录的 ShopXO 用户(甚至低权限角色)若知道路由 `/plugins/vr_ticket/admin/ticket/verify`,即可: - 查询所有票记录(包含手机号、身份证等敏感信息) - 手动核销任意票 - 导出完整 CSV 建议在基类或 `__construct()` 中添加: ```php if (!AdminIsLogin() || !AdminIsAuth('vr_ticket')) { return view('', ['msg' => '无权限']); } ``` ### 5.2 `export()` 方法无权限和参数校验 ⚠️ 中等 **文件:** `admin/controller/Ticket.php:134-164` ```php public function export() { $goods_id = input('goods_id', 0, 'intval'); $list = \Db::name('plugins_vr_tickets')->where($where)->order('id', 'desc')->select(); ExportCsv($header, $data, 'vr_tickets_' . date('Ymd')); } ``` 无权限校验,无分页限制。若管理员批量导出所有票数据(包含手机号、身份证),导出的 CSV 文件本身成为数据泄露风险点。建议: - 增加权限校验 - 导出时对敏感字段(phone、id_card)做部分遮蔽 - 对导出操作记录审计日志 ### 5.3 `verify()` 方法中 $verifier_id 由客户端控制 ⚠️ 中等 **文件:** `admin/controller/Ticket.php:116-117` ```php $verifier_id = input('verifier_id', 0, 'intval'); $result = \app\plugins\vr_ticket\service\TicketService::verifyTicket($ticket_code, $verifier_id); ``` `verifier_id` 直接取客户端参数传入,未校验该 ID 是否属于当前登录用户。这意味着:攻击者(哪怕只是普通 admin)可以**以任意核销员身份核销票**,伪造核销记录,污染核销统计。 **修复方案:** ```php $current_verifier = \Db::name(BaseService::table('verifiers')) ->where('user_id', AdminUserId()) ->find(); if (empty($current_verifier)) { return DataReturn('您未被授权为核销员', -1); } $verifier_id = $current_verifier['id']; // 用当前登录用户对应的核销员ID,不信任客户端 ``` --- ## 六、安全性综合评估矩阵 | # | 严重度 | 类别 | 文件 | 问题 | |---|---|---|---|---| | S-01 | 🔴 严重 | 业务逻辑 | TicketService.php:23 | `onOrderPaid()` 无幂等性,重复支付可发多张票 | | S-02 | 🔴 严重 | XSS | ticket_detail.html:125 | `{$goods.simple_desc\|raw}` 直接输出 HTML | | S-03 | 🔴 严重 | XSS | ticket_detail.html:164 | `{$goods.content\|raw}` 富文本 XSS | | S-04 | 🔴 严重 | 业务逻辑 | ticket_detail.html:384 | 购票参数无服务端验签,价格可被篡改 | | S-05 | 🔴 严重 | 密钥管理 | BaseService.php:106 | `getQrSecret()` 硬编码默认回退密钥 | | M-01 | 🟡 中等 | 业务逻辑 | TicketService.php:138 | `verifyTicket()` TOCTOU 竞态,双核销员可同时核销 | | M-02 | 🟡 中等 | 加密 | BaseService.php:56 | AES-CBC 无 HMAC,密文可被篡改 | | M-03 | 🟡 中等 | 隐私/枚举 | TicketService.php:220 | `getQrCodeUrl()` 明文 base64 暴露 ticket_code | | M-04 | 🟡 中等 | 功能缺失 | ticket_detail.html:370 | `loadSoldSeats()` 未实现,座位图不显示已售座位 | | M-05 | 🟡 中等 | 兼容性 | EventListener.php:100 | `empty($cols)` 条件永不成立,ALTER TABLE 从不执行 | | M-06 | 🟡 中等 | 鉴权 | admin/controller/Ticket.php:116 | `verifier_id` 来自客户端,可伪造核销身份 | | M-07 | 🟡 中等 | 鉴权 | admin/controller/*.php | Admin 控制器无权限校验 | | L-01 | 🟢 轻微 | 架构 | EventListener.php | Enable/Disable 钩子缺失 | | L-02 | 🟢 轻微 | 业务逻辑 | EventListener.php | 订单删除钩子声明但无处理函数 | | L-03 | 🟢 轻微 | 数据完整性 | EventListener.php:47 | `seat_info` VARCHAR(255) 可能溢出 | | L-04 | 🟢 轻微 | 规范 | EventListener.php | 字符集混用 `general_ci` vs `unicode_ci` | | I-01 | 💡 建议 | 架构 | EventListener.php | `upgrade()` 空实现,无版本迁移框架 | | I-02 | 💡 建议 | 架构 | TicketService.php:96 | `issueTicket()` 二次写入时序问题(建议预生成 ref) | | I-03 | 💡 建议 | 安全 | admin/controller/Ticket.php:134 | 导出 CSV 无敏感字段遮蔽 | | I-04 | 💡 建议 | 数据库 | EventListener.php:31 | `category_id` UNIQUE 约束限制多模板场景 | | I-05 | 💡 建议 | 性能 | EventListener.php | `vr_tickets.spec_base_id` 缺少独立索引 | --- ## 七、与 SecurityEngineer 报告的交叉评审 两份报告独立完成,发现高度一致,但也各有侧重: **一致确认的严重问题:** - `onOrderPaid()` 幂等性缺失(BackendArchitect §2.1 = SecurityEngineer S-01) - `verifyTicket()` TOCTOU 竞态(BackendArchitect §2.2 = SecurityEngineer S-02) - `|raw` XSS 漏洞(BackendArchitect §3.1-3.2 = SecurityEngineer M-03) - QR 密钥硬编码回退(BackendArchitect §2.6 = SecurityEngineer S-04) **本报告独有发现:** - Admin 接口鉴权缺失(§5.1-5.3) - `verifier_id` 客户端可控(§5.2) - `ALTER TABLE` 条件逻辑错误导致字段从未添加(§1.3) - `seatInfo.classes` 属性注入风险(§3.4) - `renderSessions()` 中 spec_base_id 赋值 bug(§3.6) - 数据库字符集混用(§4.4) **SecurityEngineer 报告独有发现:** - `vr_tickets.id_card` 明文存储身份证的法律合规风险 - `plugins_service_order_delete_success` 钩子处理逻辑缺失 --- ## 八、整体评分与修复优先级 ### 修复优先级 **P0 — 上线前必须修复(漏洞可被直接利用):** 1. S-01:`onOrderPaid()` 幂等性检查 2. S-02/S-03:移除 `|raw` 或改用 `|htmlspecialchars` 3. S-04:购票参数服务端验签/价格重算 4. S-05:移除硬编码默认密钥回退,强制要求环境变量 **P1 — 上线前强烈建议修复(业务逻辑风险):** 5. M-01:`verifyTicket()` 原子更新 6. M-06:Admin 接口 `verifier_id` 鉴权 7. M-07:Admin 控制器全局鉴权 8. M-05:`ALTER TABLE` 逻辑修复 **P2 — 近期迭代中修复:** 9. M-02:升级为 AES-GCM 10. M-03:QR URL 使用加密 payload 11. M-04:实现 `loadSoldSeats()` 12. I-01:建立 upgrade 迁移框架 ### 架构评分 | 维度 | 评分(1-10) | 说明 | |---|---|---| | 架构完整性 | 7 | Hook 链路清晰,但生命周期钩子不完整 | | 并发安全 | 2 | `onOrderPaid` 和 `verifyTicket` 均存在竞态 | | 输入安全 | 3 | XSS 和客户端参数篡改均未防护 | | 加密实现 | 6 | AES-256-CBC 实现正确,但缺 HMAC 认证 | | 数据库设计 | 6 | 字段合理,但缺索引和外键约束 | | Admin 接口安全 | 3 | 完全无鉴权,极易滥用 | | **综合** | **4.5** | 核心链路可行,但安全加固工作量较大 | --- ## 九、结论 vr-shopxo-plugin 的**核心业务逻辑链路**设计合理,充分利用了 ShopXO 的 Hook 扩展机制,无需修改内核代码。然而,**并发安全和接口鉴权**是当前最薄弱的两环,分别对应"票务系统"最核心的两个安全属性:**防重发**和**防滥用**。 当前代码若直接部署在生产环境,至少存在以下可被直接利用的攻击面: 1. 支付重试导致的一票多发(财务损失) 2. 任意登录用户伪造核销员身份(核销统计失真) 3. Admin 通过 XSS 窃取用户 cookie(账户接管) 4. 购票价格前端篡改(低价购票) 建议在正式上线前完成所有 P0 和 P1 项修复,并建立包含渗透测试的发布前安全评审流程。 --- *本报告由 BackendArchitect 独立完成,与 SecurityEngineer 的审计报告交叉印证。两份报告合并构成 vr-shopxo-plugin 的完整安全与架构评估。*