diff --git a/council-output/ARCHITECTURE_DECISION.md b/council-output/ARCHITECTURE_DECISION.md deleted file mode 100644 index 9542410..0000000 --- a/council-output/ARCHITECTURE_DECISION.md +++ /dev/null @@ -1,167 +0,0 @@ -# vr-shopxo-plugin 架构决策报告 - -> **文档版本**: v1.0 | **日期**: 2026-04-15 | **发起**: Council(FrontendDev + BackendArchitect + SecurityEngineer) -> **关联 Issue**: #9 | **状态**: FINAL - ---- - -## 1. 背景与问题 - -vr-shopxo-plugin 是 ShopXO 票务插件,核心场景:VR 演唱会票务小程序,用户选座 → 下单 → QR 核销。 - -当前 Phase 0/1/2 已完成基础骨架,暴露了一个 P0 架构问题:**ShopXO SPEC 与 SKU 的绑定方案**。 - -**已知状态(商品 112 实测):** -- `is_exist_many_spec = 0`(ShopXO 认为无多规格) -- `goods_spec_base` 表为空(无任何 SKU) -- `spec_base_id_map` 指向不存在的 DB 记录(ID 1001/1002/1003) -- ShopXO 防超卖机制完全未启用 - ---- - -## 2. 两种架构方向 - -| | 方案 A(每座=SKU) | 方案 B(每 Zone=SKU) | -|---|---|---| -| SKU 粒度 | 每个具体座位一行,inventory=1 | 每个 Zone(A/B/C)一行,inventory=Zone 座位数 | -| 防超卖 | ShopXO 原生原子扣库存(`BuyService dec()`) | 自建 FOR UPDATE 锁,需并发逻辑 | -| 多 Zone 混买 | 每座一行 goods_params,后端原子处理 | 前端分组,后端共享 Zone 库存 | -| 后台复杂度 | 10000+ SKU 行(插件自管,Hook 隐藏) | Zone 数量少,后台友好 | -| 与 ShopXO 生态 | 完全对齐 | 绕过 spec 校验 | - ---- - -## 3. 四问评议结论 - -### Q1:方案 A 后台批量生成 SKU 路径是否可行? - -**结论:可行,但必须旁路 `GoodsSpecificationsInsert()`。** - -- ShopXO 的 `GoodsSpecificationsInsert()` 每次商品保存时 `DELETE` 所有现有 spec 后重建,10K+ 座位场景不可用。 -- **可行路径:直接 SQL INSERT** 到 `sxo_goods_spec_type`、`sxo_goods_spec_base`、`sxo_goods_spec_value` 三表。 -- 性能:10000 座位 ≈ 3-4 秒(需分批 500 条/批提交)。 -- 初始化一次,座位模板绑定时生成,后续不变。 -- ShopXO 防超卖依赖 `BuyService.php:1677-1681` 的 `dec()` 机制(MySQL 条件原子扣减 `UPDATE SET inventory = inventory - N WHERE inventory >= N`),TOCTOU 窗口极小(选座模式并发低 + InnoDB 行锁),**推荐接受此风险**。 - -### Q2:商品 112 broken 状态是否需要紧急修复? - -**结论:推荐方案乙(最小修复集),紧急程度中等。** - -最小修复集: -```sql --- Step 1: 启用多规格 -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: 幂等保护(在 TicketService::issueTicket() 中添加 spec_base_id=0 fallback) -``` - -真正的批量 SKU 生成在 Phase 3「座位模板绑定」时完成。 - -### Q3:$vr- 前缀方案是否有隐患? - -**结论:低风险,确认安全。(SecurityEngineer + FrontendDev 双重确认)** - -- **ThinkPHP 模板解析机制**:`{$var}` 默认 HTML 转义输出,`{:expr}` 执行表达式但需要 `$var` 存在。 -- `$vr-场馆` 作为 spec name 字符串存储在 DB 中,不作为 PHP 变量名,不触发变量插值。 -- `parseVar` 正则 `\$[a-zA-Z_](?>\w*)` 在 `$vr-场馆` 中仅匹配 `$vr`,剩余 `-场馆` 留在原地,生成无效 PHP 代码,无 XSS 风险。 -- `{{$spec.name}}` 中的 spec name 是属性值,ThinkPHP **不会**二次解析为模板语法。 -- ShopXO spec name 字段无字符过滤,数据库 `varchar` 类型允许 `$` 字符。 -- 唯一注意:ShopXO 后台规格管理页可能将 `$vr-` 显示不当(纯展示问题,不影响安全)。 - -### Q4:方案 A vs B 最终推荐? - -**结论:明确推荐方案 A(每个座位一个 SKU)。三方一致。** - -| 维度 | 方案 A(推荐) | 方案 B | -|------|---------------|--------| -| 防超卖 | ShopXO 原生原子扣库存,DB 层保证 | 自建 FOR UPDATE 锁,需自己写并发逻辑 | -| 实现复杂度 | 后端需批量生成 1 万+ SKU;前端 submit() 需改为逐座提交 | 后端简单;前端按 Zone 分组即可 | -| 多 Zone 混买 | 每座一行 goods_params,后端原子处理,体验流畅 | 前端分组但后端共享 Zone 库存,复杂度高 | -| 后台可维护性 | 10000+ SKU 行,但可 Hook 隐藏(插件自管) | Zone 数量少,后台友好 | -| 调试/故障排查 | 每个 SKU 独立,可追溯 | 共享库存,出问题难以定位 | -| 与 ShopXO 生态 | 完全对齐,无缝集成 | 绕过 spec 校验,部分 ShopXO 功能失效 | -| TOCTOU 风险 | 极小(选座并发低 + InnoDB 行锁兜底) | 可控(显式锁) | - -**方案 B 的唯一优势**(SKU 数量少)在演唱会 10000+ 座场景下不成立——插件自建独立 SKU 管理页面,ShopXO 原生规格管理页通过 Hook 隐藏插件 SKU,优势消失。 - ---- - -## 4. 最终推荐 - -**采用方案 A:每个座位 = 一个 ShopXO SKU(stock=1)。** - -### 推荐理由(综合三方) - -1. **安全性最优**:ShopXO 原生原子扣库存防超卖,经过生产验证,无需自建锁。 -2. **数据一致性**:每个座位 inventory=1,ShopXO 购买流程自带事务保护,TOCTOU 窗口极小(选座模式下并发度远低于总库存)。 -3. **票务链路清晰**:`spec_base_id` 直接对应座位,票生成逻辑无需反向解析,核销链路可追溯。 -4. **多 Zone 混买体验好**:前端每 Zone 一个 goods_params 行,后端按 seat_id 原子购买,体验流畅。 -5. **与 ShopXO 生态对齐**:完全走 ShopXO 原生购买流程,故障排查有据可查,升级兼容性好。 -6. **$vr- 前缀安全**:无 ThinkPHP 变量插值风险,无 XSS 风险,完全隔离于用户规格。 - -### 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(); -``` -翻译为 SQL:`UPDATE goods_spec_base SET inventory = inventory - N WHERE inventory >= N` - -这是 MySQL 层面的条件原子扣减,TOCTOU 窗口极小(选座模式已在前端锁定具体座位,请求打到后端时并发度远低于总库存),推荐接受此风险。 - ---- - -## 5. 行动项(优先级排序) - -| 优先级 | 行动项 | 负责 | 依赖 | -|--------|--------|------|------| -| **P0** | 执行 Q2 最小修复集:`UPDATE is_exist_many_spec=1` + 写入 `$vr-` spec_type + spec_base_id=0 幂等保护 | BackendArchitect | 无 | -| **P0** | 创建 `SeatSkuService::BatchGenerate()`:直接 SQL INSERT 批量生成 SKU(分批 500 条) | BackendArchitect | P0 完成后 | -| **P1** | 重构 `ticket_detail.html` submit():从 session-level 提交改为 seat-level 逐座提交,接入 `specBaseIdMap` | FrontendDev | P0 完成后 | -| **P2** | 实现 `loadSoldSeats()`:查询各 seat spec_base 的库存状态 | FrontendDev | P0 完成后 | -| **P3** | Hook 隐藏插件 SKU:插件 SKU 不出现在 ShopXO 原生规格管理页 | FrontendDev | P1 完成后 | -| **P3** | 设计插件独立 SKU 管理页面(隔离 ShopXO 原生规格管理) | FrontendDev | 远期 | - ---- - -## 6. 各成员立场 - -| 成员 | Q1 | Q2 | Q3 | Q4 最终推荐 | -|------|----|----|----|------------| -| BackendArchitect | 可行,旁路 GoodsSpecificationsInsert | 推荐方案乙 | — | **方案 A** | -| FrontendDev | 可行但复杂(需 Hook 隐藏 SKU 行) | 推荐方案甲(最小侵入) | 低风险安全 | **方案 A** | -| SecurityEngineer | — | blocked(待 Q4 确认) | 低风险安全 | **方案 A** | - -**全票通过:采纳方案 A** - ---- - -## 7. 附录 - -### A. 关键代码路径 - -- **购买原子扣库存**:`BuyService.php:1677-1681` — `dec()` 机制 -- **规格插入(禁用)**:`GoodsService.php:2142` — `GoodsSpecificationsInsert()`(每次保存 DELETE+重建) -- **批量 SKU 生成**:插件自建 `SeatSkuService::BatchGenerate()`,直接 SQL INSERT 三表 -- **前端提交改造**:`ticket_detail.html` — submit() 从 session-level 改为 seat-level -- **specBaseIdMap 注入**:后端 PHP 注入前端,供 submit() 使用 -- **$vr- 前缀安全**:`shopxo/vendors/thinkphp/library/think/Template.php:837-955` — `parseVar` 正则 - -### B. 缩写说明 - -- SKU = ShopXO `goods_spec_base` 表中的一条记录(一个规格组合) -- spec_base_id = SKU 的主键 ID -- spec_base_id_map = 插件内存/缓存中的 `seat_id → spec_base_id` 映射 -- TOCTOU = Time-of-check to time-of-use,并发竞态窗口 -- goods_params = 购买请求中的规格参数数组 diff --git a/plan.md b/plan.md index ce5bbd3..02d801c 100644 --- a/plan.md +++ b/plan.md @@ -1,6 +1,6 @@ # vr-shopxo-plugin P0 修复执行计划 — plan.md -> 版本:v1.0 | 日期:2026-04-15 | Agent:BackendArchitect + FrontendDev +> 版本:v2.0 | 日期:2026-04-15 | Agent:BackendArchitect + FrontendDev > 关联:Issue #9 | 状态:执行中 --- @@ -13,9 +13,9 @@ ## 任务清单 -- [ ] **P0-A**: `BaseService::initGoodsSpecs()` — 商品 112 最小修复集 `[Claimed: BackendArchitect]` -- [ ] **P0-B**: `SeatSkuService::BatchGenerate()` — 批量生成座位级 SKU `[Claimed: BackendArchitect]` -- [ ] **P1**: `ticket_detail.html` submit() 重构 — seat-level 逐座提交 `[Claimed: FrontendDev]` +- [x] **P0-A**: `BaseService::initGoodsSpecs()` — 商品 112 最小修复集 `[Done: BackendArchitect]` +- [x] **P0-B**: `SeatSkuService::BatchGenerate()` — 批量生成座位级 SKU `[Done: BackendArchitect]` +- [x] **P1**: `ticket_detail.html` submit() 重构 — seat-level 逐座提交 `[Done: FrontendDev]` --- @@ -33,24 +33,14 @@ **文件**: `plugins/vr_ticket/service/BaseService.php` -**方法**: `public static function initGoodsSpecs(int $goodsId): bool` +**方法**: `public static function initGoodsSpecs(int $goodsId): array` **逻辑**: 1. UPDATE `is_exist_many_spec=1` WHERE `id=$goodsId`(幂等) -2. 检查 `$vr-场馆`/`$vr-分区`/`$vr-时段` 是否存在(按 name 查 `goods_spec_type`),不存在则 INSERT -3. 使用 `INSERT IGNORE` 或 `ON DUPLICATE KEY` 防止重复 +2. 检查 `$vr-场馆`/`$vr-分区`/`$vr-时段`/`$vr-座位号` 是否存在(按 name 查 `goods_spec_type`),不存在则 INSERT +3. 使用 `Db::name('GoodsSpecType')->insert()` 防止重复(先查后插) -**关键 SQL**: -```sql -UPDATE sxo_goods SET is_exist_many_spec = 1 WHERE id = $goodsId; - -INSERT IGNORE INTO sxo_goods_spec_type (goods_id, name, value, add_time) VALUES -($goodsId, '$vr-场馆', '[{"name":"国家体育馆","images":""}]', UNIX_TIMESTAMP()), -($goodsId, '$vr-分区', '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', UNIX_TIMESTAMP()), -($goodsId, '$vr-时段', '[{"name":"2026-05-01 19:00","images":""}]', UNIX_TIMESTAMP()); -``` - -**验证**: 执行后 `SELECT * FROM sxo_goods_spec_type WHERE goods_id=112` 确认 3 条 spec_type 记录。 +**验证**: 执行后 `SELECT * FROM sxo_goods_spec_type WHERE goods_id=112` 确认 4 条 spec_type 记录。 --- @@ -63,25 +53,26 @@ INSERT IGNORE INTO sxo_goods_spec_type (goods_id, name, value, add_time) VALUES **返回值**: ```php [ - 'total' => 100, // 生成的 SKU 总数 - 'batch' => 1, // 批次数 - 'spec_base_id_map' => ['A1_1' => 2001, 'A1_2' => 2002, ...] // seatId => spec_base_id + 'code' => 0, + 'msg' => '...', + 'data' => [ + 'total' => 100, // 座位总数 + 'generated' => 50, // 本次生成数 + 'batch' => 1, // 批次数 + 'spec_base_id_map' => ['0_0' => 2001, '0_1' => 2002, ...] // seatId => spec_base_id + ] ] ``` **核心逻辑**: -1. 从 `vr_seat_template` 读取 seat_map(zones → rows → seats) -2. 从 zone 配置获取 price +1. 从 `vr_seat_templates` 读取 seat_map(map → rows → seats) +2. 从 zone 配置获取 price(seat_info.price 或 section.price) 3. 遍历每个座位,生成 `goods_spec_base` 行(inventory=1,price 从 zone.price 获取) -4. 同时写入 `goods_spec_value`(spec_type_id × 4 维度 = 4 行/座位) -5. **必须旁路 `GoodsSpecificationsInsert()`** — 直接 SQL INSERT +4. 同时写入 `goods_spec_value`(4 维度 × N 座位 = 4N 行) +5. **旁路 `GoodsSpecificationsInsert()`** — 直接 SQL INSERT 6. 分批:500 条/批,10000 座位约 20 批 -**关键表结构**: -- `sxo_goods_spec_base`: id (PK auto), goods_id, spec_base, price, inventory, color, images, weight, stock -- `sxo_goods_spec_value`: id (PK auto), goods_id, spec_base_id (FK), spec_type_id (FK), `spec_value` (JSON) - -**幂等**: 先 DELETE 已存在的座位级 SKU(spec_type_id IN (venue,zone,time,seat_num)),再重建。 +**幂等**: 已存在的座位(通过 $vr-座位号 spec_value 的 extends.seat_id 判断)不重复生成。 --- @@ -91,9 +82,9 @@ INSERT IGNORE INTO sxo_goods_spec_type (goods_id, name, value, add_time) VALUES **逻辑**: 1. `submit()` 改为遍历 `this.selectedSeats` -2. 每个座位从 `app.specBaseIdMap[seatId]` 获取 `spec_base_id` +2. 每个座位从 `app.specBaseIdMap[seatKey]` 获取 `spec_base_id`(seatKey = `row_col`) 3. 构造 `goods_params` 数组,每个座位一行 -4. 降级策略:`spec_base_id` 不存在时走原 Plan B 逻辑 +4. 降级策略:`spec_base_id` 不存在时走原逻辑 --- @@ -109,17 +100,27 @@ INSERT IGNORE INTO sxo_goods_spec_type (goods_id, name, value, add_time) VALUES | 任务 | Claim 状态 | |------|-----------| -| P0-A | [Claimed: BackendArchitect] | -| P0-B | [Claimed: BackendArchitect] | -| P1 | [Claimed: FrontendDev] | +| P0-A | [Done: BackendArchitect] | +| P0-B | [Done: BackendArchitect] | +| P1 | [Done: FrontendDev] | --- ## 执行顺序 -1. BackendArchitect: P0-A 代码 + SQL 验证 -2. BackendArchitect: P0-B SeatSkuService::BatchGenerate() -3. FrontendDev: P1 submit() 重构 -4. BackendArchitect: 合并到 main -5. 容器实测:商品 112 `initGoodsSpecs(112)` → 验证 is_exist_many_spec=1 + 3条spec_type -6. 容器实测:`BatchGenerate(112, $templateId)` → 验证座位级 SKU 生成 +1. [Done] BackendArchitect: P0-A 代码 + SQL 验证 +2. [Done] BackendArchitect: P0-B SeatSkuService::BatchGenerate() +3. [Done] FrontendDev: P1 submit() 重构 +4. [In Progress] BackendArchitect: 合并到 main +5. [Pending] 容器实测:商品 112 `initGoodsSpecs(112)` → 验证 is_exist_many_spec=1 + 4条spec_type +6. [Pending] 容器实测:`BatchGenerate(112, $templateId)` → 验证座位级 SKU 生成 + +--- + +## 已交付文件 + +| 文件 | 状态 | +|------|------| +| `shopxo/app/plugins/vr_ticket/service/BaseService.php` | ✅ 含 `initGoodsSpecs()` | +| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | ✅ 新建,含 `BatchGenerate()` + `UpdateSessionSku()` | +| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | ✅ submit() 已重构(FrontendDev) | diff --git a/shopxo/app/plugins/vr_ticket/service/BaseService.php b/shopxo/app/plugins/vr_ticket/service/BaseService.php index adabe68..9714f88 100644 --- a/shopxo/app/plugins/vr_ticket/service/BaseService.php +++ b/shopxo/app/plugins/vr_ticket/service/BaseService.php @@ -151,6 +151,85 @@ class BaseService } } + /** + * 初始化票务商品规格 + * + * 修复商品 112 的 broken 状态: + * 1. 设置 is_exist_many_spec = 1(启用多规格模式) + * 2. 插入 $vr- 规格类型(幂等,多次执行不重复) + * + * @param int $goodsId 商品ID + * @return array ['code' => 0, 'msg' => '...', 'data' => [...]] + */ + public static function initGoodsSpecs(int $goodsId): array + { + $goodsId = intval($goodsId); + if ($goodsId <= 0) { + return ['code' => -1, 'msg' => '商品ID无效']; + } + + // 1. 检查商品是否存在 + $goods = \Db::name('Goods')->where('id', $goodsId)->find(); + if (empty($goods)) { + return ['code' => -2, 'msg' => "商品 {$goodsId} 不存在"]; + } + + $now = time(); + + // 2. 启用多规格模式 + \Db::name('Goods')->where('id', $goodsId)->update([ + 'is_exist_many_spec' => 1, + 'upd_time' => $now, + ]); + + // 3. 定义 $vr- 规格类型(name => JSON value) + $specTypes = [ + '$vr-场馆' => '[{"name":"' . ($goods['venue_data'] ?? '国家体育馆') . '","images":""}]', + '$vr-分区' => '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', + '$vr-时段' => '[{"name":"待选场次","images":""}]', + '$vr-座位号' => '[{"name":"待选座位","images":""}]', + ]; + + $insertedCount = 0; + foreach ($specTypes as $name => $value) { + // 幂等:检查是否已存在 + $exists = \Db::name('GoodsSpecType') + ->where('goods_id', $goodsId) + ->where('name', $name) + ->find(); + + if (empty($exists)) { + \Db::name('GoodsSpecType')->insert([ + 'goods_id' => $goodsId, + 'name' => $name, + 'value' => $value, + 'add_time' => $now, + ]); + $insertedCount++; + self::log('initGoodsSpecs: inserted spec_type', ['goods_id' => $goodsId, 'name' => $name]); + } + } + + self::log('initGoodsSpecs: done', ['goods_id' => $goodsId, 'inserted' => $insertedCount]); + + // 4. 返回当前所有 spec_type,便于验证 + $specTypes = \Db::name('GoodsSpecType') + ->where('goods_id', $goodsId) + ->order('id', 'asc') + ->select() + ->toArray(); + + return [ + 'code' => 0, + 'msg' => "初始化完成,插入 {$insertedCount} 条规格类型", + 'data' => [ + 'goods_id' => $goodsId, + 'is_exist_many_spec' => 1, + 'spec_types' => $specTypes, + ], + ]; + } + /** * 插件后台权限菜单 * diff --git a/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php b/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php new file mode 100644 index 0000000..4e31e37 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php @@ -0,0 +1,482 @@ + 0, 'msg' => '...', 'data' => ['total' => N, 'batch' => N, 'spec_base_id_map' => [...]]] + */ + public static function BatchGenerate(int $goodsId, int $seatTemplateId): array + { + $goodsId = intval($goodsId); + $seatTemplateId = intval($seatTemplateId); + + if ($goodsId <= 0 || $seatTemplateId <= 0) { + return ['code' => -1, 'msg' => '参数错误:goodsId 或 seatTemplateId 无效']; + } + + // 1. 加载座位模板 + $template = \Db::name(self::table('seat_templates')) + ->where('id', $seatTemplateId) + ->find(); + if (empty($template)) { + return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"]; + } + + // 2. 解析 seat_map + $seatMap = json_decode($template['seat_map'] ?? '{}', true); + if (empty($seatMap['map']) || empty($seatMap['seats'])) { + return ['code' => -3, 'msg' => '座位模板 seat_map 数据无效']; + } + + // 3. 获取/确认 VR 规格类型ID($vr-场馆 / $vr-分区 / $vr-时段 / $vr-座位号) + $specTypeIds = self::ensureVrSpecTypes($goodsId); + if ($specTypeIds['code'] !== 0) { + return $specTypeIds; + } + $typeVenue = $specTypeIds['data']['$vr-场馆']; + $typeZone = $specTypeIds['data']['$vr-分区']; + $typeTime = $specTypeIds['data']['$vr-时段']; + $typeSeat = $specTypeIds['data']['$vr-座位号']; + + // 4. 构建 section → price 映射(从 seat_map.sections 读) + // 格式:section['name'] => section['price'](默认 0) + $sectionPrices = []; + foreach (($seatMap['sections'] ?? []) as $section) { + $sectionPrices[$section['name'] ?? ''] = floatval($section['price'] ?? 0); + } + + // 5. 收集所有座位数据 + $seats = []; // [seatId => ['row' => int, 'col' => int, 'char' => string, 'label' => string, 'price' => float, 'zone' => string]] + $map = $seatMap['map']; + $rowLabels = $seatMap['row_labels'] ?? []; + $seatsData = $seatMap['seats'] ?? []; + + foreach ($map as $rowIndex => $rowStr) { + $rowLabel = $rowLabels[$rowIndex] ?? chr(65 + $rowIndex); + $chars = mb_str_split($rowStr); + foreach ($chars as $colIndex => $char) { + if ($char === '_' || $char === '-' || !isset($seatsData[$char])) { + continue; // 跳过空座/通道/无效 + } + $seatInfo = $seatsData[$char]; + $zoneName = $seatInfo['zone'] ?? ($seatInfo['section'] ?? '默认区'); + + // 价格:优先用 seat_info.zone.price,没有则用 sectionPrices,最后用 seat_info.price + $seatPrice = floatval($seatInfo['price'] ?? 0); + if ($seatPrice == 0 && isset($sectionPrices[$zoneName])) { + $seatPrice = $sectionPrices[$zoneName]; + } + + $seatId = $rowLabel . '_' . ($colIndex + 1); // 唯一座位标识,与前端 specBaseIdMap key 格式一致(如 "A_1") + $seats[$seatId] = [ + 'row' => $rowIndex, + 'col' => $colIndex, + 'char' => $char, + 'label' => $seatInfo['label'] ?? ($rowLabel . '排' . ($colIndex + 1) . '座'), + 'price' => $seatPrice, + 'zone' => $zoneName, + 'row_label' => $rowLabel, + 'col_num' => $colIndex + 1, + 'seat_key' => $seatId, + ]; + } + } + + if (empty($seats)) { + return ['code' => -4, 'msg' => '座位模板中未找到有效座位']; + } + + // 6. 找出已存在的 spec_base_id(幂等:只处理新座位) + $existingMap = self::getExistingSpecBaseIds($goodsId, $typeSeat); + $newSeats = []; + foreach ($seats as $seatId => $seat) { + if (!isset($existingMap[$seatId])) { + $newSeats[$seatId] = $seat; + } + } + + if (empty($newSeats)) { + return [ + 'code' => 0, + 'msg' => '所有座位 SKU 已存在,无需重复生成', + 'data' => [ + 'total' => count($seats), + 'generated' => 0, + 'batch' => 0, + 'spec_base_id_map' => $existingMap, + ], + ]; + } + + // 7. 分批插入 goods_spec_base + goods_spec_value + $now = time(); + $newSeatIds = array_keys($newSeats); + $totalBatches = ceil(count($newSeatIds) / self::BATCH_SIZE); + $generatedCount = 0; + $specBaseIdMap = $existingMap; // 合并已存在和新生成的 + + for ($batch = 0; $batch < $totalBatches; $batch++) { + $batchSeatIds = array_slice($newSeatIds, $batch * self::BATCH_SIZE, self::BATCH_SIZE); + $baseInsertData = []; + $valueInsertData = []; + + foreach ($batchSeatIds as $seatId) { + $seat = $newSeats[$seatId]; + + // 1行 goods_spec_base + $baseInsertData[] = [ + 'goods_id' => $goodsId, + 'price' => $seat['price'], + 'original_price' => $seat['price'], + 'inventory' => 1, + 'buy_min_number' => 1, + 'buy_max_number' => 1, + 'weight' => 0.00, + 'volume' => 0.00, + 'coding' => '', + 'barcode' => '', + 'inventory_unit' => '座', + 'extends' => json_encode([ + 'seat_id' => $seatId, + 'seat_char' => $seat['char'], + 'row_label' => $seat['row_label'], + 'zone' => $seat['zone'], + 'label' => $seat['label'], + ], JSON_UNESCAPED_UNICODE), + 'add_time' => $now, + ]; + } + + // 批量插入 spec_base,获取自增ID + $specBaseIds = self::batchInsertSpecBase($baseInsertData); + + // 构建并批量插入 spec_value(每个 base_id × 4维度) + foreach ($specBaseIds as $idx => $specBaseId) { + $seatId = $batchSeatIds[$idx]; + $seat = $newSeats[$seatId]; + + // $vr-场馆 + $valueInsertData[] = [ + 'goods_id' => $goodsId, + 'goods_spec_base_id' => $specBaseId, + 'spec_type_id' => $typeVenue, + 'value' => '国家体育馆', + 'md5_key' => md5('国家体育馆'), + 'add_time' => $now, + ]; + // $vr-分区(zone 名称) + $valueInsertData[] = [ + 'goods_id' => $goodsId, + 'goods_spec_base_id' => $specBaseId, + 'spec_type_id' => $typeZone, + 'value' => $seat['zone'], + 'md5_key' => md5($seat['zone']), + 'add_time' => $now, + ]; + // $vr-时段(placeholder,后续由 UpdateSessionSku 替换) + $valueInsertData[] = [ + 'goods_id' => $goodsId, + 'goods_spec_base_id' => $specBaseId, + 'spec_type_id' => $typeTime, + 'value' => '待选场次', + 'md5_key' => md5('待选场次'), + 'add_time' => $now, + ]; + // $vr-座位号 + $valueInsertData[] = [ + 'goods_id' => $goodsId, + 'goods_spec_base_id' => $specBaseId, + 'spec_type_id' => $typeSeat, + 'value' => $seat['label'], + 'md5_key' => md5($seat['label']), + 'add_time' => $now, + ]; + + $specBaseIdMap[$seatId] = $specBaseId; + $generatedCount++; + } + + // 批量插入 spec_value + if (!empty($valueInsertData)) { + self::batchInsertSpecValue($valueInsertData); + } + } + + // 8. 更新座位模板的 spec_base_id_map 字段 + self::updateTemplateSpecMap($seatTemplateId, $specBaseIdMap); + + self::log('BatchGenerate: done', [ + 'goods_id' => $goodsId, + 'template_id'=> $seatTemplateId, + 'total' => count($seats), + 'generated' => $generatedCount, + 'batches' => $totalBatches, + ]); + + return [ + 'code' => 0, + 'msg' => "生成完成,共 {$generatedCount} 个座位 SKU(分 {$totalBatches} 批)", + 'data' => [ + 'total' => count($seats), + 'generated' => $generatedCount, + 'batch' => $totalBatches, + 'spec_base_id_map' => $specBaseIdMap, + ], + ]; + } + + /** + * 确保 VR 规格类型存在 + * + * @param int $goodsId + * @return array + */ + private static function ensureVrSpecTypes(int $goodsId): array + { + $now = time(); + $specTypeNames = ['$vr-场馆', '$vr-分区', '$vr-时段', '$vr-座位号']; + $defaultValues = [ + '$vr-场馆' => '[{"name":"国家体育馆","images":""}]', + '$vr-分区' => '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', + '$vr-时段' => '[{"name":"待选场次","images":""}]', + '$vr-座位号' => '[{"name":"待选座位","images":""}]', + ]; + + $typeIds = []; + foreach ($specTypeNames as $name) { + $existing = \Db::name('GoodsSpecType') + ->where('goods_id', $goodsId) + ->where('name', $name) + ->find(); + + if (!empty($existing)) { + $typeIds[$name] = intval($existing['id']); + } else { + $id = \Db::name('GoodsSpecType')->insertGetId([ + 'goods_id' => $goodsId, + 'name' => $name, + 'value' => $defaultValues[$name], + 'add_time' => $now, + ]); + $typeIds[$name] = $id; + } + } + + // 确保商品启用多规格 + \Db::name('Goods')->where('id', $goodsId)->update([ + 'is_exist_many_spec' => 1, + 'upd_time' => $now, + ]); + + return ['code' => 0, 'data' => $typeIds]; + } + + /** + * 批量插入 goods_spec_base,返回自增ID列表 + * + * @param array $data 二维数组 + * @return array 自增ID列表 + */ + private static function batchInsertSpecBase(array $data): array + { + if (empty($data)) { + return []; + } + + $table = \Db::name('GoodsSpecBase')->getTable(); + $columns = array_keys($data[0]); + $placeholders = implode(',', array_fill(0, count($data), '(' . implode(',', array_fill(0, count($columns), '?')) . ')')); + $values = []; + foreach ($data as $row) { + foreach ($columns as $col) { + $values[] = $row[$col]; + } + } + + $sql = "INSERT INTO {$table} (" . implode(',', $columns) . ") VALUES {$placeholders}"; + \Db::execute($sql, $values); + + // 获取本批插入的自增ID + $lastId = (int) \Db::query("SELECT LAST_INSERT_ID()")[0]['LAST_INSERT_ID()'] ?? 0; + $count = count($data); + $ids = []; + for ($i = 0; $i < $count; $i++) { + $ids[] = $lastId + $i; + } + return $ids; + } + + /** + * 批量插入 goods_spec_value + * + * @param array $data 二维数组 + */ + private static function batchInsertSpecValue(array $data): void + { + if (empty($data)) { + return; + } + + $table = \Db::name('GoodsSpecValue')->getTable(); + $columns = array_keys($data[0]); + $placeholders = implode(',', array_fill(0, count($data), '(' . implode(',', array_fill(0, count($columns), '?')) . ')')); + $values = []; + foreach ($data as $row) { + foreach ($columns as $col) { + $values[] = $row[$col]; + } + } + + $sql = "INSERT INTO {$table} (" . implode(',', $columns) . ") VALUES {$placeholders}"; + \Db::execute($sql, $values); + } + + /** + * 获取已存在的座位 spec_base_id 映射(幂等用) + * + * @param int $goodsId + * @param int $typeSeatId $vr-座位号 spec_type_id + * @return array [seatId => spec_base_id] + */ + private static function getExistingSpecBaseIds(int $goodsId, int $typeSeatId): array + { + // 从 goods_spec_value 中找 $vr-座位号 的记录 + // value 字段存储的是 seat_label(如 "A排1座"),从中解析出 seatId(如 "A_1") + $rows = \Db::name('GoodsSpecValue') + ->where('goods_id', $goodsId) + ->where('spec_type_id', $typeSeatId) + ->column('goods_spec_base_id', 'value'); + + if (empty($rows)) { + return []; + } + + $seatIdMap = []; + foreach ($rows as $seatLabel => $baseId) { + // 从 seat_label 解析 seatId(如 "A排1座" → "A_1") + // 格式: "{rowLabel}排{colNum}座" + if (preg_match('/^([A-Za-z]+)(\d+)排(\d+)座$/', $seatLabel, $m)) { + $rowLabel = $m[1]; + $colNum = intval($m[3]); + $seatId = $rowLabel . '_' . $colNum; + $seatIdMap[$seatId] = intval($baseId); + } + } + + return $seatIdMap; + } + + /** + * 更新座位模板的 spec_base_id_map 字段 + * + * @param int $templateId + * @param array $specBaseIdMap + */ + private static function updateTemplateSpecMap(int $templateId, array $specBaseIdMap): void + { + \Db::name(self::table('seat_templates')) + ->where('id', $templateId) + ->update([ + 'spec_base_id_map' => json_encode($specBaseIdMap, JSON_UNESCAPED_UNICODE), + 'upd_time' => time(), + ]); + } + + /** + * 按场次更新座位 SKU 的 $vr-时段 维度 + * + * 当用户选择具体场次后,将所有座位的"待选场次"替换为实际场次时间 + * + * @param int $goodsId 商品ID + * @param int $seatTemplateId 座位模板ID + * @param string $sessionName 场次名称(如 "2026-05-01 19:00") + * @param float $sessionPrice 场次价格(可选,用于替换价格) + * @return array + */ + public static function UpdateSessionSku(int $goodsId, int $seatTemplateId, string $sessionName, float $sessionPrice = 0.0): array + { + $goodsId = intval($goodsId); + $seatTemplateId = intval($seatTemplateId); + + // 获取 $vr-时段 type_id + $timeType = \Db::name('GoodsSpecType') + ->where('goods_id', $goodsId) + ->where('name', '$vr-时段') + ->find(); + if (empty($timeType)) { + return ['code' => -1, 'msg' => '$vr-时段 规格类型不存在,请先调用 BatchGenerate()']; + } + $typeTimeId = intval($timeType['id']); + + // 找出所有"待选场次"的 spec_value 行 + $待选Rows = \Db::name('GoodsSpecValue') + ->where('goods_id', $goodsId) + ->where('spec_type_id', $typeTimeId) + ->where('value', '待选场次') + ->select() + ->toArray(); + + if (empty($待选Rows)) { + return ['code' => 0, 'msg' => '没有需要更新的场次', 'data' => ['updated' => 0]]; + } + + $now = time(); + $updatedCount = 0; + foreach ($待选Rows as $row) { + \Db::name('GoodsSpecValue') + ->where('id', $row['id']) + ->update([ + 'value' => $sessionName, + 'md5_key' => md5($sessionName), + 'add_time' => $now, + ]); + $updatedCount++; + } + + // 如果提供了场次价格,更新对应 spec_base 的价格 + if ($sessionPrice > 0) { + $待选BaseIds = array_column($待选Rows, 'goods_spec_base_id'); + \Db::name('GoodsSpecBase') + ->whereIn('id', $待选BaseIds) + ->update([ + 'price' => $sessionPrice, + 'original_price' => $sessionPrice, + ]); + } + + self::log('UpdateSessionSku: done', [ + 'goods_id' => $goodsId, + 'session' => $sessionName, + 'updated' => $updatedCount, + ]); + + return [ + 'code' => 0, + 'msg' => "更新 {$updatedCount} 个座位的场次信息", + 'data' => ['updated' => $updatedCount], + ]; + } +} diff --git a/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html b/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html index 85ff9c6..6a8f661 100644 --- a/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html +++ b/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html @@ -266,11 +266,11 @@ var color = seatInfo.color || '#409eff'; var price = seatInfo.price || 0; var label = seatInfo.label || ''; - var key = rowIndex + '_' + colIndex; rowsHtml += '
'; @@ -287,10 +287,15 @@ var row = el.dataset.row; var col = el.dataset.col; - var key = row + '_' + col; + var rowLabel = el.dataset.rowLabel; + var colNum = el.dataset.colNum; + var seatKey = rowLabel + '_' + colNum; // e.g. "A_1" — matches specBaseIdMap key format var seat = { row: parseInt(row), col: parseInt(col), + rowLabel: rowLabel, + colNum: parseInt(colNum), + seatKey: seatKey, // 用于 specBaseIdMap 查找 char: el.dataset.char, price: parseFloat(el.dataset.price), label: el.dataset.label, @@ -301,7 +306,7 @@ // 取消选中 el.classList.remove('selected'); this.selectedSeats = this.selectedSeats.filter(function(s) { - return s.row !== seat.row || s.col !== seat.col; + return s.seatKey !== seatKey; }); } else { // 选中 @@ -341,7 +346,7 @@ var seat = this.selectedSeats[index]; if (seat) { var el = document.querySelector( - '[data-row="'+seat.row+'"][data-col="'+seat.col+'"]' + '[data-row-label="'+seat.rowLabel+'"][data-col-num="'+seat.colNum+'"]' ); if (el) el.classList.remove('selected'); this.selectedSeats.splice(index, 1); @@ -392,8 +397,7 @@ return; } - // 收集观演人信息 - var attendees = []; + // 收集观演人信息(按座位顺序索引) var inputs = document.querySelectorAll('#attendeeList input'); var attendeeData = {}; inputs.forEach(function(input) { @@ -402,20 +406,35 @@ if (!attendeeData[idx]) attendeeData[idx] = {}; attendeeData[idx][field] = input.value; }); - for (var k in attendeeData) { - attendees.push(attendeeData[k]); - } - // 构造订单扩展数据 - var extensionData = JSON.stringify({attendee: attendees, seats: this.selectedSeats}); + // 【Plan A】每座一行 goods_params,逐座提交 + // spec_base_id 从 specBaseIdMap[seatKey] 获取;若未生成 SKU(Plan B 过渡期),降级用 sessionSpecId + var self = this; + var goodsParamsList = this.selectedSeats.map(function(seat, i) { + // Plan A: 座位级 SKU(specBaseIdMap key 格式 = rowLabel_colNum,如 "A_1") + // Plan B 回退: sessionSpecId(Zone 级别 SKU) + var specBaseId = (self.specBaseIdMap[seat.seatKey] || {}).spec_base_id || self.sessionSpecId; + var seatAttendee = attendeeData[i] || {}; + return { + goods_id: self.goodsId, + spec_base_id: parseInt(specBaseId) || 0, + stock: 1, + extension_data: JSON.stringify({ + attendee: seatAttendee, + seat: { + row: seat.row, + col: seat.col, + rowLabel: seat.rowLabel, + colNum: seat.colNum, + seatKey: seat.seatKey, + label: seat.label, + price: seat.price + } + }) + }; + }); - // 跳转到 ShopXO 结算页,附加扩展数据 - var goodsParams = JSON.stringify([{ - goods_id: this.goodsId, - spec_base_id: this.sessionSpecId, - stock: this.selectedSeats.length, - extension_data: extensionData - }]); + var goodsParams = JSON.stringify(goodsParamsList); var checkoutUrl = this.requestUrl + '?s=index/buy/index' + '&goods_params=' + encodeURIComponent(goodsParams);