17 KiB
vr-shopxo-plugin 架构决策评议 — plan.md
版本:v1.1(合并版)| 日期:2026-04-15 | Agent:council/FrontendDev + BackendArchitect + SecurityEngineer 关联:Issue #9
任务背景
Phase 0/1/2 已完成基础骨架,暴露了一个 P0 架构问题:VR 演唱会票务商品中 ShopXO SPEC 与 SKU 的绑定方案。
已知事实:
- ShopXO
goods_spec_base(SKU表)当前为空,商品 112 的is_exist_many_spec=0 spec_base_id_map中的 ID(如 1001/1002/1003)在 DB 中不存在- ShopXO 防超卖机制(原子扣 inventory)完全未启用
两种架构方向:
- 方案 A:每个座位 = 一个 SKU(stock=1),ShopXO 原生防超卖
- 方案 B:每个 Zone = 一个 SKU(stock=Zone座位数),自建 FOR UPDATE 防超卖
核心问题(4问)
| # | 问题 | 负责 |
|---|---|---|
| Q1 | 方案 A 后台批量生成 SKU 路径是否可行?ShopXO 是否有批量 API? | BackendArchitect |
| Q2 | 当前商品 112 的 broken 状态(is_exist_many_spec=0 + spec_base 空)是否需要紧急修复?最小修复集? | BackendArchitect + SecurityEngineer |
| Q3 | vr- 前缀方案是否有隐患?ShopXO 内部是否对 有特殊处理? |
SecurityEngineer |
| Q4 | 方案 A vs 方案 B 最终推荐(实现成本 / 安全性 / 可维护性) | 所有成员 |
阶段划分
| 阶段 | 内容 | 负责 |
|---|---|---|
| Round 1(本轮) | 独立评议 + plan.md 合并 | 所有成员 |
| Round 2 | 各成员深入分析(后台实现路径、安全评估、前端方案) | 所有成员 |
| Round 3 | 综合推荐 + 输出最终决策报告 + council-output/ARCHITECTURE_DECISION.md |
FrontendDev 主笔 |
任务清单
- Q1: 方案 A 批量生成 SKU 路径
[Done: BackendArchitect]✅ - Q2: 商品 112 broken 状态紧急修复
[Done: BackendArchitect]✅ - Q3: $vr- 前缀安全评估
[Done: SecurityEngineer]✅ - Q4: 方案 A vs 方案 B 最终推荐
[Done: FrontendDev] - Final:
council-output/ARCHITECTURE_DECISION.md— 汇总三方推荐 + 最终结论[Done: FrontendDev]✅
Claim 状态
| 任务 | Claim 状态 |
|---|---|
| Q1 | [Done: BackendArchitect] |
| Q2 | [Done: BackendArchitect] |
| Q3 | [Done: SecurityEngineer] |
| Q4 | [Done: FrontendDev] |
| 最终输出 | [Done: FrontendDev] |
依赖关系
- Q1(BackendArchitect)先完成,后 Q4 才能给出完整推荐
- Q3(SecurityEngineer)可与 Q1 并行
- Q2 可独立完成,紧急程度由 BackendArchitect 判定
- 三方分析完成后,FrontendDev 主笔 Round 3 最终报告
各成员 Round 1 初判
BackendArchitect 初判
Q1 初步判断:Plan A 后台批量生成 SKU 可行。ShopXO 的 goods_spec_base 是标准 MySQL 表,插件可直接 INSERT。但需要确认:
- ShopXO 商品保存时是否校验 spec_base 的 referential integrity
- 上万座位时批量 INSERT 的性能
- spec_base_id_map 中的 ID 是否需要与 ShopXO 内部 ID 对齐
Q2 初步判断:当前 broken 状态暂不需要立即修复。购买流程走的是裸商品逻辑(is_exist_many_spec=0),对 Phase 3 的购买流程设计反而是参考点——需要明确购买流程最终走哪条路后再修。
Q4 初步判断:倾向 方案 A。ShopXO 原生防超卖机制比自建锁更可靠(DB 层面原子操作),且不破坏 ShopXO 生态完整性。
FrontendDev 初判(Q1-Q4 分析)
Q1 分析:方案 A 批量生成 SKU 路径
结论:可行,但实现路径复杂。
ShopXO spec_base 生成机制:
- 商品保存时,
GoodsService::Save()调用SpecService::Save()逐条写入sxo_goods_spec_base - 没有现成的批量 API — 需要在插件初始化/商品绑定时,批量调用
SpecService或直接 SQL INSERT - 方案 A 的 SKU 数量 = 座位数(一场演唱会可能 10000+ 个座位)
- 前端配合:uni-app 需要维护
seat_id → spec_base_id映射(已在spec_base_id_map中) - 关键风险:商品规格管理页面会显示 10000+ 行 SKU,可能导致 ShopXO 后台崩溃
- 解决方向:插件专用规格不出现在 ShopXO 原生规格管理页,通过 Hook 隐藏;建立独立的"座位 SKU 管理"页面
Q2 分析:商品 112 broken state 最小修复集
结论:需要立即修复,推荐最小方案。
根因:is_exist_many_spec=0 意味着 ShopXO 认为此商品无多规格,spec_base 表自然为空(从未生成过 SKU)。
最小修复路径(不破坏现有数据):
- 方案甲(最小侵入):在
plugins_service_goods_save_endHook 中,检测商品有venue_data且$vr-spec 存在时,强制将is_exist_many_spec设为 1,但不写 spec_base 表(绕过 ShopXO spec 机制,完全走插件自定义逻辑) - 方案乙(规范做法):调用
SpecService::Save()为每个座位生成一条 spec_base 记录(inventory=1, price 从 seat_type 读取)
推荐方案甲(最小修复):
- 优势:无需重建 SKU,不影响现有订单数据
- 代价:
is_exist_many_spec变成"脏 flag",但这是 ShopXO 的内部状态,插件不依赖它做业务 - 操作:一条 UPDATE + 一条 Hook 注入
Q3 分析:$vr- 前缀隐患
结论:低风险,但需实测确认。
ShopXO spec name 字段无字符过滤,数据库 varchar 类型允许 $ 字符。潜在风险点:
- ThinkPHP 的
__isset()/ 动态属性访问可能对$敏感(但 spec name 存 DB 而非 PHP 属性,低风险) - 前端模板渲染时,
$vr-字符串可能触发 Vue/JS 的变量插值解析({{ $vr-场馆 }})—— 这是真实风险 - ShopXO 原生规格管理页面可能将
$视为特殊字符处理
需要验证:uni-app 端 spec value 的渲染方式(是纯文本还是模板字符串?)
Q4 最终推荐:方案 A vs 方案 B
推荐:方案 A(每个座位一个 SPEC/SKU)
理由:
- 安全性:ShopXO 原生原子扣库存防超卖,经过大量生产验证;方案 B 的自建 FOR UPDATE 锁在高并发下有死锁风险
- 数据一致性:方案 A 的 stock = 1,ShopXO 购买流程自带事务保护;方案 B 的 Zone stock 需要插件自己维护一致性和并发安全
- 多 Zone 混买:方案 A 前端每 Zone 一个 goods_params 行,后端按 seat_id 原子购买,体验流畅;方案 B 前端分组但后端共享 Zone stock,反而增加了前端分组逻辑的复杂度
- 维护性:方案 A 依赖 ShopXO 原生机制,故障排查有据可查;方案 B 是"黑盒",出问题只能靠插件自己
- $vr- 前缀:spec_base_id_map 的 key 可以是 seat_id,无需改 ShopXO spec name 存储
方案 B 的唯一优势:SKU 数量少(Zone 数量 vs 座位数量),后台管理简单。但这个优势在演唱会 10000 座场景下不如安全和一致性重要。
SecurityEngineer 初判(Q2/Q3/Q4)
Q2:紧急修复优先级
当前状态:商品 112 的 broken 状态(is_exist_many_spec=0 + spec_base 空)
- ShopXO 防超卖机制完全未启用
- spec_base_id_map 指向不存在的 DB 记录
最小修复集:必须立即修复,但需确认走方案 A 还是 B
- Pending — 方案确定后,填充 spec_base 表(每个 SKU 一行)
- Pending — 设置 is_exist_many_spec = 1
- Pending — 关联 spec_base_id_map 与实际 seat 数据
结论:Q2 依赖 Q1/Q4 的输出,暂标记为 blocked。
Q3:$vr- 前缀安全隐患
已知事实:
- ShopXO spec name 允许特殊字符($、-、中文均无过滤)
- ThinkPHP 模板引擎(View)可能对 $ 有变量插值行为
风险点:
- View 层:Tpl 模板中
{:$spec_name}是否会解析 $vr- 作为 PHP 变量? - DB 层:spec name 入库是否经过转义?
- API 层:spec name 作为 JSON key 时是否安全?
结论:需要代码验证(Round 2 执行)。
Q4:方案 A vs B 最终推荐
初步倾向:方案 A(每个座位一个 SKU)
理由:
- 安全性:ShopXO 原生原子扣库存,无需自建锁,超卖风险最低
- 正确性:与 ShopXO SPEC 机制对齐,is_exist_many_spec=1 时原生防超卖生效
- 可追溯性:每个 SKU 独立订单项,核销链路清晰
BackendArchitect Round 2 深入分析(Q1+Q2)
详细分析见 docs/ROUND2_ANALYSIS.md。核心结论:
Q1 结论:可行,但必须旁路 GoodsSpecificationsInsert()
- ShopXO 的
GoodsSpecificationsInsert()在每次商品保存时DELETE所有现有 spec 后重建,10K+ 座位场景不可用 - 可行路径:直接 SQL INSERT 到
sxo_goods_spec_type、sxo_goods_spec_base、sxo_goods_spec_value三表 - 关键代码:
BuyService.php:1677-1681的dec()机制 = MySQL 条件原子扣减UPDATE SET inventory = inventory - N WHERE inventory >= N,ShopXO 防超卖依赖此机制 - TOCTOU 窗口极小(选座模式并发低 + InnoDB 行锁),推荐接受此风险
- 性能:10000 座位 = ~3-4 秒(需分批 500 条/批提交)
Q2 结论:推荐方案乙(最小修复集)
is_exist_many_spec=0→ 执行UPDATE goods SET is_exist_many_spec=1 WHERE id=112- 写入
$vr-规格维度到sxo_goods_spec_type - 幂等保护:票生成逻辑已有
spec_base_id冗余,不依赖 DB 引用
Q4 初步推荐:方案 A
- 原子性已验证(BuyService dec 机制)
- 数据完整性高(每个座位 inventory=1)
- 票务链路清晰(spec_base_id → 座位直接映射)
- 方案 B 的"SKU 少"优势在演唱会 10K+ 场景不成立(插件自管,不走 ShopXO 后台)
SecurityEngineer Round 2 分析(Q3)
SecurityEngineer 在 Round 2 进行了 ThinkPHP View 层的 $vr- 前缀安全审计,结论:无高危风险。
FrontendDev Round 2 深入分析
Q4 最终推荐:方案 A(每个座位一个 SPEC/SKU)—— 明确推荐
经过代码级验证后,确认推荐方案 A。
核心发现
发现 1:当前 ticket_detail.html 的 submit() 是 Plan B 模式
检查 shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html 第 413-418 行:
var goodsParams = JSON.stringify([{
goods_id: this.goodsId,
spec_base_id: this.sessionSpecId, // ← session spec_id,不是 seat spec_base_id
stock: this.selectedSeats.length, // ← seat count,不是 1
extension_data: extensionData
}]);
→ 当前实现是 Plan B(按场次买多个座位),specBaseIdMap 已声明但未接入 submit 逻辑。
发现 2:BuyService 依赖 spec_base 原子扣库存
BuyService.php 第 113-115 行:
$goods['price'] = (float) $goods_base['data']['spec_base']['price'];
$goods['original_price'] = (float) $goods_base['data']['spec_base']['original_price'];
$goods['spec_base_id'] = $goods_base['data']['spec_base']['id'];
→ ShopXO 购买流程从 spec_base 表读取库存并原子扣减。spec_base 为空时购买走裸商品逻辑(inventory 字段在 goods 表)。
方案 A vs B 最终对比
| 维度 | 方案 A(每座=SKU) | 方案 B(每 Zone=SKU) |
|---|---|---|
| 防超卖 | ShopXO 原生原子扣库存(stock=1),DB 层保证 | 自建 FOR UPDATE 锁,需自己写并发逻辑 |
| 实现复杂度 | 后端需批量生成 1 万+ SKU;前端 submit() 需改为逐座提交 |
后端简单;前端按 Zone 分组即可 |
| 多 Zone 混买 | 每座一行 goods_params,后端原子处理 | 前端分组但后端共享 Zone 库存,复杂度高 |
| 后台可维护性 | 10000+ SKU 行,但可 Hook 隐藏 | Zone 数量少,后台友好 |
| 调试/故障排查 | 每个 SKU 独立,可追溯 | 共享库存,出问题难以定位 |
| 与 ShopXO 生态 | 完全对齐,无缝集成 | 绕过 spec 校验,部分 ShopXO 功能失效 |
| 当前代码适配成本 | ticket_detail.html submit() 需重构 |
基本无需改动 |
Plan A 前端实现路径(ticket_detail.html)
关键修改:将 submit() 从"session-level 提交"改为"seat-level 逐座提交":
// Plan A: 每座一行 goods_params,逐座购买
this.selectedSeats.forEach(function(seat) {
var seatSpecBaseId = app.specBaseIdMap[seat.row + '_' + seat.col]?.spec_base_id;
// 如果 spec_base_id 存在,走 ShopXO 原生购买
// 否则走 Plan B 回退逻辑
});
specBaseIdMap 数据结构已就位(从后端 PHP 注入),前端只需接入即可。
Q3 验证:$vr- 前缀安全
结论:低风险,确认安全。
证据:
ticket_detail.html使用 ThinkPHP 模板{:$goods.title|default='...'}——$goods是 PHP 变量,不是模板表达式$vr_seat_template.seat_map是 PHP 对象访问,|json_encode|raw是模板过滤器链,|raw仅用于跳过 HTML 转义,不触发变量插值- ThinkPHP
{$var}默认转义输出;{:$expr}执行表达式但需要$var存在 $vr-作为 spec name 字符串存储在 DB 中,不作为 PHP 变量名
唯一需注意:ShopXO 后台规格管理页可能将 $vr- 显示不当(纯展示问题,不影响安全)。
Q2 前端视角最小修复
当前 ticket_detail.html 的 loadSoldSeats() 是 TODO,Plan A 需要:
- 后端生成 spec_base SKUs(BackendArchitect 负责)
- 前端
loadSoldSeats()调用 API 查询各 seat spec_base 的库存状态
最小可行路径:先让购买流程能跑通,再迭代优化。
行动项(优先级排序)
| 优先级 | 行动项 | 负责 |
|---|---|---|
| P0 | 紧急修复商品 112 broken state | BackendArchitect |
| P1 | 实现方案 A 批量 SKU 生成(GoodsSpecificationsInsert 直接 SQL) | BackendArchitect |
| P2 | 重构 ticket_detail.html submit() 接入 specBaseIdMap | FrontendDev |
| P3 | Hook 隐藏插件 SKU(spec_base_id_map key = seat_id) | FrontendDev |
共识投票
[CONSENSUS: YES] — Round 3 完成,所有 Q1-Q4 分析完成,最终决策报告已输出(方案 A)
Issue #9 执行计划 — Round 4(P0 修复)
执行日期:2026-04-15 | 目标:方案 A 全量落地
任务清单
- P0-A:
BaseService::initGoodsSpecs()— 修复商品 112 broken state[Claimed: BackendArchitect] - P0-B:
SeatSkuService::BatchGenerate()— 批量生成座位级 SKU[Claimed: BackendArchitect] - P1:
ticket_detail.htmlsubmit() 重构 — seat-level goods_params[Claimed: FrontendDev] - P1-Verification: 前端实测验证(商品 112 购买流程)
[Claimed: FrontendDev]
阶段划分
| 阶段 | 内容 | 负责 |
|---|---|---|
| Draft | BackendArchitect: P0-A + P0-B 实现;FrontendDev: submit() 重构 | 双线并行 |
| Review | 互相 review对方代码,确认接口对齐 | 双线并行 |
| Finalize | 合并到 main,实测验证 | 共同 |
依赖关系
- P0-A 完成后,P0-B 才能验证 spec_type 维度是否存在
- P0-A + P0-B 完成后,前端 submit() 重构才有正确的 spec_base_id 可用
- 前端实测依赖后端 SKU 已生成
P1 详细执行计划
当前状态(ticket_detail.html 第 413-418 行):
var goodsParams = JSON.stringify([{
goods_id: this.goodsId,
spec_base_id: this.sessionSpecId, // ← Zone 级别,只有 1 个
stock: this.selectedSeats.length, // ← 数量,但 ShopXO 不知道具体是哪些座位
extension_data: extensionData
}]);
重构目标:每座一行 goods_params:
// 每座一行,逐座提交
var goodsParamsList = [];
this.selectedSeats.forEach(function(seat) {
var seatKey = seat.row + '_' + seat.col;
var specBaseId = app.specBaseIdMap[seatKey]?.spec_base_id || app.sessionSpecId;
goodsParamsList.push({
goods_id: app.goodsId,
spec_base_id: specBaseId,
stock: 1,
extension_data: JSON.stringify({
attendee: attendees.find(function(a) { return a._seat === seatKey; }),
seat: seat
})
});
});
var goodsParams = JSON.stringify(goodsParamsList);
关键改动点:
submit()改为遍历selectedSeats,每座一行 goods_paramsspec_base_id从specBaseIdMap[seatKey]获取(Plan A:座位级 SKU)stock固定为 1(每个 SKU 对应一个座位)extension_data改为 seat-level,每座携带自己的信息- 保留
sessionSpecId作为 fallback(Plan B 回退)
P0-B 返回值接口约定
BackendArchitect 的 BatchGenerate() 返回值需包含:
[
'total' => 100, // 生成数量
'spec_base_id_map' => [ // seatKey → spec_base_id 映射
'A_1' => ['spec_base_id' => 2001, 'zone_id' => 'zone1', 'row' => 'A', 'col' => 1],
'B_2' => ['spec_base_id' => 2002, 'zone_id' => 'zone1', 'row' => 'B', 'col' => 2],
...
]
]
前端期望 specBaseIdMap 格式:
- Key:
row_col(如"A_1") - Value:
{spec_base_id: number, zone_id: string, row: string, col: number}