vr-shopxo-plugin/reviews/FirstPrinciples-on-phase2-a...

204 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# FirstPrinciples — Phase 2 Technical Assessment
**Agent**: council/FirstPrinciples
**Date**: 2026-04-21
**Files analyzed**:
- `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
- `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php`
- `shopxo/app/service/GoodsCartService.php`
- `shopxo/app/service/BuyService.php`
- `shopxo/app/index/controller/Buy.php`
---
## FP-1: 多座位串行提交 — API 设计正交性分析
### 根因URL 重定向完全绕过了 ShopXO 的下单流程
**当前 submit() 行为**ticket_detail.html:439-442:
```javascript
var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
'&goods_params=' + encodeURIComponent(goodsParams);
location.href = checkoutUrl;
```
**ShopXO Buy::Index() 实际逻辑**Buy.php:56-74:
```php
public function Index()
{
if($this->data_post) {
// POST 时:存储到 session然后 redirect
BuyService::BuyDataStorage($this->user['id'], $this->data_post);
return MyRedirect(MyUrl('index/buy/index'));
} else {
// GET 时:从 session 读取,永远不用 URL 参数
$buy_data = BuyService::BuyDataRead($this->user['id']);
// ...
}
}
```
**断裂点**
1. `location.href` 产生 **GET 请求**,所以 `$this->data_post` 为空
2. `BuyDataStorage()` 从未被调用session 中没有任何数据
3. `BuyDataRead()` 返回空,订单确认页显示"商品数据为空"错误
4. URL 中的 `goods_params` **从未被读取**
**另外**`submit()` 发送的是 `goods_params`,但 `BuyDataStorage` / `BuyGoods` 期望的是 `goods_data`。参数名不匹配。
### API 设计正交性评估
| 设计决策 | 评估 | 问题 |
|---------|------|------|
| 多座位用 `goods_params` 数组 | ⚠️ 可行 | ShopXO BuyGoods 支持 goods_data 数组 |
| URL 传递购物数据 | ❌ 违反关注点分离 | URL 是导航用的,不是数据传递通道 |
| session 存储购买意图 | ✅ 正确 | 但 submit() 没有写入 session |
| redirect 后自读取 | ✅ 正确 | 但 redirect 需要 POSTsubmit() 用了 GET |
**结论**:当前实现是一个「想用 GET 做 POST 的事」的混合方案。两步正确做法:
- **方案 A表单 POST**:创建隐藏 formPOST `goods_data``Buy::Index()`
- **方案 B直接 API**POST JSON 到 `plugins/vr_ticket/index/buy` 自定义端点,自行调用 `BuyDataStorage`
---
## FP-2: spec_base_id_map 复杂度质疑
### 为什么要 map
`spec_base_id_map` 的语义是:`{seatKey: specBaseId}` — 把前端座位标识映射到 ShopXO 的 `goods_spec_base.id`
**问题:这个映射层是必要的吗?**
有两种消费方需要 spec_base_id
1. **前端 submit()** — 把 spec_base_id 发给 BuyService用于锁定库存
2. **后端 onOrderPaid()** — 验证座位是否被重复销售
**当前设计**
```
SeatSkuService::BatchGenerate → 生成 GoodsSpecBase 行 →
写入 seatTemplate.spec_base_id_map →
前端读取 → submit() 使用
```
**替代方案**(不需要 map
```
前端:只传 {goods_id, seatKey}
后端 onOrderPaid():按 seatKey 在 GoodsSpecValue 中查找对应的 spec_base_id
```
即:`spec_base_id` 是可以通过查询得到的,不需要提前存储在 map 中。
**spec_base_id_map 的额外成本**
- 存储冗余(每个座位行在模板表 + GoodsSpecBase 表中都有记录)
- 同步风险BatchGenerate 重新运行时,如果模板中 spec_base_id_map 被清空,前端拿到的是过时的 map
- 复杂度spec_base_id_map 的 key 格式(`A_1`)需要与前端 seatKey 格式严格一致
**spec_base_id_map 的合理存在理由**
- 如果 `onOrderPaid()` 的 seatKey → spec_base_id 查找太慢(数千座位时 JOIN 查询),缓存 map 是合理的性能优化
- 但当前实现中spec_base_id_map 的正确性完全依赖 BatchGenerate 没有失败
**结论**spec_base_id_map 是一个**性能缓存**,不是业务必需的。如果 spec 数量少(<1000),直接 JOIN 查询更简单正确。如果数量大(>5000才值得维护这个 map。
---
## FP-3: 选座 → 购物车流程是否必要?
### 问题重构
VR 演唱会票务是「强时效性单场次商品」:
- 用户选座 → 立即下单
- 不需要跨 session 持久化(今天选座,明天买)
- 不需要多件合并购买(演唱会票几乎不存在"加购"场景)
- 不需要 wishlist / 价格比较 / 购物车管理
**ShopXO 购物车的核心价值**(对标准电商):
1. 跨页面收集购买意向
2. 合并结算多店铺/多商品
3. 未登录时暂存选购
**VR 票务场景下,这些价值全部为零。**
### 购物车流程的额外成本
| 成本项 | 影响 |
|--------|------|
| 座位库存锁 | 需要考虑购物车超时释放 |
| 购物车页面 UI | 与票务流程无关 |
| 多座位串行提交逻辑 | 增加 submit() 复杂度 |
| 观演人信息持久化 | 隐私风险(暂存他人信息) |
### 直购方案的优点
如果绕过购物车,直接进入订单确认页:
- 消除购物车超时/锁座问题
- 减少 1 个跳转步骤(选座 → 订单确认 vs 选座 → 购物车 → 订单确认)
- 观演人信息只存在表单中,不落持久化存储
**但注意**ShopXO 的 `Buy::Index()` + `BuyService::BuyGoods()` 流程仍然可用,只是应该直接 POST到这个链路而不是绕弯子。
**结论**从第一性原则看票务场景不需要购物车。直接进入订单确认页Buy 链路)更简洁。但当前实现**已经在用 Buy 链路**(不是 Cart 链路),只是 submit() 的传递方式错了。修复 submit() 后,这个问题就不存在了。
---
## FP-4: 被忽略的关键目标
### 为什么需要 spec为什么需要库存
**当前的隐式假设**
1. 每个座位 = 一个 ShopXO spec_base 行inventory = 1
2. 用户下单时,通过 spec_base_id 锁定库存
**更深层的问题**
**问题 A库存一致性的真正来源是什么**
ShopXO 的 spec_base.inventory 由谁维护?
- `SeatSkuService::BatchGenerate` 写入 `inventory = 1`
- `SeatSkuService::refreshGoodsBase` 更新总库存
- **但用户下单后ShopXO 是否会原子性地将 spec_base.inventory 减 1**
如果不会,则 inventory 只是「参考值」真实库存安全需要靠业务层onOrderPaid保证。这意味着 spec_base.inventory 只是一个「建议库存」,而不是「锁定库存」。
**如果 onOrderPaid 才是真正的库存权威**,那么前端实时显示「已售座位」的价值就降低了——座位只在付款成功后才真正被占用。
**问题 B已售座位显示的用户体验价值**
`loadSoldSeats()` 当前是 TODO stub。如果不显示已售座位
- 用户可能选了 5 个座位,提交时才发现有 1 个已售
- 体验是「提交失败」而不是「选座时就知道」
**但**如果下单流程足够快5 秒内完成支付),用户在支付前选到已售座位的概率极低。「提交时返回错误」是可接受的降级体验。
**真正的 P0 是什么?**
无论是否显示已售座位,**后端必须在 onOrderPaid 层面防止双售**。这是业务正确性的根本。前端是否实时显示已售状态,是 P1 优化。
**问题 C多场次场景**
当前实现中,场次用 `goods_spec_data` 展示。但 `GetGoodsViewData()` 只返回第一个配置的场次(取 `validConfigs[0]`)。如果一个商品有多个场次配置,只显示第一个——这是 bug不是设计。
**问题 D为什么用 ShopXO 的 spec 系统?**
核心问题被掩盖在「我们必须用 ShopXO」的前提下了。真正的选择是
- **方案 1当前**:把座位映射到 ShopXO spec_base每个座位一行
- **方案 2**ShopXO 商品只表示「演出票」这个品类,座位管理完全在 vr_ticket 插件自己的表中ShopXO order_goods 中的数量=座位数,不区分具体座位
方案 2 避免了 spec_base_id_map 的复杂性,座位验证全在 onOrderPaid 中完成。代价是需要自行维护座位状态表。
---
## 汇总:第一性原则视角的关键提醒
1. **submit() 的 URL 重定向是根本性 bug**P0需要改成表单 POST 或直接 API 调用。修复后 Buy 链路本身是可用的。
2. **spec_base_id_map 是路径依赖**,不是必需的设计。如果 onOrderPaid 能通过 seatKey 查询到 spec_base_id则 map 可以去掉。保留它是合理的性能优化,但需要确保同步机制。
3. **购物车对票务无价值**,但当前实现已经在用 Buy 链路,不是 Cart 链路——说明直觉上的「绕过购物车」需求其实不存在,只是 submit() 的传递方式错了。
4. **已售座位展示是 P1不是 P0**。真正的 P0 是 onOrderPaid 防双售——无论前端是否显示已售,后端必须在付款时保证座位唯一性。
5. **GetGoodsViewData() 只返回第一个配置的场次**——这是一个潜在的 bug影响多场次商品。
6. **最小修复范围**:只需修复 submit() 的传递方式(表单 POST不需要重构 spec 系统,不需要引入实时已售座位更新。