14 KiB
vr-shopxo-plugin 架构决策评议 — plan.md
版本:v1.2(最终合并版)| 日期:2026-04-15 | Agent:council/FrontendDev + BackendArchitect + SecurityEngineer 关联:Issue #9 | 状态:FINAL
任务背景
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 |
| Q3 | vr- 前缀方案是否有隐患?ShopXO 内部是否对 有特殊处理? |
SecurityEngineer + FrontendDev |
| 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 + FrontendDev]✅ - Q4: 方案 A vs 方案 B 最终推荐
[Done: 所有成员]✅ — 三方一致推荐方案 A - Final:
council-output/ARCHITECTURE_DECISION.md[Done: FrontendDev]✅
Claim 状态
| 任务 | Claim 状态 |
|---|---|
| Q1 | [Done: BackendArchitect] |
| Q2 | [Done: BackendArchitect] |
| Q3 | [Done: SecurityEngineer] + [Done: FrontendDev] |
| Q4 | [Done: BackendArchitect] + [Done: FrontendDev] + [Done: SecurityEngineer] |
| 最终输出 | [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。
Q2 初步判断:当前 broken 状态暂不需要立即修复。购买流程走的是裸商品逻辑(is_exist_many_spec=0),需要明确购买流程最终走哪条路后再修。
Q4 初步判断:倾向 方案 A。ShopXO 原生防超卖机制比自建锁更可靠(DB 层面原子操作)。
FrontendDev 初判(Q1-Q4 分析)
Q1:结论:可行,但实现路径复杂。 无现成批量 API,需要插件自管(Hook 隐藏)。SKU 数量 = 座位数(10000+)。 Q2:结论:需要立即修复,推荐最小方案。 Q3:结论:低风险,但需实测确认。 Q4 推荐:方案 A(每个座位一个 SPEC/SKU)。安全性+数据一致性优先。
SecurityEngineer 初判(Q2/Q3/Q4)
Q2:依赖 Q1/Q4,标记为 blocked。
Q3:ThinkPHP View 层可能对 $ 有变量插值行为,需要代码验证(Round 2 执行)。
Q4:初步倾向 方案 A。
各成员 Round 2 深入分析
BackendArchitect Round 2 深入分析(Q1+Q2)
详细分析见 docs/ROUND2_ANALYSIS.md。
Q1 结论:可行,但必须旁路 GoodsSpecificationsInsert()
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 - TOCTOU 窗口极小(选座模式并发低 + InnoDB 行锁),推荐接受此风险
- 性能:10000 座位 ≈ 3-4 秒(需分批 500 条/批提交)
Q2 结论:推荐方案乙(最小修复集)
UPDATE goods SET is_exist_many_spec=1 WHERE id=112- 写入
$vr-规格维度到sxo_goods_spec_type - 幂等保护:票生成逻辑已有
spec_base_id冗余
Q4 初步推荐:方案 A
- 原子性已验证(BuyService dec 机制)
- 数据完整性高(每个座位 inventory=1)
- 票务链路清晰(spec_base_id → 座位直接映射)
SecurityEngineer Round 2 分析(Q3)
SecurityEngineer 在 Round 2 进行了 ThinkPHP View 层的 $vr- 前缀安全审计,结论:无高危风险。
FrontendDev Round 2 深入分析(Q3+Q4)
Q3 结论:$vr- 前缀安全 ✅
- ThinkPHP
{$var}默认做 HTML 转义,$vr- 不会被解析为 PHP 变量 |raw仅跳过 HTML 转义,不会执行变量插值- ThinkPHP parseVar 正则对连字符
-的处理会阻断 $vr- 的完整解析 - ShopXO spec name 存 DB 无过滤,但渲染层安全
Q4 最终推荐:方案 A(每个座位一个 SPEC/SKU)—— 明确推荐
核心发现:
- 当前
ticket_detail.htmlsubmit() 是 Plan B 模式,specBaseIdMap已声明但未接入 submit 逻辑 - ShopXO 购买流程从
spec_base表读取库存并原子扣减
方案 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 功能失效 |
Plan A 前端实现路径:
关键修改:将 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 注入),前端只需接入即可。
各成员 Round 3 最终推荐
BackendArchitect Round 3 最终推荐(Q1+Q2+Q4)
Q1 最终结论:可行。必须旁路 GoodsSpecificationsInsert(),走直接 SQL INSERT 路径。性能:10000 座位 ≈ 3-4 秒(分批 500 条/批)。关键:spec_base_id_map[seat_id] → actual_db_id 映射必须在 INSERT 后即时重建。
Q2 最终结论:推荐方案乙(最小修复集):
UPDATE sxo_goods SET is_exist_many_spec=1 WHERE id=112INSERT $vr- spec_type(场馆/分区/时段三行)- 幂等保护:
TicketService::issueTicket()中对spec_base_id=0做 fallback
Q3 最终结论(汇入 SecurityEngineer + FrontendDev 确认):低风险。ThinkPHP {$var} 默认 HTML 转义,$vr- 不会触发变量解析。
Q4 最终推荐:方案 A,理由汇总:
- ShopXO 原生原子防超卖:
BuyService::dec()= MySQL 条件原子扣减,无需自建锁 - TOCTOU 风险可接受:选座模式并发窗口极小,InnoDB 行锁提供最后保护
- 票务链路清晰:
spec_base_id直接映射座位,票生成无需反向解析 - 方案 B 优势不成立:插件自管 SKU(Hook 隐藏),不走 ShopXO 后台,无"管理困难"问题
FrontendDev Round 3 最终推荐(Q3+Q4)
三方一致推荐 方案 A(每个座位一个 ShopXO SKU)。
最终决策报告:council-output/ARCHITECTURE_DECISION.md
行动项(优先级排序)
| 优先级 | 行动项 | 负责 |
|---|---|---|
| P0 | 创建 SeatSkuService::BatchGenerate() — 直接 SQL INSERT 批量生成 SKU(分批 500 条) |
BackendArchitect |
| P0 | 执行 Q2 最小修复集:UPDATE is_exist_many_spec=1 + INSERT $vr- spec_type |
BackendArchitect |
| P1 | TicketService::issueTicket() 添加 spec_base_id=0 幂等保护 |
BackendArchitect |
| P1 | 重构 ticket_detail.html submit():接入 specBaseIdMap,改为 seat-level 逐座提交 |
FrontendDev |
| P2 | 实现 loadSoldSeats():查询各 seat spec_base 的库存状态 |
FrontendDev |
| P2 | Hook 隐藏插件专用 SKU(隔离 ShopXO 原生规格管理页) | FrontendDev |
| P3 | 设计插件独立 SKU 管理页面 | FrontendDev |
共识投票
| 成员 | CONSENSUS |
|---|---|
| BackendArchitect | [CONSENSUS: YES] — 推荐方案 A,Round 2/3 分析完成 |
| SecurityEngineer | [CONSENSUS: YES] — $vr- 前缀低风险,方案 A 推荐 |
| FrontendDev | [CONSENSUS: YES] — 方案 A 推荐,前端配合方案清晰 |
全票通过:采纳方案 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[Done: 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}
P1 实现记录
已完成的改动(ticket_detail.html):
renderSeatMap():为每个座位 div 新增data-row-label和data-col-num属性,用于生成与specBaseIdMapkey 格式一致的 seatKeytoggleSeat():将 seatKey 从rowIndex_colIndex(如"0_0")改为rowLabel_colNum(如"A_1"),匹配specBaseIdMapkey 格式removeSeat():改用[data-row-label][data-col-num]选择器查找座位元素submit():从 Zone 级别提交(1 行 goods_params)改为座位级提交(N 行 goods_params,每座 stock=1,spec_base_id 从specBaseIdMap[seat.seatKey]获取);降级兜底:若 specBaseIdMap 无对应 key,使用 sessionSpecId
seatKey 格式约定:
- 格式:
{rowLabel}_{colNum},例如"A_1","B_5" - 与 BackendArchitect
SeatSkuService::BatchGenerate()返回的spec_base_id_mapkey 格式保持一致