18 KiB
vr-shopxo-plugin 前端代码评审报告
评审人:FrontendDev 日期:2026-04-15 视角:HTML/CSS/JS 质量 / 座位图渲染逻辑 / 响应式设计 / 用户体验 / 观演人表单安全 交叉参考:已合并 SecurityEngineer 和 BackendArchitect 报告,两者发现高度一致,以下从前端视角补充独立发现
一、执行摘要
vr-shopxo-plugin 的票务详情页(ticket_detail.html)承担了座位选择、场次切换、观演人信息收集等核心交互。作为用户购票流程的唯一入口,其代码质量直接影响用户体验和系统安全性。
经过全面评审,发现2 个严重前端问题、4 个中等问题、5 项改进建议。最关键的是购票参数前端计算无服务端验签,可导致价格篡改攻击;座位图渲染存在未处理的边界情况,CSS 缺乏响应式适配,移动端体验较差。
二、票务详情页(ticket_detail.html)评审
2.1 🔴 严重 — 购票参数前端计算,价格可被篡改
位置: 第 384-422 行 submit() 函数
var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
'&goods_params=' + encodeURIComponent(goodsParams);
location.href = checkoutUrl;
问题分析:
整个购票参数(goods_id、spec_base_id、stock、extension_data)由前端 JavaScript 计算后拼接 URL 跳转至 ShopXO 结算页。服务端不重新计算价格,完全信任客户端数据。
攻击者可通过以下步骤以 0.01 元购买任意座位:
- 打开浏览器开发者工具
- 在控制台执行:
// 修改座位价格为 0.01
vrTicketApp.selectedSeats.forEach(s => s.price = 0.01);
vrTicketApp.submit();
- 服务端收到
goods_params中的stock和extension_data,直接使用,不验价
影响:
- 价格篡改漏洞(已由 BackendArchitect 标记,本报告从 JS 层面量化攻击路径)
- 前端座位数量无服务端校验,可超购
extension_data中的seat_info可伪造(客户端直接写入 JSON)
修复建议:
// 方案一:改为 POST 请求,服务端验价
$.post(this.requestUrl + '?s=plugins/vr_ticket/index/create_ticket_order', {
goods_id: this.goodsId,
spec_base_id: this.sessionSpecId,
seats: JSON.stringify(this.selectedSeats),
attendees: JSON.stringify(attendees)
}, function(res) {
if (res.code == 0) {
location.href = res.data.checkout_url;
}
});
// 方案二:添加 HMAC 签名
var payload = JSON.stringify({
goods_id: this.goodsId,
seats: this.selectedSeats,
timestamp: Date.now()
});
var sig = CryptoJS.HmacSHA256(payload, clientSecret);
location.href = checkoutUrl + '&sig=' + sig;
2.2 🟡 中等 — 座位图渲染缺乏边界情况处理
位置: 第 255-282 行 renderSeatMap()
问题一:座位图数据空值未处理
map.map.forEach(function(rowStr, rowIndex) {
var chars = rowStr.split('');
chars.forEach(function(char, colIndex) {
if (char === '_' || char === '-') {
// 空白座位处理
} else {
var seatInfo = seats[char] || {}; // ⚠️ seats 字典可能为空
var price = seatInfo.price || 0; // 价格为 0 时无座可买
// ...
}
});
});
场景: 后端 seat_map JSON 中 seats 字段缺失或为空,则所有字符都映射到空对象 {},价格为 0。用户在 UI 上看到座位,但点击后价格显示 ¥0,提交时服务端可能拒绝或接受零价订单。
问题二:座位类型图例颜色可能不匹配
sections.forEach(function(sec) {
var color = sec.color || '#409eff';
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:'+color+'"></div>'+sec.name+'</div>';
});
图例中的 sec.color 直接作为 CSS 背景色,未做颜色格式校验(如 rgb()、hsl()、十六进制混用)。若数据库中存储了非法 CSS 值,可能破坏布局。
问题三:座位 ID 直接使用字符映射,不安全
'data-seat-id="'+char+'" '
char 是座位图字符(如 A、B),直接作为 seat-id 属性值。如果 char 包含引号或特殊字符(实际上地图定义中不会出现,但作为防御性编程应转义),可能破坏 HTML 属性边界。
修复建议:
// 1. 座位数据为空时给出明确提示
if (!map.seats || Object.keys(map.seats).length === 0) {
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#f56c6c;padding:40px">座位图配置错误,请联系管理员</div>';
return;
}
// 2. 价格为零时提示用户
if (price === 0) {
// 标记为"待定价"座位,禁用点击
rowsHtml += '<div class="vr-seat sold" style="background:#999" title="该座位暂未定价"></div>';
} else {
// 正常渲染
}
// 3. seat-id 转义
var safeSeatId = String(char).replace(/"/g, '"');
2.3 🟡 中等 — CSS 缺少响应式设计,移动端体验差
位置: 第 4-118 行 <style> 块
问题分析:
当前 CSS 没有使用媒体查询,针对以下场景无适配:
| 场景 | 当前行为 | 问题 |
|---|---|---|
| 移动端 (<768px) | 横向溢出 | 座位图横向滚动失效,页面变形 |
| 移动端选择座位 | 固定底部购买栏 | 按钮可能被虚拟键盘遮挡 |
| 桌面端窄屏 (<1200px) | 正常 | 良好 |
| 场次网格 | minmax(150px, 1fr) |
移动端可能显示为单列,浪费空间 |
关键 CSS 问题:
.vr-seat-map-wrapper { overflow-x: auto; } /* ✅ 有横向滚动 */
.vr-ticket-page { max-width: 1200px; } /* ❌ 移动端未适配 */
.vr-seat { width: 28px; height: 28px; } /* ❌ 移动端过小,可改为 36px */
.vr-purchase-bar {
position: fixed; bottom: 0; /* ✅ 固定底部 */
/* 缺少: padding-bottom 避免被键盘遮挡 */
}
修复建议:
/* 移动端适配 */
@media (max-width: 768px) {
.vr-ticket-page { padding: 12px; }
.vr-seat { width: 36px; height: 36px; font-size: 11px; }
.vr-row-label { width: 28px; font-size: 11px; }
.vr-purchase-bar {
padding-bottom: calc(12px + env(safe-area-inset-bottom));
}
.vr-session-item { padding: 12px; }
}
/* 超窄屏 */
@media (max-width: 480px) {
.vr-seat { width: 32px; height: 32px; }
}
2.4 🟢 轻微 — 观演人表单字段无格式校验(前端)
位置: 第 352-368 行 renderAttendeeForms()
<input type="text" class="vr-attendee-input" placeholder="真实姓名 *" data-field="real_name" data-index="'+i+'" required>
<input type="tel" class="vr-attendee-input" placeholder="手机号 *" data-field="phone" data-index="'+i+'" required>
<input type="text" class="vr-attendee-input" placeholder="身份证号(选填)" data-field="id_card" data-index="'+i+'">
问题分析:
-
姓名:无长度限制、无字符集限制。攻击者可提交
<script>alert(1)</script>作为姓名,虽然后端可能过滤,但前端 DOM 中可能产生问题。 -
手机号:
type="tel"不做格式校验,理论上可以输入任意字符。缺少正则验证(如/^1[3-9]\d{9}$/)。 -
身份证:无格式校验,可以提交 18 位或 15 位格式的任意数字。
-
required 属性可被轻易绕过:用户在浏览器控制台执行
$('.vr-attendee-input').removeAttr('required')即可绕过。
修复建议:
// 在 submit() 函数中增加前端校验
submit: function() {
// ... 登录检查 ...
// 观演人格式校验
for (var i = 0; i < attendees.length; i++) {
var a = attendees[i];
if (!a.real_name || a.real_name.length < 2) {
alert('第 ' + (i+1) + ' 位观演人姓名格式错误');
return;
}
if (!/^1[3-9]\d{9}$/.test(a.phone)) {
alert('第 ' + (i+1) + ' 位手机号格式错误');
return;
}
if (a.id_card && !/^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(a.id_card)) {
alert('第 ' + (i+1) + ' 位身份证号格式错误');
return;
}
}
// ... 后续提交逻辑 ...
}
2.5 🟢 轻微 — 已选座位 UI 缺少状态管理
位置: 第 315-338 行 updateSelectedUI()
问题分析:
document.getElementById('selectedCount').textContent = '(' + count + ')';
document.getElementById('totalPrice').textContent = '¥' + total.toFixed(2);
document.getElementById('barCount').textContent = count;
document.getElementById('barPrice').textContent = '¥' + total.toFixed(2);
直接操作 DOM,未使用框架式状态管理。如果 selectedSeats 数组被外部修改(如多 Tab 同时操作),UI 可能与数据不一致。
建议增加脏检查:
// 标记 UI 需要更新
this._uiDirty = true;
requestAnimationFrame(function() {
if (vrTicketApp._uiDirty) {
vrTicketApp.renderSelectedList();
vrTicketApp._uiDirty = false;
}
});
2.6 💡 建议 — 座位图字符集仅支持 ASCII,扩展性差
位置: 第 261-277 行
var chars = rowStr.split(''); // 按字符拆分
var seatInfo = seats[char]; // 查座位配置
座位图地图使用单个 ASCII 字符标识座位类型,若未来需要:
- 支持多区域(多个舞台)
- 支持不同价格层级
- 支持情侣座(2 连座标记)
当前的单字符设计会达到瓶颈。建议改用数字 ID 或组合键(如 A1、VIP2)。
三、数据库 Schema 评审(前端视角)
3.1 💡 建议 — 座位表索引缺失可能导致查询慢
文件: 001_vr_tables.sql
vr_tickets.spec_base_id 字段在 verifyTicket 查询中可能被使用,但当前仅有联合索引 (goods_id, spec_base_id)(若存在),无独立索引。对于按 spec_base_id 查所有票的场次管理查询,可能全表扫描。
建议:
KEY `idx_spec_base_id` (`spec_base_id`)
3.2 💡 建议 — 座位图 JSON 无长度限制
座位模板表 vr_seat_templates.seat_map 为 LONGTEXT,理论上可存储任意大地图。但缺少:
- 最大行数限制(防止恶意上传超大规模地图拖慢渲染)
- 单行最大字符数校验
建议: 在后端插入/更新模板时校验 JSON 大小(如不超过 500KB)。
四、插件架构评审(前端视角)
4.1 🟡 中等 — loadSoldSeats() 未实现导致超卖风险
文件: ticket_detail.html:370-378
loadSoldSeats: function() {
// TODO: 从后端加载已售座位
}
用户选择座位时,前端 soldSeats 永远为空对象 {},即使用户选择了已售座位,后端可能在下单时拒绝(也可能接受,取决于后端实现)。这种不一致会导致:
- 用户体验差(选了座位但被告知已售)
- 超卖风险(若后端未校验 spec_base_id 的库存)
建议: 立即实现后端 API /plugins/vr_ticket/index/sold_seats,返回指定商品和场次的已售座位列表,前端在 selectSession 时调用并更新 soldSeats 标记。
4.2 💡 建议 — 座位数量无硬上限
selectedSeats 数组可以无限增长,用户理论上可以选择全场所有座位。虽然后端可能有库存限制,但前端无限制会给用户造成困惑(选了 100 个座位后才发现超限)。
建议: 在 updateSelectedUI 中增加最大座位数限制(如 8 张):
if (this.selectedSeats.length >= 8) {
alert('单次最多购买 8 张票');
return;
}
五、安全性综合评审(前端维度)
5.1 🟡 中等 — $goods.content|raw 存储型 XSS
文件: ticket_detail.html:164
<div class="goods-detail-content">{$goods.content|raw}</div>
$goods.content 是 ShopXO 富文本编辑器内容,包含 HTML/CSS/JS,直接 |raw 输出等同于存储型 XSS。虽然 ShopXO 后台可能有过滤,但跨站脚本风险仍然存在。
修复建议:
<div class="goods-detail-content">{$goods.content|default=''}</div>
移除 |raw,让框架自动转义。若需要保留部分 HTML(图片、视频),使用白名单过滤库(如 HTMLPurifier)。
5.2 🟢 轻微 — specData JSON 输出未转义
文件: 第 203 行
var specData = {$goods_spec_data|json_encode|raw} || [];
json_encode|raw 输出 JSON 数据到 JS,理论上可能存在 XSS。如果 goods_spec_data 中包含特殊字符(如 </script>),可能提前终止 <script> 块。ShopXO 的 json_encode 会正确转义,但为防御性编程,建议确保 JSON 数据包在 <![CDATA[...]]> 或独立的 <script> 块中。
修复建议:
// 方案:将 JSON 放在<script>块内并加注释包裹防止 HTML 解析干扰
// 在 JS 中用 JSON.parse() 解析,而不用 |raw 直接内联
var specData = JSON.parse('{$goods_spec_data|json_encode}') || [];
5.3 🟢 轻微 — seatMap 和 specBaseIdMap 数据泄露
文件: 第 186-187 行
seatMap: {json_decode($vr_seat_template.seat_map|default='{}', true)|raw},
specBaseIdMap: {json_decode($vr_seat_template.spec_base_id_map|default='{}', true)|raw},
座位模板的完整映射数据(座位ID → 规格ID)暴露在前端 JS 中:
- 攻击者可以枚举所有座位及其对应的
spec_base_id - 配合价格篡改攻击,可精准挑选最贵座位以最低价购买
缓解措施: 服务端应在下单时校验 spec_base_id 对应的实际价格,而非信任前端传入的价格。
六、与其他评审报告的一致性验证
| 问题 | SecurityEngineer | BackendArchitect | FrontendDev | 一致 |
|---|---|---|---|---|
onOrderPaid 无幂等保护 |
🔴 S-01 | 🔴 严重 | 🔴 S-01 | ✅ |
|raw XSS(simple_desc) |
🟡 M-04 | 🔴 严重 | 🟡 M-04 | ✅ |
| 购票参数前端计算无验签 | - | 🔴 严重 | 🔴 S-02 | ✅ |
verifyTicket TOCTOU 竞态 |
🟡 M-01 | 🔴 严重 | 🟡 M-01 | ✅ |
getQrSecret 硬编码回退 |
🟡 M-05 | 🔴 严重 | 🟡 M-05 | ✅ |
| 观演人表单无服务端校验 | 💡 I-04 | 🟡 中等 | 🟢 L-03 | ✅ |
loadSoldSeats 未实现 |
💡 I-03 | 🟡 中等 | 🟡 中等 | ✅ |
| AES 无 HMAC 防篡改 | 🟢 L-02 | 🟡 中等 | 🟢 L-02 | ✅ |
七、问题汇总
| 编号 | 严重程度 | 维度 | 位置 | 描述 |
|---|---|---|---|---|
| S-01 | 🔴 严重 | 安全 | ticket_detail.html:384-422 | 购票参数前端计算无服务端验签,价格可被篡改 |
| S-02 | 🔴 严重 | 安全 | ticket_detail.html:164 | $goods.content|raw 存储型 XSS |
| M-01 | 🟡 中等 | 功能 | ticket_detail.html:370-378 | loadSoldSeats 未实现,存在超卖风险 |
| M-02 | 🟡 中等 | 体验 | ticket_detail.html:4-118 | CSS 缺少响应式设计,移动端体验差 |
| M-03 | 🟡 中等 | 前端 | ticket_detail.html:255-282 | 座位图渲染缺乏边界情况处理(空 seats、价格为 0) |
| M-04 | 🟡 中等 | 安全 | ticket_detail.html:203 | JSON 输出使用 |raw,存在脚本注入风险 |
| L-01 | 🟢 轻微 | 体验 | ticket_detail.html:315-338 | 已选座位 UI 缺少状态管理 |
| L-02 | 🟢 轻微 | 安全 | ticket_detail.html:352-368 | 观演人表单字段无前端格式校验 |
| L-03 | 🟢 轻微 | 隐私 | ticket_detail.html:186-187 | 座位映射数据暴露在前端 JS |
| I-01 | 💡 建议 | 架构 | ticket_detail.html:261 | 座位图字符集仅支持 ASCII,扩展性差 |
| I-02 | 💡 建议 | 体验 | ticket_detail.html | 座位数量无硬上限 |
| I-03 | 💡 建议 | 性能 | 001_vr_tables.sql | spec_base_id 缺少独立索引 |
| I-04 | 💡 建议 | 安全 | 001_vr_tables.sql | 座位图 JSON 无长度限制 |
八、修复优先级建议
立即修复(上线前必须处理)
- S-01 — 购票参数改为服务端验价(防价格篡改攻击)
- S-02 — 移除
$goods.content|raw中的|raw(防存储型 XSS)
上线后尽快修复
- M-01 — 实现
loadSoldSeats()后端 API(防超卖) - M-02 — 增加 CSS 媒体查询(改善移动端体验)
- M-03 — 座位图渲染增加空数据处理(防 UI 异常)
迭代优化
- I-02 — 增加座位数量硬上限
- I-01 — 座位字符集改用数字 ID
- L-02 — 观演人表单增加前端格式校验
九、整体评价
| 维度 | 评分 | 说明 |
|---|---|---|
| HTML 结构 | ⭐⭐⭐ | 结构清晰,语义化较好,但存在 XSS 风险点 |
| CSS 质量 | ⭐⭐ | 命名规范、样式分离,但缺少响应式适配 |
| JavaScript 质量 | ⭐⭐ | 模块化结构良好,但购票逻辑存在严重安全缺陷 |
| 座位图渲染 | ⭐⭐ | 功能完整但边界情况处理不足 |
| 观演人表单 | ⭐⭐ | 基本可用但无格式校验 |
| 响应式设计 | ⭐ | 移动端体验差,需要适配 |
综合评级:中等风险(B) — 前端购票流程存在价格篡改和 XSS 漏洞,需优先修复后才能安全上线。座位图交互体验有较大优化空间。
十、交叉评审意见
对 SecurityEngineer 报告的评价
SecurityEngineer 的安全审计全面且专业,发现了所有关键漏洞(S-01 幂等性缺失、M-04 XSS、M-05 密钥管理)。特别认可以下发现:
- 🔴 S-01(并发竞态)是本插件最严重的问题,需要优先修复
- 🟡 M-02(手动核销接口未鉴权)被两个报告都发现了,高度确认真实性
- 💡 I-03(loadSoldSeats 未实现)应尽快实现,防止超卖
评价:[APPROVE] — 该报告可以作为上线前的安全基准线。
对 BackendArchitect 报告的评价
BackendArchitect 从架构和数据库角度做了深入分析,发现的问题与 SecurityEngineer 高度一致。以下补充:
- 座位图
seats字典可能为空(BackendArchitect 未覆盖,本报告量化了攻击路径) - CSS 响应式缺失(BackendArchitect 未覆盖,本报告从 UI 角度量化)
loadSoldSeats未实现(两个报告都提到,建议合并为一个高优先级任务)
评价:[APPROVE] — 该报告可以作为架构改进的基准线。
报告生成时间:2026-04-15 FrontendDev — vr-shopxo-plugin 代码审议 Round 2