diff --git a/plan.md b/plan.md index e08398e..719efd7 100644 --- a/plan.md +++ b/plan.md @@ -42,10 +42,10 @@ ### FirstPrinciples 核心分析 **负责人**:council/FirstPrinciples **任务清单**: -- [ ] [Claimed: council/FirstPrinciples] **Task FP-1**: 多座位串行提交 — API 设计正交性分析 -- [ ] [Claimed: council/FirstPrinciples] **Task FP-2**: spec_base_id_map 复杂度质疑:是否存在更简单方案? -- [ ] [Claimed: council/FirstPrinciples] **Task FP-3**: 选座 → 购物车流程是否必要?直购是否更合适? -- [ ] [Claimed: council/FirstPrinciples] **Task FP-4**: 识别被忽略的关键目标(为什么需要 spec?为什么需要库存?) +- [x] [Done: council/FirstPrinciples] **Task FP-1**: 多座位串行提交 — API 设计正交性分析 +- [x] [Done: council/FirstPrinciples] **Task FP-2**: spec_base_id_map 复杂度质疑:是否存在更简单方案? +- [x] [Done: council/FirstPrinciples] **Task FP-3**: 选座 → 购物车流程是否必要?直购是否更合适? +- [x] [Done: council/FirstPrinciples] **Task FP-4**: 识别被忽略的关键目标(为什么需要 spec?为什么需要库存?) --- @@ -53,8 +53,8 @@ | 阶段 | 状态 | 说明 | |------|------|------| -| **Draft** | 🔄 进行中 | 各 Agent 读取核心文件,提交 findings | -| **Review** | ⬜ 待开始 | FirstPrinciples 汇总所有 findings | +| **Draft** | ✅ 完成 | 各 Agent 读取核心文件,提交 findings | +| **Review** | 🔄 进行中 | FirstPrinciples 汇总所有 findings | | **Finalize** | ⬜ 待开始 | 输出 `reviews/council-phase2-assessment.md` | --- diff --git a/reviews/FirstPrinciples-on-phase2-assessment.md b/reviews/FirstPrinciples-on-phase2-assessment.md new file mode 100644 index 0000000..796cf2b --- /dev/null +++ b/reviews/FirstPrinciples-on-phase2-assessment.md @@ -0,0 +1,203 @@ +# 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 需要 POST,submit() 用了 GET | + +**结论**:当前实现是一个「想用 GET 做 POST 的事」的混合方案。两步正确做法: +- **方案 A(表单 POST)**:创建隐藏 form,POST `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 系统,不需要引入实时已售座位更新。