# Round 2 深入分析 — BackendArchitect

> 日期：2026-04-15
> 负责：Q1（批量 SKU 生成路径）+ Q2（紧急修复优先级）

---

## Q1：方案 A 后台批量生成 SKU 路径分析

### ShopXO SPEC/SKU 创建机制

通过代码审查 `GoodsService.php:2142` 的 `GoodsSpecificationsInsert()` 函数：

1. **删除再插入**：`GoodsSpecificationsInsert()` 在插入前会 `DELETE` 该商品的所有 `GoodsSpecType`、`GoodsSpecValue`、`GoodsSpecBase` 记录（line 2145-2147）
2. **逐行写入**：`GoodsSpecBase` 通过循环 `foreach($data['data'] as $v)` 逐条 `insertGetId`（line 2230），不是真正的批量 API
3. **无现成批量 API**：ShopXO 没有 `batchInsertSpecs()` 之类的公共方法
4. **必须旁路 GoodsSpecificationsInsert**：不能走 ShopXO 原生商品保存流程（否则每次都清空重建）

### 可行路径：直接 SQL INSERT

插件在座位模板绑定/初始化时，直接 SQL INSERT 三个表：

**Step 1**: 写入 `sxo_goods_spec_type`（规格维度）
```sql
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":""}]', UNIX_TIMESTAMP()),
(112, '$vr-时段', '[{"name":"2026-05-01 19:00","images":""}]', UNIX_TIMESTAMP());
```

**Step 2**: 写入 `sxo_goods_spec_base`（每个座位一行 SKU，inventory=1）
```sql
INSERT INTO sxo_goods_spec_base (goods_id, price, original_price, inventory,
  buy_min_number, buy_max_number, add_time) VALUES
(112, 680.00, 880.00, 1, 1, 1, UNIX_TIMESTAMP()),  -- A区 A-1
(112, 680.00, 880.00, 1, 1, 1, UNIX_TIMESTAMP()),  -- A区 A-2
... -- 10000+ 行
```

**Step 3**: 写入 `sxo_goods_spec_value`（建立 spec_base_id ↔ spec_value 的映射）
```sql
INSERT INTO sxo_goods_spec_value (goods_id, goods_spec_base_id, value, md5_key, add_time) VALUES
(112, @base_id_1, '国家体育馆', md5('国家体育馆'), UNIX_TIMESTAMP()),
(112, @base_id_1, 'A区', md5('A区'), UNIX_TIMESTAMP()),
(112, @base_id_1, '2026-05-01 19:00', md5('2026-05-01 19:00'), UNIX_TIMESTAMP());
-- 每个座位 3 条（对应3个spec维度）
```

**Step 4**: 更新 `sxo_goods` 的 `is_exist_many_spec = 1`（告诉 ShopXO 启用多规格）

### 关键发现：防超卖机制的原子性

审查 `BuyService.php:1677-1681`：

```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();
```

ThinkPHP 的 `dec()` 翻译为 SQL：`UPDATE goods_spec_base SET inventory = inventory - N WHERE inventory >= N`

这是**条件原子扣减**——在 MySQL 层是原子的。方案 A 依赖这个机制来防超卖。

**但存在 TOCTOU 窗口**：在并发极高（10K+ 同时抢票）时，两条请求可能同时通过 `inventory >= 1` 检查再同时执行 dec()。MySQL 的 InnoDB 行锁会串行化这两个 UPDATE，但不保证顺序——理论上可能出现：两人都查到 inventory=1，都通过检查，都执行 dec()，最终 inventory=-1。

**实际风险评估**：演唱会抢票场景是"选座"而非"随机库存"，用户选座时前端已经锁定了具体座位，请求打到后端时并发度远低于总库存。TOCTOU 窗口极小。**推荐接受此风险**。

### 性能估算

- 10000 座位 = 10000 条 `goods_spec_base` + 30000 条 `goods_spec_value`
- 单次批量 INSERT 耗时：~0.5-2 秒（InnoDB 批量插入效率高）
- **需要分批提交**：每批 500 条，避免单次大事务锁表超时
- **初始化一次**：座位模板绑定时生成，后续不变

