Compare commits
15 Commits
cd975797e3
...
d7d7b33c96
| Author | SHA1 | Date |
|---|---|---|
|
|
d7d7b33c96 | |
|
|
5e9c111370 | |
|
|
93b70d4d50 | |
|
|
a2fb70d216 | |
|
|
22afafa1e1 | |
|
|
b4a94f832a | |
|
|
1d7f600675 | |
|
|
a3ef16034e | |
|
|
78b699eab4 | |
|
|
e5814c3bd4 | |
|
|
62553ab9f7 | |
|
|
0eb8adbf71 | |
|
|
fe457eee23 | |
|
|
e2008e2778 | |
|
|
5a047936e6 |
|
|
@ -1,150 +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- 前缀方案是否有隐患?
|
||||
|
||||
**结论:低风险,确认安全。**
|
||||
|
||||
- ThinkPHP 模板 `{:$goods.title|default='...'}` 中 `$goods` 是 PHP 变量引用,`$vr-` 作为 spec name 字符串存储在 DB 中,不作为 PHP 变量名,不触发变量插值。
|
||||
- ThinkPHP `{$var}` 默认转义输出;`{:$expr}` 执行表达式但需要 `$var` 存在,`$vr-` 作为字符串字面量不会解析。
|
||||
- 唯一注意:ShopXO 后台规格管理页可能将 `$vr-` 显示不当(纯展示问题,不影响安全)。
|
||||
- ShopXO spec name 字段无字符过滤,数据库 `varchar` 类型允许 `$` 字符。
|
||||
|
||||
### 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 行锁兜底) | 可控(显式锁) |
|
||||
|
||||
**三方一致推荐方案 A**(BackendArchitect + FrontendDev + SecurityEngineer)。
|
||||
|
||||
---
|
||||
|
||||
## 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 风险,完全隔离于用户规格。
|
||||
|
||||
### 方案 B 的唯一优势
|
||||
|
||||
SKU 数量少(Zone 数量 vs 座位数量),后台管理简单。但这个优势在演唱会 10000+ 座场景下不成立——插件自建独立 SKU 管理页面,ShopXO 原生规格管理页通过 Hook 隐藏插件 SKU,优势消失。
|
||||
|
||||
---
|
||||
|
||||
## 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** |
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录
|
||||
|
||||
### A. 关键代码路径
|
||||
|
||||
- **购买原子扣库存**:`BuyService.php:1677-1681` — `dec()` 机制
|
||||
- **规格插入(禁用)**:`GoodsService.php:2142` — `GoodsSpecificationsInsert()`(每次保存 DELETE+重建)
|
||||
- **批量 SKU 生成**:插件自建 `SeatSkuService::BatchGenerate()`,直接 SQL INSERT 三表
|
||||
- **前端提交改造**:`ticket_detail.html:413-418` — submit() 从 session-level 改为 seat-level
|
||||
- **specBaseIdMap 注入**:后端 PHP 注入前端,供 submit() 使用
|
||||
|
||||
### 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,并发竞态窗口
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
# 甲方新需求文档(2026-04-15)
|
||||
|
||||
## 来源
|
||||
|
||||
2026-04-15 下午,甲方补充需求,已与大头确认。
|
||||
|
||||
---
|
||||
|
||||
## 需求内容
|
||||
|
||||
### 需求 1:多座位单订单
|
||||
一个订单可包含多个座位,每个座位生成独立核销码。
|
||||
|
||||
**技术要求**:
|
||||
- 每个座位 = 一个 ShopXO SKU(spec_base_id),stock=1
|
||||
- 一次购买多个不同座位 = 多个 goods_params 条目(同一 goods_id + 不同 spec_base_id)
|
||||
- ShopXO 原生支持多 spec_base_id 同单购买,各生成独立 order_goods 行 ✅
|
||||
|
||||
### 需求 2:核销码卡夹展示(订单详情页)
|
||||
订单详情展示多个 QR 核销码,交互如下:
|
||||
- 手动滑切换(类似轮播,但手动)
|
||||
- 每个 QR 独立状态:已核销 → 灰掉
|
||||
- 自动切换到下一张未核销的 QR
|
||||
- 买了 N 个座位 → 显示 N 个 QR
|
||||
|
||||
**技术要求**:
|
||||
- 多行 `order_goods` → 多张 `vr_tickets` QR
|
||||
- 前端轮播组件(uni-app)
|
||||
- Realtime 订阅:核销状态变更 → 前端自动更新
|
||||
|
||||
### 需求 3:商品级必填信息配置
|
||||
商品 `ext` 字段声明购买时用户需填写的必填信息。
|
||||
|
||||
**字段设计(建议)**:
|
||||
```json
|
||||
{
|
||||
"required_fields": ["id_card", "phone"],
|
||||
"field_labels": {
|
||||
"id_card": "身份证号",
|
||||
"phone": "手机号"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑**:
|
||||
- `ext` 为空 → 下单不弹窗,直接购买
|
||||
- `ext` 有内容 → 弹窗要求填写,填写后附在订单备注
|
||||
|
||||
### 需求 4:手机号自动填充(订单级)
|
||||
- 默认自动填微信认证手机号(wx.getPhoneNumber),可编辑
|
||||
- 手机号是**购买凭据**(售后定位用)
|
||||
- **同一订单多个座位/核销码 → 只需填一份联系信息**(订单级,非座位级)
|
||||
|
||||
---
|
||||
|
||||
## ShopXO 多 Spec_base_id 同单购买验证
|
||||
|
||||
### 源码分析结论
|
||||
|
||||
**问题**:ShopXO 能否在同一次购买中,用同一个 `goods_id` + 不同 `spec_base_id`,各买 1 个?
|
||||
|
||||
**结论:✅ 支持**
|
||||
|
||||
### 源码证据
|
||||
|
||||
**BuyService.php 关键路径**:
|
||||
|
||||
```
|
||||
goods_params = [
|
||||
{ goods_id: 112, spec_base_id: 1001, stock: 1 }, ← 座位A1
|
||||
{ goods_id: 112, spec_base_id: 1002, stock: 1 } ← 座位B2
|
||||
]
|
||||
```
|
||||
|
||||
1. **BuyGoods()**:`foreach($params['goods_data'] as $v)` → 每个 goods_params 条目 → 一个 `$data[]` 元素
|
||||
2. **OrderSplitService::Run()**:按 warehouse 分组(非 goods_id 合并)→ 不同 spec_base_id 保留为不同 goods_items[]
|
||||
3. **OrderInsert()**:`foreach($v['goods_items'] as $vs)` → 每个 goods_items 条目 → **一行 order_goods**
|
||||
|
||||
```php
|
||||
// BuyService.php:786
|
||||
foreach($v['goods_items'] as $vs)
|
||||
{
|
||||
$order['detail_data'][] = [
|
||||
'goods_id' => $vs['goods_id'],
|
||||
'price' => $vs['price'],
|
||||
'buy_number' => intval($vs['stock']), // = 1
|
||||
// ...
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**结果**:
|
||||
- goods_id=112, spec_base_id=1001 → order_goods 第1行(座位A1)
|
||||
- goods_id=112, spec_base_id=1002 → order_goods 第2行(座位B2)
|
||||
- 两个座位,同一订单,各生成独立 vr_tickets QR ✅
|
||||
|
||||
### 与需求对应关系
|
||||
|
||||
| 甲方需求 | 技术实现 | 状态 |
|
||||
|---------|---------|------|
|
||||
| 多座位单订单 | 每座位 = 独立 spec_base_id,stock=1 | ✅ |
|
||||
| 多核销码 | 多行 order_goods → 多张 vr_tickets QR | ✅ |
|
||||
| ext 必填字段 | extension_data.required_fields | ✅ |
|
||||
| 手机号订单级 | 联系信息挂 order 备注,非 goods_params | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 当前数据库状态(已验证)
|
||||
|
||||
```sql
|
||||
-- 商品 112 票务商品
|
||||
is_exist_many_spec = 0 -- ShopXO 认为无多规格
|
||||
spec_base 表 = 空的 -- 没有任何 SKU
|
||||
|
||||
-- vr_seat_templates.spec_base_id_map
|
||||
-- {"A": 1001, "B": 1002, "C": 1003} ← 这些 ID 在 DB 里不存在!
|
||||
```
|
||||
|
||||
**问题**:ShopXO 防超卖机制完全未启用,购买走裸商品逻辑。
|
||||
|
||||
---
|
||||
|
||||
## spec_value 绑定方案($vr- 前缀)
|
||||
|
||||
### 方案已确认
|
||||
|
||||
ShopXO spec name 允许特殊字符($,-,中文),无字符过滤。
|
||||
|
||||
### 插件专用规格命名
|
||||
|
||||
```
|
||||
$vr-场馆 → 场馆名称(如 $vr-场馆 = "鸟巢")
|
||||
$vr-分区 → 座位分区(Zone)
|
||||
$vr-时段 → 场次时间
|
||||
```
|
||||
|
||||
### 为什么不会与用户规格冲突
|
||||
|
||||
- 插件票务商品使用自定义模板 `ticket_detail.html`
|
||||
- 前端 UI 不走 ShopXO 默认规格选择器
|
||||
- 用户无法通过默认界面触碰到 `$vr-` 规格
|
||||
|
||||
---
|
||||
|
||||
## 方案 A(每个座位一个 SPEC)兼容性
|
||||
|
||||
**结论:方案 A 完全兼容甲方全部 4 项新需求**
|
||||
|
||||
| 需求 | 方案 A 如何满足 |
|
||||
|-----|---------------|
|
||||
| 多座位单订单 | 每座位 = SKU,ShopXO 原生支持多 SKU 同单 ✅ |
|
||||
| 核销码卡夹 | order_goods × N → vr_tickets × N → N 张 QR ✅ |
|
||||
| ext 必填字段 | goods.extension_data.required_fields ✅ |
|
||||
| 手机号订单级 | 联系信息不写在 goods_params,写在 order 备注 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
- [ ] Issue #9:方案 A vs B 最终决策
|
||||
- [ ] 紧急修复:is_exist_many_spec → 1 + 正确生成每个座位的 SKU
|
||||
- [ ] 后台批量创建 SKU 实现(方案 A 关键路径)
|
||||
- [ ] ext.required_fields 前端弹窗实现
|
||||
- [ ] 订单详情核销码卡夹组件
|
||||
- [ ] 微信手机号自动填充 API 集成
|
||||
371
plan.md
371
plan.md
|
|
@ -1,33 +1,26 @@
|
|||
# vr-shopxo-plugin 架构决策评议 — plan.md
|
||||
# vr-shopxo-plugin P0 修复执行计划 — plan.md
|
||||
|
||||
> 版本:v1.1(合并版)| 日期:2026-04-15 | Agent:council/FrontendDev + BackendArchitect + SecurityEngineer
|
||||
> 关联:Issue #9
|
||||
> 版本:v3.0 | 日期:2026-04-15 | Agent:BackendArchitect + FrontendDev
|
||||
> 关联:Issue #9 | 状态:待合并
|
||||
|
||||
## Bug Fixes (Round 3 Review)
|
||||
|
||||
- [x] **Fix-1**: `SeatSkuService.php` — 修复 `getExistingSpecBaseIds()` 中 seat label 解析正则,`(\d+)排(\d+)座` → `(\d+)排(\d)座`(正则多捕获了1个数字,导致对"A排10座"等座位ID无法正确解析)`[Done: BackendArchitect]`
|
||||
- [x] **Fix-2**: `ticket_detail.html` — 修复 `submit()` 中 `specBaseIdMap[seatKey]` 访问方式,`(obj||{}).spec_base_id` → 直接取数值(PHP 返回的是整数而非对象)`[Done: BackendArchitect]`
|
||||
|
||||
---
|
||||
|
||||
## 任务背景
|
||||
|
||||
Phase 0/1/2 已完成基础骨架,暴露了一个 P0 架构问题:VR 演唱会票务商品中 ShopXO SPEC 与 SKU 的绑定方案。
|
||||
|
||||
**已知事实:**
|
||||
- ShopXO `goods_spec_base`(SKU表)当前为空,商品 112 的 `is_exist_many_spec=0`
|
||||
- `spec_base_id_map` 中的 ID(如 1001/1002/1003)在 DB 中不存在
|
||||
- ShopXO 防超卖机制(原子扣 inventory)完全未启用
|
||||
|
||||
**两种架构方向:**
|
||||
- **方案 A**:每个座位 = 一个 SKU(stock=1),ShopXO 原生防超卖
|
||||
- **方案 B**:每个 Zone = 一个 SKU(stock=Zone座位数),自建 FOR UPDATE 防超卖
|
||||
方案 A 已全票通过(见 `council-output/ARCHITECTURE_DECISION.md`)。现在进入**执行阶段**,按优先级实施三个任务。
|
||||
|
||||
---
|
||||
|
||||
## 核心问题(4问)
|
||||
## 任务清单
|
||||
|
||||
| # | 问题 | 负责 |
|
||||
|---|------|------|
|
||||
| Q1 | 方案 A 后台批量生成 SKU 路径是否可行?ShopXO 是否有批量 API? | BackendArchitect |
|
||||
| Q2 | 当前商品 112 的 broken 状态(is_exist_many_spec=0 + spec_base 空)是否需要紧急修复?最小修复集? | BackendArchitect + SecurityEngineer |
|
||||
| Q3 | $vr- 前缀方案是否有隐患?ShopXO 内部是否对 $ 有特殊处理? | SecurityEngineer |
|
||||
| Q4 | 方案 A vs 方案 B 最终推荐(实现成本 / 安全性 / 可维护性) | 所有成员 |
|
||||
- [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]`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -35,19 +28,76 @@ Phase 0/1/2 已完成基础骨架,暴露了一个 P0 架构问题:VR 演唱
|
|||
|
||||
| 阶段 | 内容 | 负责 |
|
||||
|------|------|------|
|
||||
| Round 1(本轮)| 独立评议 + plan.md 合并 | 所有成员 |
|
||||
| Round 2 | 各成员深入分析(后台实现路径、安全评估、前端方案) | 所有成员 |
|
||||
| Round 3 | 综合推荐 + 输出最终决策报告 + `council-output/ARCHITECTURE_DECISION.md` | FrontendDev 主笔 |
|
||||
| Draft | 各成员编写执行代码 | BackendArchitect (P0-A/P0-B), FrontendDev (P1) |
|
||||
| Review | 代码互审,验证 SQL 正确性 | BackendArchitect 审 P1, FrontendDev 审 P0-A/P0-B |
|
||||
| Finalize | 合并到 main,实测验证 | 所有成员 |
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
## P0-A 详细设计
|
||||
|
||||
- [x] **Q1**: 方案 A 批量生成 SKU 路径 `[Done: BackendArchitect]` ✅
|
||||
- [x] **Q2**: 商品 112 broken 状态紧急修复 `[Done: BackendArchitect]` ✅
|
||||
- [x] **Q3**: $vr- 前缀安全评估 `[Done: SecurityEngineer]` ✅
|
||||
- [x] **Q4**: 方案 A vs 方案 B 最终推荐 `[Done: FrontendDev]`
|
||||
- [x] **Final**: `council-output/ARCHITECTURE_DECISION.md` — 汇总三方推荐 + 最终结论 `[Done: FrontendDev]` ✅
|
||||
**文件**: `plugins/vr_ticket/service/BaseService.php`
|
||||
|
||||
**方法**: `public static function initGoodsSpecs(int $goodsId): array`
|
||||
|
||||
**逻辑**:
|
||||
1. UPDATE `is_exist_many_spec=1` WHERE `id=$goodsId`(幂等)
|
||||
2. 检查 `$vr-场馆`/`$vr-分区`/`$vr-时段`/`$vr-座位号` 是否存在(按 name 查 `goods_spec_type`),不存在则 INSERT
|
||||
3. 使用 `Db::name('GoodsSpecType')->insert()` 防止重复(先查后插)
|
||||
|
||||
**验证**: 执行后 `SELECT * FROM sxo_goods_spec_type WHERE goods_id=112` 确认 4 条 spec_type 记录。
|
||||
|
||||
---
|
||||
|
||||
## P0-B 详细设计
|
||||
|
||||
**文件**: `plugins/vr_ticket/service/SeatSkuService.php`(新建)
|
||||
|
||||
**方法**: `public static function BatchGenerate(int $goodsId, int $seatTemplateId): array`
|
||||
|
||||
**返回值**:
|
||||
```php
|
||||
[
|
||||
'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_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`(4 维度 × N 座位 = 4N 行)
|
||||
5. **旁路 `GoodsSpecificationsInsert()`** — 直接 SQL INSERT
|
||||
6. 分批:500 条/批,10000 座位约 20 批
|
||||
|
||||
**幂等**: 已存在的座位(通过 $vr-座位号 spec_value 的 extends.seat_id 判断)不重复生成。
|
||||
|
||||
---
|
||||
|
||||
## P1 详细设计(FrontendDev)
|
||||
|
||||
**文件**: `plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||
|
||||
**逻辑**:
|
||||
1. `submit()` 改为遍历 `this.selectedSeats`
|
||||
2. 每个座位从 `app.specBaseIdMap[seatKey]` 获取 `spec_base_id`(seatKey = `row_col`)
|
||||
3. 构造 `goods_params` 数组,每个座位一行
|
||||
4. 降级策略:`spec_base_id` 不存在时走原逻辑
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
- P0-A 和 P0-B 可并行开发
|
||||
- P1 依赖 P0-B 完成后注入 `specBaseIdMap` 数据
|
||||
- P0-A 完成后需在 ShopXO 容器实测验证
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -55,258 +105,27 @@ Phase 0/1/2 已完成基础骨架,暴露了一个 P0 架构问题:VR 演唱
|
|||
|
||||
| 任务 | Claim 状态 |
|
||||
|------|-----------|
|
||||
| Q1 | [Done: BackendArchitect] |
|
||||
| Q2 | [Done: BackendArchitect] |
|
||||
| Q3 | [Done: SecurityEngineer] |
|
||||
| Q4 | [Done: FrontendDev] |
|
||||
| 最终输出 | [Done: FrontendDev] |
|
||||
| P0-A | [Done: BackendArchitect] |
|
||||
| P0-B | [Done: BackendArchitect] |
|
||||
| P1 | [Done: FrontendDev] |
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
## 执行顺序
|
||||
|
||||
- Q1(BackendArchitect)先完成,后 Q4 才能给出完整推荐
|
||||
- Q3(SecurityEngineer)可与 Q1 并行
|
||||
- Q2 可独立完成,紧急程度由 BackendArchitect 判定
|
||||
- 三方分析完成后,FrontendDev 主笔 Round 3 最终报告
|
||||
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 生成
|
||||
|
||||
---
|
||||
|
||||
## 各成员 Round 1 初判
|
||||
## 已交付文件
|
||||
|
||||
### BackendArchitect 初判
|
||||
|
||||
**Q1 初步判断**:Plan A 后台批量生成 SKU **可行**。ShopXO 的 `goods_spec_base` 是标准 MySQL 表,插件可直接 INSERT。但需要确认:
|
||||
- ShopXO 商品保存时是否校验 spec_base 的 referential integrity
|
||||
- 上万座位时批量 INSERT 的性能
|
||||
- spec_base_id_map 中的 ID 是否需要与 ShopXO 内部 ID 对齐
|
||||
|
||||
**Q2 初步判断**:当前 broken 状态**暂不需要立即修复**。购买流程走的是裸商品逻辑(is_exist_many_spec=0),对 Phase 3 的购买流程设计反而是参考点——需要明确购买流程最终走哪条路后再修。
|
||||
|
||||
**Q4 初步判断**:倾向 **方案 A**。ShopXO 原生防超卖机制比自建锁更可靠(DB 层面原子操作),且不破坏 ShopXO 生态完整性。
|
||||
|
||||
### FrontendDev 初判(Q1-Q4 分析)
|
||||
|
||||
**Q1 分析:方案 A 批量生成 SKU 路径**
|
||||
|
||||
结论:**可行,但实现路径复杂。**
|
||||
|
||||
ShopXO spec_base 生成机制:
|
||||
- 商品保存时,`GoodsService::Save()` 调用 `SpecService::Save()` 逐条写入 `sxo_goods_spec_base`
|
||||
- **没有现成的批量 API** — 需要在插件初始化/商品绑定时,批量调用 `SpecService` 或直接 SQL INSERT
|
||||
- 方案 A 的 SKU 数量 = 座位数(一场演唱会可能 10000+ 个座位)
|
||||
- **前端配合**:uni-app 需要维护 `seat_id → spec_base_id` 映射(已在 `spec_base_id_map` 中)
|
||||
- **关键风险**:商品规格管理页面会显示 10000+ 行 SKU,可能导致 ShopXO 后台崩溃
|
||||
- **解决方向**:插件专用规格不出现在 ShopXO 原生规格管理页,通过 Hook 隐藏;建立独立的"座位 SKU 管理"页面
|
||||
|
||||
**Q2 分析:商品 112 broken state 最小修复集**
|
||||
|
||||
结论:**需要立即修复,推荐最小方案。**
|
||||
|
||||
根因:`is_exist_many_spec=0` 意味着 ShopXO 认为此商品无多规格,spec_base 表自然为空(从未生成过 SKU)。
|
||||
|
||||
最小修复路径(不破坏现有数据):
|
||||
1. 方案甲(最小侵入):在 `plugins_service_goods_save_end` Hook 中,检测商品有 `venue_data` 且 `$vr-` spec 存在时,强制将 `is_exist_many_spec` 设为 1,但不写 spec_base 表(绕过 ShopXO spec 机制,完全走插件自定义逻辑)
|
||||
2. 方案乙(规范做法):调用 `SpecService::Save()` 为每个座位生成一条 spec_base 记录(inventory=1, price 从 seat_type 读取)
|
||||
|
||||
**推荐方案甲**(最小修复):
|
||||
- 优势:无需重建 SKU,不影响现有订单数据
|
||||
- 代价:`is_exist_many_spec` 变成"脏 flag",但这是 ShopXO 的内部状态,插件不依赖它做业务
|
||||
- 操作:一条 UPDATE + 一条 Hook 注入
|
||||
|
||||
**Q3 分析:$vr- 前缀隐患**
|
||||
|
||||
结论:**低风险,但需实测确认。**
|
||||
|
||||
ShopXO spec name 字段无字符过滤,数据库 `varchar` 类型允许 `$` 字符。潜在风险点:
|
||||
- ThinkPHP 的 `__isset()` / 动态属性访问可能对 `$` 敏感(但 spec name 存 DB 而非 PHP 属性,低风险)
|
||||
- 前端模板渲染时,`$vr-` 字符串可能触发 Vue/JS 的变量插值解析(`{{ $vr-场馆 }}`)—— **这是真实风险**
|
||||
- ShopXO 原生规格管理页面可能将 `$` 视为特殊字符处理
|
||||
|
||||
**需要验证**:uni-app 端 spec value 的渲染方式(是纯文本还是模板字符串?)
|
||||
|
||||
**Q4 最终推荐:方案 A vs 方案 B**
|
||||
|
||||
**推荐:方案 A(每个座位一个 SPEC/SKU)**
|
||||
|
||||
理由:
|
||||
1. **安全性**:ShopXO 原生原子扣库存防超卖,经过大量生产验证;方案 B 的自建 FOR UPDATE 锁在高并发下有死锁风险
|
||||
2. **数据一致性**:方案 A 的 stock = 1,ShopXO 购买流程自带事务保护;方案 B 的 Zone stock 需要插件自己维护一致性和并发安全
|
||||
3. **多 Zone 混买**:方案 A 前端每 Zone 一个 goods_params 行,后端按 seat_id 原子购买,体验流畅;方案 B 前端分组但后端共享 Zone stock,反而增加了前端分组逻辑的复杂度
|
||||
4. **维护性**:方案 A 依赖 ShopXO 原生机制,故障排查有据可查;方案 B 是"黑盒",出问题只能靠插件自己
|
||||
5. **$vr- 前缀**:spec_base_id_map 的 key 可以是 seat_id,无需改 ShopXO spec name 存储
|
||||
|
||||
**方案 B 的唯一优势**:SKU 数量少(Zone 数量 vs 座位数量),后台管理简单。但这个优势在演唱会 10000 座场景下不如安全和一致性重要。
|
||||
|
||||
### SecurityEngineer 初判(Q2/Q3/Q4)
|
||||
|
||||
**Q2:紧急修复优先级**
|
||||
|
||||
当前状态:商品 112 的 broken 状态(is_exist_many_spec=0 + spec_base 空)
|
||||
- ShopXO 防超卖机制完全未启用
|
||||
- spec_base_id_map 指向不存在的 DB 记录
|
||||
|
||||
**最小修复集**:必须立即修复,但需确认走方案 A 还是 B
|
||||
- [ ] **Pending** — 方案确定后,填充 spec_base 表(每个 SKU 一行)
|
||||
- [ ] **Pending** — 设置 is_exist_many_spec = 1
|
||||
- [ ] **Pending** — 关联 spec_base_id_map 与实际 seat 数据
|
||||
|
||||
结论:Q2 依赖 Q1/Q4 的输出,暂标记为 blocked。
|
||||
|
||||
**Q3:$vr- 前缀安全隐患**
|
||||
|
||||
已知事实:
|
||||
- ShopXO spec name 允许特殊字符($、-、中文均无过滤)
|
||||
- ThinkPHP 模板引擎(View)可能对 $ 有变量插值行为
|
||||
|
||||
风险点:
|
||||
- [ ] View 层:Tpl 模板中 `{:$spec_name}` 是否会解析 $vr- 作为 PHP 变量?
|
||||
- [ ] DB 层:spec name 入库是否经过转义?
|
||||
- [ ] API 层:spec name 作为 JSON key 时是否安全?
|
||||
|
||||
结论:需要代码验证(Round 2 执行)。
|
||||
|
||||
**Q4:方案 A vs B 最终推荐**
|
||||
|
||||
**初步倾向**:方案 A(每个座位一个 SKU)
|
||||
|
||||
理由:
|
||||
1. 安全性:ShopXO 原生原子扣库存,无需自建锁,超卖风险最低
|
||||
2. 正确性:与 ShopXO SPEC 机制对齐,is_exist_many_spec=1 时原生防超卖生效
|
||||
3. 可追溯性:每个 SKU 独立订单项,核销链路清晰
|
||||
|
||||
---
|
||||
|
||||
### BackendArchitect Round 2 深入分析(Q1+Q2)
|
||||
|
||||
详细分析见 `docs/ROUND2_ANALYSIS.md`。核心结论:
|
||||
|
||||
**Q1 结论:可行,但必须旁路 `GoodsSpecificationsInsert()`**
|
||||
|
||||
- ShopXO 的 `GoodsSpecificationsInsert()` 在每次商品保存时 `DELETE` 所有现有 spec 后重建,10K+ 座位场景不可用
|
||||
- 可行路径:**直接 SQL INSERT** 到 `sxo_goods_spec_type`、`sxo_goods_spec_base`、`sxo_goods_spec_value` 三表
|
||||
- 关键代码:`BuyService.php:1677-1681` 的 `dec()` 机制 = MySQL 条件原子扣减 `UPDATE SET inventory = inventory - N WHERE inventory >= N`,ShopXO 防超卖依赖此机制
|
||||
- TOCTOU 窗口极小(选座模式并发低 + InnoDB 行锁),**推荐接受此风险**
|
||||
- 性能:10000 座位 = ~3-4 秒(需分批 500 条/批提交)
|
||||
|
||||
**Q2 结论:推荐方案乙(最小修复集)**
|
||||
|
||||
- `is_exist_many_spec=0` → 执行 `UPDATE goods SET is_exist_many_spec=1 WHERE id=112`
|
||||
- 写入 `$vr-` 规格维度到 `sxo_goods_spec_type`
|
||||
- 幂等保护:票生成逻辑已有 `spec_base_id` 冗余,不依赖 DB 引用
|
||||
|
||||
**Q4 初步推荐:方案 A**
|
||||
|
||||
- 原子性已验证(BuyService dec 机制)
|
||||
- 数据完整性高(每个座位 inventory=1)
|
||||
- 票务链路清晰(spec_base_id → 座位直接映射)
|
||||
- 方案 B 的"SKU 少"优势在演唱会 10K+ 场景不成立(插件自管,不走 ShopXO 后台)
|
||||
|
||||
### SecurityEngineer Round 2 分析(Q3)
|
||||
|
||||
> SecurityEngineer 在 Round 2 进行了 ThinkPHP View 层的 $vr- 前缀安全审计,结论:无高危风险。
|
||||
|
||||
### FrontendDev Round 2 深入分析
|
||||
|
||||
### Q4 最终推荐:方案 A(每个座位一个 SPEC/SKU)—— 明确推荐
|
||||
|
||||
**经过代码级验证后,确认推荐方案 A。**
|
||||
|
||||
#### 核心发现
|
||||
|
||||
**发现 1:当前 ticket_detail.html 的 submit() 是 Plan B 模式**
|
||||
|
||||
检查 `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` 第 413-418 行:
|
||||
```javascript
|
||||
var goodsParams = JSON.stringify([{
|
||||
goods_id: this.goodsId,
|
||||
spec_base_id: this.sessionSpecId, // ← session spec_id,不是 seat spec_base_id
|
||||
stock: this.selectedSeats.length, // ← seat count,不是 1
|
||||
extension_data: extensionData
|
||||
}]);
|
||||
```
|
||||
→ 当前实现是 **Plan B**(按场次买多个座位),`specBaseIdMap` 已声明但**未接入** submit 逻辑。
|
||||
|
||||
**发现 2:BuyService 依赖 spec_base 原子扣库存**
|
||||
|
||||
`BuyService.php` 第 113-115 行:
|
||||
```php
|
||||
$goods['price'] = (float) $goods_base['data']['spec_base']['price'];
|
||||
$goods['original_price'] = (float) $goods_base['data']['spec_base']['original_price'];
|
||||
$goods['spec_base_id'] = $goods_base['data']['spec_base']['id'];
|
||||
```
|
||||
→ ShopXO 购买流程从 `spec_base` 表读取库存并原子扣减。`spec_base` 为空时购买走裸商品逻辑(inventory 字段在 goods 表)。
|
||||
|
||||
#### 方案 A vs B 最终对比
|
||||
|
||||
| 维度 | 方案 A(每座=SKU) | 方案 B(每 Zone=SKU) |
|
||||
|------|-------------------|---------------------|
|
||||
| **防超卖** | ShopXO 原生原子扣库存(stock=1),DB 层保证 | 自建 FOR UPDATE 锁,需自己写并发逻辑 |
|
||||
| **实现复杂度** | 后端需批量生成 1 万+ SKU;前端 `submit()` 需改为逐座提交 | 后端简单;前端按 Zone 分组即可 |
|
||||
| **多 Zone 混买** | 每座一行 goods_params,后端原子处理 | 前端分组但后端共享 Zone 库存,复杂度高 |
|
||||
| **后台可维护性** | 10000+ SKU 行,但可 Hook 隐藏 | Zone 数量少,后台友好 |
|
||||
| **调试/故障排查** | 每个 SKU 独立,可追溯 | 共享库存,出问题难以定位 |
|
||||
| **与 ShopXO 生态** | 完全对齐,无缝集成 | 绕过 spec 校验,部分 ShopXO 功能失效 |
|
||||
| **当前代码适配成本** | `ticket_detail.html` submit() 需重构 | 基本无需改动 |
|
||||
|
||||
#### Plan A 前端实现路径(ticket_detail.html)
|
||||
|
||||
关键修改:将 `submit()` 从"session-level 提交"改为"seat-level 逐座提交":
|
||||
|
||||
```javascript
|
||||
// Plan A: 每座一行 goods_params,逐座购买
|
||||
this.selectedSeats.forEach(function(seat) {
|
||||
var seatSpecBaseId = app.specBaseIdMap[seat.row + '_' + seat.col]?.spec_base_id;
|
||||
// 如果 spec_base_id 存在,走 ShopXO 原生购买
|
||||
// 否则走 Plan B 回退逻辑
|
||||
});
|
||||
```
|
||||
|
||||
`specBaseIdMap` 数据结构已就位(从后端 PHP 注入),前端只需接入即可。
|
||||
|
||||
### Q3 验证:$vr- 前缀安全
|
||||
|
||||
**结论:低风险,确认安全。**
|
||||
|
||||
证据:
|
||||
1. `ticket_detail.html` 使用 ThinkPHP 模板 `{:$goods.title|default='...'}` —— `$goods` 是 PHP 变量,不是模板表达式
|
||||
2. `$vr_seat_template.seat_map` 是 PHP 对象访问,`|json_encode|raw` 是模板过滤器链,`|raw` 仅用于跳过 HTML 转义,不触发变量插值
|
||||
3. ThinkPHP `{$var}` 默认转义输出;`{:$expr}` 执行表达式但需要 `$var` 存在
|
||||
4. `$vr-` 作为 spec name 字符串存储在 DB 中,不作为 PHP 变量名
|
||||
|
||||
**唯一需注意**:ShopXO 后台规格管理页可能将 `$vr-` 显示不当(纯展示问题,不影响安全)。
|
||||
|
||||
### Q2 前端视角最小修复
|
||||
|
||||
当前 `ticket_detail.html` 的 `loadSoldSeats()` 是 TODO,Plan A 需要:
|
||||
1. 后端生成 spec_base SKUs(BackendArchitect 负责)
|
||||
2. 前端 `loadSoldSeats()` 调用 API 查询各 seat spec_base 的库存状态
|
||||
|
||||
最小可行路径:**先让购买流程能跑通,再迭代优化**。
|
||||
|
||||
---
|
||||
|
||||
## 行动项(优先级排序)
|
||||
|
||||
| 优先级 | 行动项 | 负责 |
|
||||
|--------|--------|------|
|
||||
| P0 | 紧急修复商品 112 broken state | BackendArchitect |
|
||||
| P1 | 实现方案 A 批量 SKU 生成(GoodsSpecificationsInsert 直接 SQL) | BackendArchitect |
|
||||
| P2 | 重构 ticket_detail.html submit() 接入 specBaseIdMap | FrontendDev |
|
||||
| P3 | Hook 隐藏插件 SKU(spec_base_id_map key = seat_id) | FrontendDev |
|
||||
|
||||
---
|
||||
|
||||
## 共识投票
|
||||
|
||||
[CONSENSUS: YES] — Round 3 完成,所有 Q1-Q4 分析完成,最终决策报告已输出(方案 A)
|
||||
|
||||
---
|
||||
|
||||
## Round 3 安全审计结果(保留,仅供参考)
|
||||
|
||||
### Task S1 — Admin 鉴权覆盖完整性审查 ✅ 验证通过
|
||||
### Task S2 — SQL 注入风险审计 ✅ 无注入风险
|
||||
### Task S3 — XSS / CSRF 防护检查 ✅ 通过
|
||||
### Task S5 — IDOR 水平越权检查 ✅ 通过
|
||||
### Task S4 — 敏感操作审计日志设计 ✅ 设计完成
|
||||
| 文件 | 状态 |
|
||||
|------|------|
|
||||
| `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) |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件后台权限菜单
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,482 @@
|
|||
<?php
|
||||
/**
|
||||
* VR票务插件 - 座位 SKU 服务
|
||||
*
|
||||
* 核心业务:批量生成座位级 SKU(spec_base + spec_value)
|
||||
* 旁路 GoodsSpecificationsInsert(),直接 SQL INSERT
|
||||
*
|
||||
* @package vr_ticket\service
|
||||
*/
|
||||
|
||||
namespace app\plugins\vr_ticket\service;
|
||||
|
||||
class SeatSkuService extends BaseService
|
||||
{
|
||||
/** @var int 分批处理每批条数 */
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* 批量生成座位级 SKU
|
||||
*
|
||||
* 遍历座位模板的 seat_map,为每个座位生成:
|
||||
* 1. goods_spec_base 行(inventory=1,价格从 zone.price 获取)
|
||||
* 2. goods_spec_value 行(4维度 × N座位 = 4N行)
|
||||
*
|
||||
* 幂等:已存在的座位(spec_value 中已有关联)不重复生成
|
||||
*
|
||||
* @param int $goodsId 商品ID
|
||||
* @param int $seatTemplateId 座位模板ID
|
||||
* @return array ['code' => 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -266,11 +266,11 @@
|
|||
var color = seatInfo.color || '#409eff';
|
||||
var price = seatInfo.price || 0;
|
||||
var label = seatInfo.label || '';
|
||||
var key = rowIndex + '_' + colIndex;
|
||||
|
||||
rowsHtml += '<div class="vr-seat '+(seatInfo.classes||'')+'" '+
|
||||
'style="background:'+color+'" '+
|
||||
'data-row="'+rowIndex+'" data-col="'+colIndex+'" '+
|
||||
'data-row-label="'+rowLabel+'" data-col-num="'+(colIndex+1)+'" '+
|
||||
'data-char="'+char+'" data-price="'+price+'" '+
|
||||
'data-seat-id="'+char+'" data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '+
|
||||
'onclick="vrTicketApp.toggleSeat(this)"></div>';
|
||||
|
|
@ -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,36 @@
|
|||
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)
|
||||
// PHP 返回格式: specBaseIdMap['A_1'] = 2001(整数),非对象
|
||||
var specBaseId = self.specBaseIdMap[seat.seatKey] || 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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue