From 470ffdeec02e5fe296c08616da409ad0a736be45 Mon Sep 17 00:00:00 2001 From: Council Date: Tue, 21 Apr 2026 08:41:26 +0800 Subject: [PATCH] council(draft): BackendArchitect - Phase 2 technical assessment findings + merged council report Key findings: - Buy::Index() POST/GET dual-path: submit() uses GET (fails) instead of POST - Field name mismatch: goods_params vs goods_data - ShopXO spec matches by type:value, not spec_base_id direct - Recommend: hidden form POST, plugin API for sold-seats Co-Authored-By: Claude Sonnet 4.6 --- plan.md | 10 +- reviews/BackendArchitect-on-phase2.md | 229 ++++++++++++++++++++++++++ reviews/council-phase2-assessment.md | 218 ++++++++++++++++++++++++ 3 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 reviews/BackendArchitect-on-phase2.md create mode 100644 reviews/council-phase2-assessment.md diff --git a/plan.md b/plan.md index 44ef2cf..8ea9abf 100644 --- a/plan.md +++ b/plan.md @@ -11,11 +11,11 @@ | Task | 内容 | 状态 | |------|------|------| -| B1 | 分析 GoodsCartService::Save 的真实 API 契约(期望参数格式) | [Claimed: council/BackendArchitect] | -| B2 | 验证 ticket_detail.html submit() 的 params 构造是否符合规范 | [Claimed: council/BackendArchitect] | -| B3 | ShopXO spec 加载标准端点调研(GoodsSpecDetail 等) | [Claimed: council/BackendArchitect] | -| B4 | 在 ticket_detail.html 中优雅加载规格/库存的方案设计 | [Claimed: council/BackendArchitect] | -| B5 | 整合 BackendArchitect findings → `reviews/BackendArchitect-on-phase2.md` | Pending | +| B1 | 分析 GoodsCartService::Save 的真实 API 契约(期望参数格式) | [Done: council/BackendArchitect] | +| B2 | 验证 ticket_detail.html submit() 的 params 构造是否符合规范 | [Done: council/BackendArchitect] | +| B3 | ShopXO spec 加载标准端点调研(GoodsSpecDetail 等) | [Done: council/BackendArchitect] | +| B4 | 在 ticket_detail.html 中优雅加载规格/库存的方案设计 | [Done: council/BackendArchitect] | +| B5 | 整合 BackendArchitect findings → `reviews/BackendArchitect-on-phase2.md` | [Done: council/BackendArchitect] | ### FrontendDev diff --git a/reviews/BackendArchitect-on-phase2.md b/reviews/BackendArchitect-on-phase2.md new file mode 100644 index 0000000..8938c29 --- /dev/null +++ b/reviews/BackendArchitect-on-phase2.md @@ -0,0 +1,229 @@ +# BackendArchitect Phase 2 技术评估 Findings + +> Agent: council/BackendArchitect | Date: 2026-04-21 | Round 2 + +--- + +## B1: GoodsCartService::Save API 契约分析 + +### 结论:`Save()` 方法未找到,但找到了真正的下单入口 + +**关键发现**:ShopXO 的票务下单流程 **不经过购物车**,而是直接 POST 到 `index/buy/index`。 + +```php +// Buy.php Index() — 真正的入口 +public function Index() +{ + if($this->data_post) + { + // 将数据存储到缓存,以 user_id 为 key + BuyService::BuyDataStorage($this->user['id'], $this->data_post); + return MyRedirect(MyUrl('index/buy/index')); + } + // 读取缓存,展示订单确认页 + $buy_data = BuyService::BuyDataRead($this->user['id']); +} +``` + +**真正接收的数据结构**(来自 `BuyService::BuyInitialize`): +```php +// BuyService.php ~line 51 — 参数契约 +$p = [ + [ + 'checked_type' => 'empty', + 'key_name' => 'goods_data', // ← 核心字段 + 'error_msg' => MyLang('common_service.buy.buy_goods_data_error_tips'), + ], +]; +// goods_data 格式: +// [{goods_id, spec, stock, ...}] +// 或从 base64 解码:json_decode(base64_decode(urldecode($params['goods_data'])), true) +``` + +### BuyService::BuyInitialize 处理流程 + +```php +foreach($params['goods_data'] as $v) { + // 1. 规格解析 — GoodsSpecificationsHandle() + // 期望: {goods_id, spec: [{type, value}], stock, extension_data?, ...} + $goods['spec'] = self::GoodsSpecificationsHandle($v); + + // 2. 调用 GoodsService::GoodsSpecDetail(spec: [{type, value}]) + // ← 关键:这里通过 spec.value 匹配 GoodsSpecValue 表,而不是 spec_base_id + // 如果 spec 为空但商品有多规格,必须报错 + // 如果 spec 不为空但商品无规格,也必须报错 + + // 3. 从返回的 spec_base 获取 inventory, price, spec_base_id + $goods['inventory'] = $goods_base['data']['spec_base']['inventory']; + $goods['price'] = $goods_base['data']['spec_base']['price']; + $goods['spec_base_id'] = $goods_base['data']['spec_base']['id']; +} +``` + +### 结论(B1) + +**ShopXO 的 spec 匹配机制是 `type:value` 匹配,不是 `spec_base_id` 直接传递。** + +`GoodsSpecDetail` 内部逻辑: +1. 从 `params['spec']` 提取 `value` 数组 → `spec = array_column($params['spec'], 'value')` +2. `WHERE goods_id=X AND value IN (...)` 查询 `GoodsSpecValue` 表 → 得到 `goods_spec_base_id` +3. 从 `GoodsSpecBase` 读取最终规格记录 + +--- + +## B2: ticket_detail.html submit() 参数校验 + +### 当前代码(submit 函数) + +```javascript +var goodsParamsList = this.selectedSeats.map(function(seat, i) { + var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId; + return { + goods_id: self.goodsId, + spec_base_id: parseInt(specBaseId) || 0, // ← 直接传 ID + stock: 1, + extension_data: JSON.stringify({...}) + }; +}); +// 重定向到 checkoutUrl: +var checkoutUrl = this.requestUrl + '?s=index/buy/index' + + '&goods_params=' + encodeURIComponent(goodsParams); // ← 拼到 URL +``` + +**问题 1(严重)**:`goods_params` 是 URL 参数,而不是 POST body。 +- ShopXO `Buy::Index()` 通过 `$this->data_post` 判断是否 POST +- URL 参数在 `$_GET`,不在 `$_POST`,所以 `$this->data_post` 可能是 `false` +- 应该用 `
` 提交,或者用 JS `fetch('/?s=index/buy/index', {method:'POST', body: JSON.stringify(...)})` +- 当前的重定向方式 `$location.href = checkoutUrl` → GET 请求,无法触发 POST 分支 + +**问题 2(中等)**:`BuyService::BuyInitialize` 期望的字段是 `goods_data`,不是 `goods_params`。 + +**问题 3(严重)**:`BuyInitialize` 期望的 `spec` 格式是 `[{type, value}]`,不是 `spec_base_id`。 +- 当前代码直接传 `spec_base_id`,不经过 ShopXO 的规格匹配逻辑 +- ShopXO 会调用 `GoodsSpecDetail({id, spec: [{type, value}]})`,通过 `value` 匹配规格 +- 如果 `specBaseIdMap` 存储的是规格值而非 `{type, value}` 对象,则不兼容 + +**问题 4(中等)**:`extension_data` 不是标准字段,ShopXO 的订单系统不会处理。 + +--- + +## B3: ShopXO spec 加载标准端点 + +### 关键端点:GoodsService::GoodsSpecDetail + +**参数**: +```php +[ + 'id' => goods_id, + 'spec' => [ // ← 必须是 [{type, value}] 格式 + ['type' => '场次', 'value' => '2026-04-21 19:00'], + ['type' => '座位区', 'value' => 'A区'], + ], + 'stock' => 1 // 可选,数量 +] +``` + +**返回**: +```json +{ + "code": 0, + "data": { + "spec_base": { + "id": 2001, + "price": 599.00, + "inventory": 50, + "original_price": 799.00, + "buy_min_number": 1, + "buy_max_number": 5 + } + } +} +``` + +### spec 加载链路 + +1. **直接调用**(后端 PHP):`GoodsService::GoodsSpecDetail(['id'=>X, 'spec'=>[...]])` +2. **前端 AJAX**:ShopXO 有 API 端点 `api/goods/spec-detail`(需验证) +3. **ShopXO 标准流程**:用户选择规格 → 前端拼 `spec=[{type:'场次',value:'...'}]` → 提交 `goods_data` + +### spec 数据来源 + +`$goods_spec_data` 由 `SeatSkuService::GetGoodsViewData()` 传入前端(PHP 渲染): +```php +// ticket_detail.html 顶部 +var specData = || []; +// specData[0]: {spec_id, spec_name, price, ...} +``` + +--- + +## B4: ticket_detail.html 加载真实库存的方案 + +### 方案对比 + +| 方案 | 复杂度 | 实时性 | 风险 | +|------|--------|--------|------| +| A. 直接调用插件 API(AJAX) | 低 | 高 | 需新增插件端点 `/api/vr-ticket/sold-seats` | +| B. ShopXO 标准 spec 加载流程 | 中 | 高 | 需理解 ShopXO 规格匹配机制 | +| C. PHP 后端预渲染(当前) | 低 | 低 | 页面加载时已固定 | + +### 推荐方案(最小实现) + +**插件新增 API 端点**: +``` +GET /?s=api/vr-ticket/sold-seats&goods_id=X&spec_base_id=Y +Response: {sold_seats: ["A_1", "A_2", "B_5"]} +``` + +前端在选中场次后调用此接口,标记已售座位。 + +### 关于 ShopXO spec 机制的说明 + +VR 票务的 `spec_base_id_map` 存储的是每个座位对应的 `GoodsSpecBase.id`。但 ShopXO 的 `GoodsSpecDetail` 是通过 `{type, value}` 匹配规格的,不是直接接受 `spec_base_id`。 + +**这意味着**:如果 VR 票务已经生成了 `GoodsSpecBase` 记录,`GoodsSpecDetail` 可以通过 `spec=[{type:'座位', value:'A_1'}]` 来查询,但更直接的方式是让插件自己维护座位→规格的映射,并提供独立的 API。 + +--- + +## B5: 根因总结 + +### Issue 1(P0)— 购物车提交格式错误 + +**根因**:submit() 把 `goods_params` 拼到 URL(GET),但 `Buy::Index()` 只在 `$this->data_post` 时处理数据 → POST 分支永远不触发。 + +**其次**:`BuyService::BuyInitialize` 期望 `goods_data` 字段,且 `spec` 必须是 `[{type, value}]` 格式,而不是 `spec_base_id`。 + +**修复方案(后端)**: +1. 新增插件端点 `index/buy/index` 或 `api/vr-ticket/buy-direct`,专门处理 VR 票务的 POST 提交 +2. 或者修改 submit() 为表单 POST 提交,但需处理 ShopXO 的 CSRF 保护 + +### Issue 2(P1)— 缩放时舞台不跟随 + +**根因**:`.vr-stage` 在 `.vr-seat-rows` 容器外,CSS `transform: scale()` 只作用于容器内子元素。 + +**修复方案(前端)**:将 `.vr-stage` 移入 `.vr-seat-rows` 容器,或创建共享的 zoom wrapper(详见 FrontendDev findings)。 + +### Issue 3(P1)— spec 加载问题 + +**根因**:ShopXO 的规格匹配通过 `spec.value` 字符串匹配,而非直接接受 `spec_base_id`。VR 票务场景下,每个座位对应独立的 `GoodsSpecBase`,ShopXO 标准流程需要为每个座位生成 ShopXO 规格记录。 + +**修复方案**:插件需要维护座位→规格映射,并在选中场次后通过 AJAX 加载已售座位数据(新增插件 API)。 + +### Issue 4(P2)— 商品详情/图片加载 + +**根因**:ShopXO 商品详情页通过 `Goods.php` 的 `Index()` 方法加载,`$goods['content_web']` 等字段由 ShopXO 处理。 + +**修复方案**:需要确认 ticket_detail.html 是否需要 ShopXO 的商品内容渲染,如果需要,应该在插件模板中引入 ShopXO 的商品内容组件。 + +--- + +## 推荐的修复优先级 + +1. **P0(立即修复)**:Issue 1 — submit() 的 GET→POST 问题,导致下单无法工作 +2. **P1**:Issue 2 — 舞台缩放视觉问题 +3. **P1**:Issue 3 — spec 加载/已售座位显示 +4. **P2**:Issue 4 — 商品详情(可延后) + +--- + +*BackendArchitect findings — Round 2 完成* \ No newline at end of file diff --git a/reviews/council-phase2-assessment.md b/reviews/council-phase2-assessment.md new file mode 100644 index 0000000..1d3854a --- /dev/null +++ b/reviews/council-phase2-assessment.md @@ -0,0 +1,218 @@ +# Council Phase 2 技术评估报告 + +> 协作产出 | 日期:2026-04-21 +> 参与 Agent:BackendArchitect、FrontendDev、FirstPrinciples + +--- + +## 问题总览 + +| # | 问题 | 优先级 | 根因分类 | +|---|------|--------|---------| +| 1 | 购物车提交格式错误 | P0 | API 传递方式 + 参数契约不匹配 | +| 2 | 缩放时舞台元素不跟随 | P1 | DOM 结构导致 CSS transform 不共享 | +| 3 | spec 加载问题(已回滚) | P1 | ShopXO 规格匹配机制 + API 链路不明确 | +| 4 | 商品详情/图片加载 | P2 | 模板未引入 ShopXO 商品内容组件 | + +--- + +## Issue 1(P0):购物车提交格式错误 + +### 根因分析(三层) + +**第一层(致命)**:`location.href` 产生 GET 请求,但 `Buy::Index()` 只在 `$this->data_post` 时处理下单逻辑。 + +```php +// Buy.php:58-61 +public function Index() +{ + if($this->data_post) { + 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']); + } +} +``` + +→ `goods_params` URL 参数从未被读取,`BuyDataStorage` 从未被调用,`BuyDataRead` 返回空 → "商品数据为空"错误。 + +**第二层(严重)**:字段名不匹配。 +- 前端发送:`goods_params`(JSON 数组) +- ShopXO 期望:`goods_data`(JSON 数组) + +**第三层(中等)**:规格匹配机制不兼容。 +- 当前:`spec_base_id: parseInt(specBaseId)` — 直接传 ID +- ShopXO:`spec: [{type, value}]` — 通过 type:value 字符串匹配 GoodsSpecValue 表 + +### 推荐修复(前后端) + +**前端(BackendArchitect + FrontendDev 联合)**: + +```javascript +// 方案 A:隐藏表单 POST(最小化变更) +submit: function() { + var goods_data = this.selectedSeats.map(function(seat, i) { + return { + goods_id: self.goodsId, + spec: [{type: '座位', value: seat.seatKey}], // ← ShopXO 规格格式 + stock: 1, + extension_data: JSON.stringify({attendee: attendeeData[i], seat: {...}}) + }; + }); + + var form = document.createElement('form'); + form.method = 'POST'; + form.action = MyUrl('index/buy/index'); + var input = document.createElement('input'); + input.name = 'goods_data'; + input.value = JSON.stringify(goods_data); + form.appendChild(input); + document.body.appendChild(form); + form.submit(); +} +``` + +**后端(BackendArchitect)**: +- 方案 B(推荐):在插件中新增 `VrTicketBuy` 控制器,复用 BuyService 链路,但绕过 ShopXO 的 spec 匹配(直接通过 spec_base_id 查询) +- 方案 C:在 `BuyService::BuyInitialize` 中增加插件扩展点,允许 vr_ticket 插件注入自定义的规格处理逻辑 + +### API 设计建议 + +当前实现是「用 GET 做 POST 的事」。正确的做法是: +1. 隐藏表单 POST `goods_data` 到 `index/buy/index`(ShopXO 原生) +2. 或者插件新增端点,POST `goods_data` 到 `plugins/vr_ticket/buy-direct` + +--- + +## Issue 2(P1):缩放时舞台元素不跟随 + +### 根因分析 + +```html +
+
舞 台
+
+
+``` + +CSS `transform: scale()` 只作用于应用元素的子树。`.vr-stage` 和 `.vr-seat-rows` 是平级,没有共同的 transform 容器。 + +### 推荐修复(FrontendDev) + +**方案:将舞台和座位行包裹在同一 zoom 容器内** + +```html +
+
+
舞 台
+
+
+
+``` + +```css +.vr-zoom-container { + display: flex; + flex-direction: column; + align-items: center; + transform-origin: center top; + transition: transform 0.2s ease; +} +``` + +JS 缩放时,操作 `#zoomContainer` 的 `transform: scale(X)`,舞台和座位同步缩放。 + +**注意**:舞台的 `border-radius: 50% 50% 0 0 / 20px 20px 0 0` 弧形在缩放后可能变形,需要在 zoom 场景下调整。 + +--- + +## Issue 3(P1):spec 加载问题 + +### 根因分析 + +**问题 A**:ShopXO 的 `GoodsSpecDetail` 通过 `spec.value` 字符串匹配规格,而非直接接受 `spec_base_id`。 + +```php +// GoodsService.php:2749-2757 +$spec = array_column($params['spec'], 'value'); // ['A_1', 'VIP'] +$where['value'] = $spec; +$ids = Db::name('GoodsSpecValue')->where($where)->column('goods_spec_base_id'); +``` + +VR 票务场景下,每个座位对应独立的 `GoodsSpecBase` 记录(inventory=1)。要通过 ShopXO 标准流程加载,需要为每个座位生成 `GoodsSpecValue` 记录(type='座位', value='A_1')。 + +**问题 B**:插件 API 端点未建立,导致前端 `loadSoldSeats()` 是 TODO stub。 + +### 推荐修复 + +**后端(BackendArchitect)**:新增插件 API 端点 +``` +GET /?s=api/vr-ticket/sold-seats&goods_id=X&spec_base_id=Y +Response: {code: 0, data: {sold_seats: ["A_1", "A_2", "B_5"]}} +``` + +前端在选中场次后调用此接口,标记已售座位。 + +**关于 ShopXO spec 机制**:如果 VR 票务已经生成了 `GoodsSpecBase` 记录,最直接的方式是让插件维护座位→规格的映射,并提供独立的已售座位查询 API,而不是依赖 ShopXO 的规格匹配流程。 + +--- + +## Issue 4(P2):商品详情/图片加载 + +### 根因分析 + +`ticket_detail.html` 是插件独立模板,ShopXO 商品的 `content_web` 和图片数据由 `Goods.php Index()` 加载,但插件模板可能未正确引入这些数据。 + +### 推荐修复 + +确认 ticket_detail.html 是否需要 ShopXO 商品内容渲染。如果需要,应该在模板中引入 ShopXO 的商品内容组件: +- 商品详情:`$goods['content_web']` 由 GoodsService 处理 +- 商品图册:通过 `ResourcesService` 获取 + +如果票务 UI 不需要 ShopXO 商品内容区(票务详情页有自己的布局),则此问题可降级为「确认不需要」。 + +--- + +## 第一性原则视角的关键提醒(FirstPrinciples) + +1. **P0 的真正来源**:submit() 的 URL 重定向方式错了,修复后 Buy 链路本身是可用的——不需要重构 spec 系统。 + +2. **spec_base_id_map 是性能缓存**:不是业务必需。如果 `onOrderPaid` 能通过 seatKey 查询到 spec_base_id,map 可以去掉。保留它是合理的优化,但需要确保同步机制。 + +3. **购物车对票务无价值**:当前实现已经在用 Buy 链路,不是 Cart 链路。说明直觉上的「绕过购物车」需求其实不存在——只是 submit() 的传递方式错了。 + +4. **已售座位展示是 P1,不是 P0**:真正的 P0 是 `onOrderPaid` 防双售。前端是否实时显示已售状态,是体验优化,不是业务正确性的根本。 + +5. **多场次 bug**:`GetGoodsViewData()` 只返回第一个配置的场次(取 `validConfigs[0]`)。如果一个商品有多个场次配置,只显示第一个——这是潜在的 bug。 + +6. **最小修复范围**:只需修复 submit() 的传递方式(隐藏表单 POST),不需要重构 spec 系统,不需要引入实时已售座位更新(除非 spec 加载方案已实施)。 + +--- + +## 修复优先级与分工 + +| 优先级 | 问题 | 负责方 | 修复说明 | +|--------|------|--------|---------| +| P0 | Issue 1 submit() | BackendArchitect + FrontendDev | 改用隐藏表单 POST,复用 Buy 链路 | +| P1 | Issue 2 舞台缩放 | FrontendDev | 新增 zoom wrapper 容器 | +| P1 | Issue 3 spec 加载 | BackendArchitect | 新增插件 API 端点 | +| P2 | Issue 4 商品详情 | 延后 | 确认是否需要 | + +--- + +## 附录:ShopXO Buy 链路关键代码索引 + +| 文件 | 行号 | 说明 | +|------|------|------| +| `Buy.php` | 56-62 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead | +| `BuyService.php` | ~51 | BuyGoods — goods_data 参数校验 + base64 解码 | +| `BuyService.php` | ~173 | GoodsSpecificationsHandle — 规格解析 | +| `BuyService.php` | ~104-109 | GoodsSpecDetail 调用 — 通过 spec.value 匹配 | +| `GoodsService.php` | 2720-2795 | GoodsSpecDetail — type:value 查询 GoodsSpecValue | +| `BuyService.php` | 1932-1936 | BuyDataStorage — session 缓存(21600s TTL) | + +--- + +*Council Phase 2 技术评估报告 — 由 BackendArchitect、FrontendDev、FirstPrinciples 协作完成* \ No newline at end of file