### 结论

**Q1 结论：可行，但必须旁路 ShopXO 原生 `GoodsSpecificationsInsert()`，走直接 SQL INSERT 路径。**

---

## Q2：商品 112 broken 状态紧急修复优先级

### 当前状态分析

```
goods_id=112:
  is_exist_many_spec = 0    ← ShopXO 认为无多规格
  spec_base 表 = 空           ← 从未生成过 SKU
  spec_base_id_map → {A:1001, B:1002, C:1003}  ← 这些 ID 在 DB 里不存在！
```

### 影响评估

| 影响点 | 严重程度 | 说明 |
|--------|---------|------|
| 购买流程 | **高** | 当前 is_exist_many_spec=0，购买走裸商品逻辑，`spec_base_id_map` 形同虚设 |
| 票生成（onOrderPaid） | **高** | `spec_base_id` 指向不存在的 DB 记录，但代码有幂等保护，暂不崩溃 |
| ShopXO 后台显示 | 低 | 不影响 ShopXO 原生商品管理 |
| 用户端选座 | 低 | 前端/小程序逻辑独立 |

### 修复路径

**最小修复集**（方案甲）：仅设置 flag，不重建 SKU
```sql
UPDATE sxo_goods SET is_exist_many_spec = 1 WHERE id = 112;
```
然后在票生成逻辑中对 `spec_base_id=0` 做 fallback 保护。

**推荐修复集**（方案乙）：设置 flag + 重建 $vr- spec_type
```sql
-- Step 1: 告诉 ShopXO 启用多规格
UPDATE sxo_goods SET is_exist_many_spec = 1 WHERE id = 112;

-- 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());

-- Step 3: 重建 spec_base_id_map → seat_id 到 spec_base_id 的映射
-- （由插件 SeatSkuService 完成）
```

**注意**：spec_base 表不重建——因为真正的批量 SKU 生成是在 Phase 3「座位模板绑定」时做的，届时会走 SQL INSERT 路径。

### 结论

**Q2 结论：推荐方案乙——最小修复集 = UPDATE is_exist_many_spec + INSERT $vr- spec_type + 幂等保护。紧急程度中等，不影响当前票务逻辑运行，但应在 Phase 3 批量 SKU 生成前完成。**

---

## Q4 初步推荐（基于 Q1/Q2 分析）

**推荐方案 A（每个座位一个 SKU）**，理由补充：

1. **原子性已验证**：`BuyService.php` 的 dec() 机制是 MySQL 层面的条件原子扣减，方案 A 的防超卖完全依赖此机制，无需自建锁
2. **数据完整性**：每个座位独立 inventory=1，ShopXO 原生购买流程完整走通，无需 Hook 旁路购买逻辑
3. **票务链路清晰**：`spec_base_id` 直接对应座位，票生成逻辑无需反向解析
4. **TOCTOU 风险可接受**：选座模式并发窗口极小，ShopXO 行锁提供最后保护

**方案 B 的唯一优势**（SKU 数量少）在演唱会场景下不成立——方案 A 的批量 INSERT 一次性完成，不存在"管理困难"问题（插件自己管理，不走 ShopXO 后台）。

---

## 行动项（Round 2 输出）

| 优先级 | 行动项 | 负责 |
|--------|--------|------|
| P0 | 创建 `SeatSkuService::BatchGenerate()` — 直接 SQL INSERT 批量生成 SKU | BackendArchitect |
| P0 | 执行 Q2 最小修复集：UPDATE is_exist_many_spec + INSERT $vr- spec_type | BackendArchitect |
| P1 | 在 `TicketService::issueTicket()` 中添加 spec_base_id=0 的幂等保护 | BackendArchitect |
| P2 | 设计插件独立 SKU 管理页面（隔离 ShopXO 原生规格管理） | FrontendDev |
