2026-04-15 11:26:45 +00:00
|
|
|
|
# vr-shopxo-plugin 架构决策报告
|
|
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
> **文档版本**: v1.0 | **日期**: 2026-04-15 | **发起**: Council(FrontendDev + BackendArchitect + SecurityEngineer)
|
|
|
|
|
|
> **关联 Issue**: #9 | **状态**: FINAL
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
## 1. 背景与问题
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
vr-shopxo-plugin 是 ShopXO 票务插件,核心场景:VR 演唱会票务小程序,用户选座 → 下单 → QR 核销。
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
当前 Phase 0/1/2 已完成基础骨架,暴露了一个 P0 架构问题:**ShopXO SPEC 与 SKU 的绑定方案**。
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**已知状态(商品 112 实测):**
|
|
|
|
|
|
- `is_exist_many_spec = 0`(ShopXO 认为无多规格)
|
|
|
|
|
|
- `goods_spec_base` 表为空(无任何 SKU)
|
|
|
|
|
|
- `spec_base_id_map` 指向不存在的 DB 记录(ID 1001/1002/1003)
|
|
|
|
|
|
- ShopXO 防超卖机制完全未启用
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 2. 两种架构方向
|
|
|
|
|
|
|
|
|
|
|
|
| | 方案 A(每座=SKU) | 方案 B(每 Zone=SKU) |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| SKU 粒度 | 每个具体座位一行,inventory=1 | 每个 Zone(A/B/C)一行,inventory=Zone 座位数 |
|
|
|
|
|
|
| 防超卖 | ShopXO 原生原子扣库存(`BuyService dec()`) | 自建 FOR UPDATE 锁,需并发逻辑 |
|
|
|
|
|
|
| 多 Zone 混买 | 每座一行 goods_params,后端原子处理 | 前端分组,后端共享 Zone 库存 |
|
|
|
|
|
|
| 后台复杂度 | 10000+ SKU 行(插件自管,Hook 隐藏) | Zone 数量少,后台友好 |
|
|
|
|
|
|
| 与 ShopXO 生态 | 完全对齐 | 绕过 spec 校验 |
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
## 3. 四问评议结论
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
|
|
|
|
|
### Q1:方案 A 后台批量生成 SKU 路径是否可行?
|
|
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**结论:可行,但必须旁路 `GoodsSpecificationsInsert()`。**
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
- ShopXO 的 `GoodsSpecificationsInsert()` 每次商品保存时 `DELETE` 所有现有 spec 后重建,10K+ 座位场景不可用。
|
|
|
|
|
|
- **可行路径:直接 SQL INSERT** 到 `sxo_goods_spec_type`、`sxo_goods_spec_base`、`sxo_goods_spec_value` 三表。
|
|
|
|
|
|
- 性能:10000 座位 ≈ 3-4 秒(需分批 500 条/批提交)。
|
|
|
|
|
|
- 初始化一次,座位模板绑定时生成,后续不变。
|
|
|
|
|
|
- ShopXO 防超卖依赖 `BuyService.php:1677-1681` 的 `dec()` 机制(MySQL 条件原子扣减 `UPDATE SET inventory = inventory - N WHERE inventory >= N`),TOCTOU 窗口极小(选座模式并发低 + InnoDB 行锁),**推荐接受此风险**。
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
|
|
|
|
|
### Q2:商品 112 broken 状态是否需要紧急修复?
|
|
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**结论:推荐方案乙(最小修复集),紧急程度中等。**
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
最小修复集:
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- Step 1: 启用多规格
|
|
|
|
|
|
UPDATE sxo_goods SET is_exist_many_spec = 1 WHERE id = 112;
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
-- Step 2: 写入 $vr- 规格维度
|
|
|
|
|
|
INSERT INTO sxo_goods_spec_type (goods_id, name, value, add_time) VALUES
|
|
|
|
|
|
(112, '$vr-场馆', '[{"name":"国家体育馆","images":""}]', UNIX_TIMESTAMP()),
|
|
|
|
|
|
(112, '$vr-分区', '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', UNIX_TIMESTAMP()),
|
|
|
|
|
|
(112, '$vr-时段', '[{"name":"2026-05-01 19:00","images":""}]', UNIX_TIMESTAMP());
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
-- Step 3: 幂等保护(在 TicketService::issueTicket() 中添加 spec_base_id=0 fallback)
|
|
|
|
|
|
```
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
真正的批量 SKU 生成在 Phase 3「座位模板绑定」时完成。
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
### Q3:$vr- 前缀方案是否有隐患?
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**结论:低风险,确认安全。(SecurityEngineer + FrontendDev 双重确认)**
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
- **ThinkPHP 模板解析机制**:`{$var}` 默认 HTML 转义输出,`{:expr}` 执行表达式但需要 `$var` 存在。
|
|
|
|
|
|
- `$vr-场馆` 作为 spec name 字符串存储在 DB 中,不作为 PHP 变量名,不触发变量插值。
|
|
|
|
|
|
- `parseVar` 正则 `\$[a-zA-Z_](?>\w*)` 在 `$vr-场馆` 中仅匹配 `$vr`,剩余 `-场馆` 留在原地,生成无效 PHP 代码,无 XSS 风险。
|
|
|
|
|
|
- `{{$spec.name}}` 中的 spec name 是属性值,ThinkPHP **不会**二次解析为模板语法。
|
|
|
|
|
|
- ShopXO spec name 字段无字符过滤,数据库 `varchar` 类型允许 `$` 字符。
|
|
|
|
|
|
- 唯一注意:ShopXO 后台规格管理页可能将 `$vr-` 显示不当(纯展示问题,不影响安全)。
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
### Q4:方案 A vs B 最终推荐?
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**结论:明确推荐方案 A(每个座位一个 SKU)。三方一致。**
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
| 维度 | 方案 A(推荐) | 方案 B |
|
|
|
|
|
|
|------|---------------|--------|
|
|
|
|
|
|
| 防超卖 | ShopXO 原生原子扣库存,DB 层保证 | 自建 FOR UPDATE 锁,需自己写并发逻辑 |
|
|
|
|
|
|
| 实现复杂度 | 后端需批量生成 1 万+ SKU;前端 submit() 需改为逐座提交 | 后端简单;前端按 Zone 分组即可 |
|
|
|
|
|
|
| 多 Zone 混买 | 每座一行 goods_params,后端原子处理,体验流畅 | 前端分组但后端共享 Zone 库存,复杂度高 |
|
|
|
|
|
|
| 后台可维护性 | 10000+ SKU 行,但可 Hook 隐藏(插件自管) | Zone 数量少,后台友好 |
|
|
|
|
|
|
| 调试/故障排查 | 每个 SKU 独立,可追溯 | 共享库存,出问题难以定位 |
|
|
|
|
|
|
| 与 ShopXO 生态 | 完全对齐,无缝集成 | 绕过 spec 校验,部分 ShopXO 功能失效 |
|
|
|
|
|
|
| TOCTOU 风险 | 极小(选座并发低 + InnoDB 行锁兜底) | 可控(显式锁) |
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**方案 B 的唯一优势**(SKU 数量少)在演唱会 10000+ 座场景下不成立——插件自建独立 SKU 管理页面,ShopXO 原生规格管理页通过 Hook 隐藏插件 SKU,优势消失。
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
---
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
## 4. 最终推荐
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**采用方案 A:每个座位 = 一个 ShopXO SKU(stock=1)。**
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
### 推荐理由(综合三方)
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
1. **安全性最优**:ShopXO 原生原子扣库存防超卖,经过生产验证,无需自建锁。
|
|
|
|
|
|
2. **数据一致性**:每个座位 inventory=1,ShopXO 购买流程自带事务保护,TOCTOU 窗口极小(选座模式下并发度远低于总库存)。
|
|
|
|
|
|
3. **票务链路清晰**:`spec_base_id` 直接对应座位,票生成逻辑无需反向解析,核销链路可追溯。
|
|
|
|
|
|
4. **多 Zone 混买体验好**:前端每 Zone 一个 goods_params 行,后端按 seat_id 原子购买,体验流畅。
|
|
|
|
|
|
5. **与 ShopXO 生态对齐**:完全走 ShopXO 原生购买流程,故障排查有据可查,升级兼容性好。
|
|
|
|
|
|
6. **$vr- 前缀安全**:无 ThinkPHP 变量插值风险,无 XSS 风险,完全隔离于用户规格。
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
### ShopXO 原生防超卖机制
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
`BuyService.php:1677-1681`:
|
2026-04-15 11:26:45 +00:00
|
|
|
|
```php
|
|
|
|
|
|
$where = [
|
|
|
|
|
|
['id', '=', $base['data']['spec_base']['id']],
|
|
|
|
|
|
['goods_id', '=', $v['goods_id']],
|
|
|
|
|
|
['inventory', '>=', $v['buy_number']],
|
|
|
|
|
|
];
|
|
|
|
|
|
Db::name('GoodsSpecBase')->where($where)->dec('inventory', $v['buy_number'])->update();
|
|
|
|
|
|
```
|
2026-04-15 11:29:08 +00:00
|
|
|
|
翻译为 SQL:`UPDATE goods_spec_base SET inventory = inventory - N WHERE inventory >= N`
|
|
|
|
|
|
|
|
|
|
|
|
这是 MySQL 层面的条件原子扣减,TOCTOU 窗口极小(选座模式已在前端锁定具体座位,请求打到后端时并发度远低于总库存),推荐接受此风险。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 5. 行动项(优先级排序)
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
| 优先级 | 行动项 | 负责 | 依赖 |
|
|
|
|
|
|
|--------|--------|------|------|
|
|
|
|
|
|
| **P0** | 执行 Q2 最小修复集:`UPDATE is_exist_many_spec=1` + 写入 `$vr-` spec_type + spec_base_id=0 幂等保护 | BackendArchitect | 无 |
|
|
|
|
|
|
| **P0** | 创建 `SeatSkuService::BatchGenerate()`:直接 SQL INSERT 批量生成 SKU(分批 500 条) | BackendArchitect | P0 完成后 |
|
|
|
|
|
|
| **P1** | 重构 `ticket_detail.html` submit():从 session-level 提交改为 seat-level 逐座提交,接入 `specBaseIdMap` | FrontendDev | P0 完成后 |
|
|
|
|
|
|
| **P2** | 实现 `loadSoldSeats()`:查询各 seat spec_base 的库存状态 | FrontendDev | P0 完成后 |
|
|
|
|
|
|
| **P3** | Hook 隐藏插件 SKU:插件 SKU 不出现在 ShopXO 原生规格管理页 | FrontendDev | P1 完成后 |
|
|
|
|
|
|
| **P3** | 设计插件独立 SKU 管理页面(隔离 ShopXO 原生规格管理) | FrontendDev | 远期 |
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
## 6. 各成员立场
|
|
|
|
|
|
|
|
|
|
|
|
| 成员 | Q1 | Q2 | Q3 | Q4 最终推荐 |
|
|
|
|
|
|
|------|----|----|----|------------|
|
|
|
|
|
|
| BackendArchitect | 可行,旁路 GoodsSpecificationsInsert | 推荐方案乙 | — | **方案 A** |
|
|
|
|
|
|
| FrontendDev | 可行但复杂(需 Hook 隐藏 SKU 行) | 推荐方案甲(最小侵入) | 低风险安全 | **方案 A** |
|
|
|
|
|
|
| SecurityEngineer | — | blocked(待 Q4 确认) | 低风险安全 | **方案 A** |
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
**全票通过:采纳方案 A**
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
## 7. 附录
|
|
|
|
|
|
|
|
|
|
|
|
### A. 关键代码路径
|
|
|
|
|
|
|
|
|
|
|
|
- **购买原子扣库存**:`BuyService.php:1677-1681` — `dec()` 机制
|
|
|
|
|
|
- **规格插入(禁用)**:`GoodsService.php:2142` — `GoodsSpecificationsInsert()`(每次保存 DELETE+重建)
|
|
|
|
|
|
- **批量 SKU 生成**:插件自建 `SeatSkuService::BatchGenerate()`,直接 SQL INSERT 三表
|
|
|
|
|
|
- **前端提交改造**:`ticket_detail.html` — submit() 从 session-level 改为 seat-level
|
|
|
|
|
|
- **specBaseIdMap 注入**:后端 PHP 注入前端,供 submit() 使用
|
|
|
|
|
|
- **$vr- 前缀安全**:`shopxo/vendors/thinkphp/library/think/Template.php:837-955` — `parseVar` 正则
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
### B. 缩写说明
|
2026-04-15 11:26:45 +00:00
|
|
|
|
|
2026-04-15 11:29:08 +00:00
|
|
|
|
- SKU = ShopXO `goods_spec_base` 表中的一条记录(一个规格组合)
|
|
|
|
|
|
- spec_base_id = SKU 的主键 ID
|
|
|
|
|
|
- spec_base_id_map = 插件内存/缓存中的 `seat_id → spec_base_id` 映射
|
|
|
|
|
|
- TOCTOU = Time-of-check to time-of-use,并发竞态窗口
|
|
|
|
|
|
- goods_params = 购买请求中的规格参数数组
|