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

9.0 KiB
Raw Blame History

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:

var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
    '&goods_params=' + encodeURIComponent(goodsParams);
location.href = checkoutUrl;

ShopXO Buy::Index() 实际逻辑Buy.php:56-74:

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_dataBuy::Index()
  • 方案 B直接 APIPOST 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每个座位一行
  • 方案 2ShopXO 商品只表示「演出票」这个品类,座位管理完全在 vr_ticket 插件自己的表中ShopXO order_goods 中的数量=座位数,不区分具体座位

方案 2 避免了 spec_base_id_map 的复杂性,座位验证全在 onOrderPaid 中完成。代价是需要自行维护座位状态表。


汇总:第一性原则视角的关键提醒

  1. submit() 的 URL 重定向是根本性 bugP0需要改成表单 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 系统,不需要引入实时已售座位更新。