Compare commits
41 Commits
council/Pr
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
57cc10f8c5 | |
|
|
de7c25c6b9 | |
|
|
8c38484c58 | |
|
|
6688a10d95 | |
|
|
a97e5fd0d3 | |
|
|
f6f02a0c79 | |
|
|
fdd89fbb70 | |
|
|
dce3c45b23 | |
|
|
de9134773f | |
|
|
fc07c2ece6 | |
|
|
c4a35ca258 | |
|
|
8ea0c1a229 | |
|
|
4683862688 | |
|
|
416fe0a067 | |
|
|
c134351c82 | |
|
|
461dd6b101 | |
|
|
82a5b2129d | |
|
|
fb300e00fc | |
|
|
c581395a9c | |
|
|
919c5cfd4e | |
|
|
671b0359ad | |
|
|
ccf0fbb309 | |
|
|
11fdf0309f | |
|
|
cba9c64eb9 | |
|
|
c9105f7eb3 | |
|
|
1803262bbd | |
|
|
d52bf31b55 | |
|
|
ff30e79d0b | |
|
|
dbacd36230 | |
|
|
f84f95b569 | |
|
|
a96a3c00ba | |
|
|
f441deaa61 | |
|
|
f27a32dc3d | |
|
|
795126cd55 | |
|
|
aa6651e963 | |
|
|
98dfbbd943 | |
|
|
63c1608442 | |
|
|
2590f361f7 | |
|
|
325eb4116a | |
|
|
bdb4eb55e7 | |
|
|
c18e298a69 |
|
|
@ -0,0 +1,161 @@
|
|||
# Agent 执行 Prompt — VR 演唱会票务小程序 Phase 2
|
||||
|
||||
## 前提条件(必读)
|
||||
|
||||
你正在帮助开发一个 **ShopXO 票务插件(vr_ticket)**。
|
||||
|
||||
- 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin`
|
||||
- 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin`
|
||||
- ShopXO 容器:localhost:10000(Web)/ localhost:10001(MySQL)/ localhost:9000(PHP-FPM)
|
||||
- DB 用户:root / shopxo_root_2024,表前缀:`vrt_`
|
||||
|
||||
**完整文档路径**:`/Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/FULL_PLAN.md`
|
||||
|
||||
> ⚠️ 在做任何事情之前,**必须先读 `FULL_PLAN.md`**,理解完整上下文后再开始。
|
||||
|
||||
> 📋 **AntiGravity 已进行会话进度**: `SESSION_REPORT_20260421_PHASE2_FIX.md` - 记录AntiGravity 推进的所有工作。涵盖经验、教训与改动。
|
||||
---
|
||||
|
||||
## 项目背景(一句话)
|
||||
|
||||
VR 演唱会票务微信小程序插件。用户选座 → 填观演人 → 微信支付 → 电子票二维码 → 现场扫码核销。
|
||||
|
||||
---
|
||||
|
||||
## 当前优先级
|
||||
|
||||
### P0(阻塞一切)
|
||||
|
||||
**Issue 1 修复**:购买提交流程完全失效,有三层叠加问题。
|
||||
|
||||
**顺序**:
|
||||
|
||||
1. **后端**:修改 `SeatSkuService::GetGoodsViewData()`,新增 `seatSpecMap` 生成逻辑
|
||||
- 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends.seat_key`
|
||||
- 输出 `seatSpecMap[seatKey] = {spec_base_id, price, inventory, spec: [...]}`
|
||||
- 完整逻辑见 `FULL_PLAN.md` 第 4.3 节
|
||||
|
||||
2. **后端**:修改 `Goods.php`,在 `MyViewAssign` 中加入 `seatSpecMap`
|
||||
|
||||
3. **前端**:修改 `ticket_detail.html`,用 `seatSpecMap` 替代当前错误的 `specBaseIdMap`
|
||||
|
||||
4. **前端**:修复 `submit()` 函数
|
||||
- 改 GET → POST 隐藏表单(**不是** `location.href`)
|
||||
- spec 必须是**完整的 4 维数组**:`[{type:'$vr-场馆',value:'...'},{type:'$vr-分区',value:'...'},{type:'$vr-座位号',value:'...'},{type:'$vr-场次',value:'...'}]`
|
||||
- **不是** `{type:'$vr-座位号', value: seatKey}` — 这是错的
|
||||
- spec 从 `seatSpecMap[seatKey].spec` 读取,**不要自己构造**
|
||||
- `extension_data` 必须嵌套在 `order_base` 内,**不是平铺在第一层**
|
||||
- 直接 `JSON.stringify`,**不需要 base64**
|
||||
|
||||
### P1
|
||||
|
||||
5. **前端**:`ticket_detail.html` 新增场次/场馆/分区选择器 UI + `filterSeatMap()` 联动过滤
|
||||
6. **前端**:缩放时舞台跟随(zoom wrapper 方案)
|
||||
7. **后端**:新增 `sold_seats` API 端点 + 前端 `loadSoldSeats()` 调用
|
||||
|
||||
### P2
|
||||
|
||||
8. 商品详情图片展示(确认需求)
|
||||
9. `GetGoodsViewData()` 多场次返回数组而非 `validConfigs[0]`
|
||||
10. `onOrderPaid` spec 匹配审计(未来关注,不阻塞)
|
||||
|
||||
---
|
||||
|
||||
## 绝对禁止事项
|
||||
|
||||
- ❌ **不要**用 `location.href` 传递购买参数(ShopXO 只在 POST 时存储数据)
|
||||
- ❌ **不要**把 spec 格式写成 `{type:'$vr-座位号', value: 'room_001_A_3'}` — 这是错的
|
||||
- ❌ **不要**把 `extension_data` 平铺在 `goods_data` 第一层 — 必须嵌套在 `order_base` 里
|
||||
- ❌ **不要**在 submit() 里对 `goods_data` 做 base64 — 直接 `JSON.stringify` 即可
|
||||
- ❌ **不要**修改 `BuyService.php` 的核心逻辑 — 所有修复都在前端和插件后端做
|
||||
- ❌ **不要**新建数据库表来存观演人信息 — 用 ShopXO 原生的 `order.extension_data`
|
||||
|
||||
---
|
||||
|
||||
## 常见错误警告
|
||||
|
||||
### spec 数组格式(最高频错误)
|
||||
|
||||
```
|
||||
错误:
|
||||
spec: [{type:'$vr-座位号', value: 'room_001_A_3'}]
|
||||
|
||||
正确(完整4维):
|
||||
spec: [
|
||||
{type:'$vr-场馆', value: 'VR 演唱会馆'},
|
||||
{type:'$vr-分区', value: 'VR 演唱会馆-1号演播厅-VIP区'},
|
||||
{type:'$vr-座位号', value: 'VR 演唱会馆-1号演播厅-VIP区-A-1排3座'},
|
||||
{type:'$vr-场次', value: '15:00-16:59'}
|
||||
]
|
||||
```
|
||||
|
||||
### order_base 嵌套(第二高频错误)
|
||||
|
||||
```
|
||||
错误:
|
||||
{
|
||||
goods_id: 118,
|
||||
spec: [...],
|
||||
extension_data: {...} ← 平铺!错!
|
||||
}
|
||||
|
||||
正确:
|
||||
{
|
||||
goods_id: 118,
|
||||
spec: [...],
|
||||
order_base: { ← 必须嵌套在 order_base 内!
|
||||
extension_data: {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### goods_data 编码(第三高频错误)
|
||||
|
||||
```
|
||||
错误:
|
||||
input.value = btoa(JSON.stringify(goodsDataList)) ← 不需要 base64!
|
||||
|
||||
正确:
|
||||
input.value = JSON.stringify(goodsDataList) ← 直接 JSON 字符串
|
||||
```
|
||||
|
||||
ShopXO `BuyService::BuyGoods` 第60行判断 `!is_array($_POST['goods_data'])` 才会 decode,直接 POST JSON 字符串即可。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 我需要知道 | 去哪里找 |
|
||||
|-----------|---------|
|
||||
| 完整上下文 + 修复方案 | `FULL_PLAN.md` |
|
||||
| 原始 goods.vr_goods_config 数据 | `FULL_PLAN.md` 第二章 |
|
||||
| seatSpecMap 正确结构 | `FULL_PLAN.md` 4.3 节 |
|
||||
| submit() 正确实现 | `FULL_PLAN.md` 第六章 |
|
||||
| Buy 链路数据流 | `FULL_PLAN.md` 6.3 节 |
|
||||
| 关键代码行号索引 | `FULL_PLAN.md` 第八章 |
|
||||
| spec 选择器设计 | `FULL_PLAN.md` 第五章 |
|
||||
| 座位图渲染方法 | `FULL_PLAN.md` 5.3 节 |
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. **读** `FULL_PLAN.md` 全文(必读)
|
||||
2. **确认**你理解了 4 维 spec 结构 + seatSpecMap 映射关系
|
||||
3. **按优先级顺序**处理 P0 → P1 → P2
|
||||
4. **每完成一个模块**,在本地测试验证后再继续
|
||||
5. **commit 前**:`git status` 检查暂存区,不提交 binary(图片/压缩包),不在本仓库 push 到远程
|
||||
|
||||
---
|
||||
|
||||
## commit 规范
|
||||
|
||||
```
|
||||
feat(Phase2): [模块名] [简短描述]
|
||||
|
||||
示例:
|
||||
feat(Phase2): SeatSkuService GetGoodsViewData 新增 seatSpecMap 生成
|
||||
feat(Phase2): ticket_detail.html 修复 submit() POST + 4维spec数组
|
||||
```
|
||||
|
||||
**注意**:本仓库是 fork,不直接 push 到 upstream。只 commit 到本地,汇报给大头后由他处理上游合并。
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
# VR 演唱会票务小程序 Phase 2 技术评估报告(修正版)
|
||||
|
||||
> 日期:2026-04-21
|
||||
> 协作产出:BackendArchitect、FrontendDev、FirstPrinciples
|
||||
> 修正:大头 + 西莉雅(2026-04-21 上午)
|
||||
> 源码依据:BuyService.php、GoodsCartService.php、SeatSkuService.php、ticket_detail.html、vr_tickets install.sql
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
Phase 2 完成 4 个已知问题的根因分析 + 1 个新发现潜在 Bug。**经大头确认后,修正了 FirstPrinciples 的关键错误结论。**
|
||||
|
||||
**核心修正**:FirstPrinciples「购物车对票务无价值」的结论是**错误的**。Buy 链路是正确方向,ShopXO 原生支持多 SKU 合并下单 + extension_data 透传 + onOrderPaid 写入 vr_tickets。只需修复 submit() 的传递方式。
|
||||
|
||||
---
|
||||
|
||||
## 问题总览
|
||||
|
||||
| # | 问题 | 优先级 | 根因 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 购买提交流程失效 | **P0** | GET→POST 机制错误 + spec 字段格式错误 |
|
||||
| 2 | 缩放时舞台不跟随 | **P1** | DOM 结构导致 transform 不共享 |
|
||||
| 3 | spec 加载问题(已回滚) | **P1** | loadSoldSeats() 是空 stub + 需 sold_seats API |
|
||||
| 4 | 商品详情/图片加载 | **P2** | 模板未引入内容组件 |
|
||||
|
||||
**新发现**:
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|--------|
|
||||
| 5 | GetGoodsViewData() 只返回第一个场次 | **P2 潜在** |
|
||||
|
||||
---
|
||||
|
||||
## Issue 1(P0):购买提交流程失效
|
||||
|
||||
### 根因(三层叠加)
|
||||
|
||||
**第一层(致命)**:`location.href` 产生 GET,但 `Buy::Index()` 只在 POST 时调用 `BuyDataStorage()`。
|
||||
|
||||
```php
|
||||
// Buy.php:58-61
|
||||
public function Index() {
|
||||
if($this->data_post) {
|
||||
BuyService::BuyDataStorage($user_id, $this->data_post); // ← POST 才执行
|
||||
return MyRedirect(MyUrl('index/buy/index'));
|
||||
} else {
|
||||
$buy_data = BuyService::BuyDataRead($user_id); // GET → 读 session → 空
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ `goods_params` URL 参数从未被读取 → `BuyDataStorage` 未被调用 → buy 确认页收不到数据 → "商品数据为空"。
|
||||
|
||||
**第二层(严重)**:字段名 `goods_params` vs 期望的 `goods_data`。
|
||||
|
||||
**第三层(中等)**:spec 格式不匹配:
|
||||
- 当前:`spec_base_id: int`(直接传 ID)
|
||||
- ShopXO:`spec: [{type, value}]` 字符串匹配 GoodsSpecValue 表
|
||||
|
||||
### ShopXO Buy 链路完全支持多座位合并下单
|
||||
|
||||
**ShopXO 原生能力验证**:
|
||||
- `BuyService::BuyGoods` 第86行:`foreach($params['goods_data'] as $v)` — 原生遍历多 SKU
|
||||
- `BuyService::OrderInsertHandle` 第773行:`'extension_data' => json_encode($v['order_base']['extension_data'])` — 原生写入 extension_data
|
||||
- `vr_tickets` install.sql 已有:`real_name`, `phone`, `id_card` 字段 ✅
|
||||
- `TicketService::issueTicket()` 第141行:从 `$order['extension_data']` 读取观演人 ✅
|
||||
|
||||
### 正确修复方案(只需改 submit())
|
||||
|
||||
```javascript
|
||||
// var self = this; — 原始代码第6行已有此声明,确保 submit() 上方作用域有 var self = this
|
||||
submit: function() {
|
||||
var self = this; // 如作用域内已有则忽略此行
|
||||
|
||||
// 1. 收集观演人
|
||||
var inputs = document.querySelectorAll('#attendeeList input');
|
||||
var attendeeData = {};
|
||||
inputs.forEach(function(input) {
|
||||
var idx = input.dataset.index;
|
||||
var field = input.dataset.field;
|
||||
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||
attendeeData[idx][field] = input.value;
|
||||
});
|
||||
|
||||
// 2. 构建 ShopXO 原生 goods_data 格式
|
||||
//
|
||||
// ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(第86行 $v['order_base'])
|
||||
// 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层!
|
||||
// ⚠️ 【必须】直接传 JSON 字符串,不需要 base64
|
||||
// BuyService 第60行判断:!is_array($_POST['goods_data']) → json_decode()
|
||||
// ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64
|
||||
var goodsDataList = this.selectedSeats.map(function(seat, i) {
|
||||
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||
return {
|
||||
goods_id: self.goodsId,
|
||||
spec: [{type: '$vr-座位号', value: seat.seatKey}],
|
||||
stock: 1,
|
||||
order_base: { // ← 必须嵌套!不能平铺!
|
||||
extension_data: {
|
||||
attendee: {
|
||||
real_name: attendeeData[i]?.real_name || '',
|
||||
phone: attendeeData[i]?.phone || '',
|
||||
id_card: attendeeData[i]?.id_card || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 3. 隐藏表单 POST 到 Buy 链路
|
||||
//
|
||||
// ⚠️ requestUrl 来自 PHP 模板注入(ticket_detail.html 第6行):
|
||||
// var requestUrl = '<?php echo Config("shopxo.host_url"); ?>';
|
||||
// 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = requestUrl + '?s=index/buy/index'; // 用模板注入的全局 requestUrl 变量
|
||||
var input = document.createElement('input');
|
||||
input.name = 'goods_data';
|
||||
input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
```
|
||||
|
||||
**完整数据流**(ShopXO 原生,无需扩展):
|
||||
```
|
||||
submit() POST goods_data(含 order_base.extension_data)
|
||||
→ Buy::Index → BuyDataStorage(user_id, data_post) [存入 session]
|
||||
→ 跳转确认页(GET)→ form hidden field 携带 goods_data
|
||||
→ Buy::Add → BuyGoods → OrderInsertHandle
|
||||
→ order.extension_data 写入 Order 表
|
||||
→ 支付成功 → onOrderPaid → issueTicket()
|
||||
→ 从 $order['extension_data'] 读取观演人 → 写入 vr_tickets(real_name/phone/id_card) ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue 2(P1):缩放时舞台不跟随
|
||||
|
||||
### 根因
|
||||
|
||||
`.vr-stage` 和 `.vr-seat-rows` 是平级兄弟元素,transform 只作用于子树。
|
||||
|
||||
### 修复方案
|
||||
|
||||
```html
|
||||
<div class="vr-seat-map-wrapper">
|
||||
<div class="vr-zoom-container" id="zoomContainer">
|
||||
<div class="vr-stage">舞 台</div>
|
||||
<div class="vr-seat-rows" id="seatRows"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```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()`,舞台和座位同步缩放。
|
||||
|
||||
---
|
||||
|
||||
## Issue 3(P1):spec 加载问题(已回滚)
|
||||
|
||||
### 根因
|
||||
|
||||
- `loadSoldSeats()` 是空 TODO stub,无任何 AJAX 调用
|
||||
- 后端无 `sold_seats` API 端点
|
||||
|
||||
### 修复方案
|
||||
|
||||
**后端**:新增 `plugins/vr_ticket/index/soldSeats` API 端点
|
||||
```
|
||||
GET /?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=index&pluginsaction=soldSeats
|
||||
Query: goods_id, spec_base_id
|
||||
Response: {code: 0, data: {sold_seats: ["A_1", "A_2", "B_5"]}}
|
||||
```
|
||||
|
||||
**前端**:`loadSoldSeats()` 调用该接口,标记 `.sold` class。
|
||||
|
||||
---
|
||||
|
||||
## Issue 4(P2):商品详情/图片加载
|
||||
|
||||
- `$goods['content']`:✅ 正常渲染
|
||||
- `$goods['images']`:⚠️ 数据存在但未使用
|
||||
- `.goods-detail-content` CSS:⚠️ 缺失
|
||||
|
||||
如需展示商品图片,在模板中添加图片渲染逻辑。如票务详情页不需要 ShopXO 商品内容,可降级为「确认不需要」。
|
||||
|
||||
---
|
||||
|
||||
## Issue 5(P2 潜在):GetGoodsViewData 只返回第一个场次
|
||||
|
||||
`SeatSkuService::GetGoodsViewData()` 第368行返回 `validConfigs[0]`,多场次商品只显示第一个场次。
|
||||
|
||||
### 修复方向
|
||||
|
||||
修改返回值格式为数组,前端根据选中场次索引读取对应数据。
|
||||
|
||||
---
|
||||
|
||||
## 第一性原则视角(修正后)
|
||||
|
||||
1. **Issue 1 是「传输机制损坏」,不是「流程错误」**:Buy 链路完全正确,多 SKU 合并下单是 ShopXO 原生能力,不需要绕过。
|
||||
|
||||
2. **extension_data 存储完全在 ShopXO 生态内**:`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路原生打通,不需要新建表或扩展字段。
|
||||
|
||||
3. **`spec_base_id_map` 是性能缓存**:如果 `onOrderPaid` 能通过 seatKey(spec value 字符串)查询 spec_base_id,map 可以去掉。但保留是合理的优化。
|
||||
|
||||
4. **`onOrderPaid` 是座位唯一性权威**(未审计):在 Issue 1 修复部署前,必须验证此 Hook 是否正确实现了座位锁定(幂等 + FOR UPDATE)。这是防双售的核心。
|
||||
|
||||
5. **onOrderPaid spec 匹配存在潜在 bug(⚠️ 新增)**:`BatchGenerate` 写入 GoodsSpecValue.value 的格式是 `"{$venueName}-{$roomName}-{$char}-{$rowLabel}{$col}"`(如 "场馆A-放映室1-A-A3座"),而前端 seatKey 格式是 `"roomId_A_3"`,两者不匹配。`TicketService::issueTicket` 第57-77行通过 `type='$vr-座位号'` 匹配 GoodsSpecValue.value 的逻辑会失效。目前不影响功能是因为幂等靠 `seat_info` 字段(不需要 spec_base_id),但如果未来需要精确关联,此处需修复 value 写入格式或改为读 GoodsSpecBase.extends.seat_key。
|
||||
|
||||
6. **最小修复范围**:只需修改 `submit()` 函数(POST + 正确 goods_data 格式 + extension_data)。不需要重构 spec 系统,不需要新建表,不需要绕过 Buy 链路。
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级
|
||||
|
||||
| 优先级 | Issue | 负责 | 修复说明 |
|
||||
|--------|------|------|---------|
|
||||
| P0 | Issue 1 submit() | FrontendDev | 改隐藏表单 POST,正确构造 goods_data + extension_data |
|
||||
| P1 | Issue 2 舞台缩放 | FrontendDev | 新增 zoom wrapper 容器 |
|
||||
| P1 | Issue 3 spec 加载 | BackendArchitect | 新增 sold_seats API + 前端调用 |
|
||||
| P2 | Issue 4 商品详情 | FrontendDev | 确认是否需要,补充 CSS |
|
||||
| P2 | Issue 5 多场次 | BackendArchitect | GetGoodsViewData 返回数组格式 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:ShopXO Buy 链路关键代码索引
|
||||
|
||||
| 文件 | 行号 | 说明 |
|
||||
|------|------|------|
|
||||
| `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead |
|
||||
| `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + base64/JSON 解码 |
|
||||
| `BuyService.php` | 86 | foreach($params['goods_data'] as $v) — 多 SKU 原生遍历 |
|
||||
| `BuyService.php` | 104-109 | GoodsSpecDetail 调用 — spec.value 字符串匹配 |
|
||||
| `BuyService.php` | 773 | OrderInsertHandle — extension_data 写入 order 表 |
|
||||
| `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 |
|
||||
| `Buy/index.html` | 871 | 确认表单 hidden goods_data field(原生包含) |
|
||||
| `TicketService.php` | 141-143 | issueTicket — 从 $order['extension_data'] 读观演人 |
|
||||
| `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug |
|
||||
|
||||
---
|
||||
|
||||
*VR 演唱会票务小程序 Phase 2 技术评估 — Council 协作完成,2026-04-21 修正版*
|
||||
|
|
@ -0,0 +1,682 @@
|
|||
# VR 演唱会票务小程序 — 完整实现文档
|
||||
|
||||
> 最后更新:2026-04-21
|
||||
> 用途:给任意 agent 独立阅读并推进事务
|
||||
> 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin`
|
||||
> 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin`
|
||||
> ShopXO 容器:localhost:10000(Web)/ localhost:10001(MySQL)/ localhost:9000(PHP-FPM)
|
||||
|
||||
> 📋 **AntiGravity 已进行会话进度**: `SESSION_REPORT_20260421_PHASE2_FIX.md` - 记录AntiGravity 推进的所有工作,包含经验教训与改动。
|
||||
|
||||
---
|
||||
|
||||
## 一、项目概览
|
||||
|
||||
### 1.1 目标产品
|
||||
|
||||
VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。
|
||||
|
||||
### 1.2 技术栈
|
||||
|
||||
- **前端**:原生 HTML + CSS + JS(无框架),商品详情页使用 `ticket_detail.html`
|
||||
- **后端**:ShopXO(ThinkPHP 8)插件 `vr_ticket`
|
||||
- **数据库**:ShopXO MySQL,表前缀 `vrt_`
|
||||
- **微信支付**:ShopXO 原生微信支付
|
||||
|
||||
### 1.3 核心表结构
|
||||
|
||||
| 表名 | 用途 |
|
||||
|------|------|
|
||||
| `vrt_vr_seat_templates` | 座位模板(座位图画法 + 绑定分类) |
|
||||
| `vrt_vr_tickets` | 电子票(order_id + seat_info + real_name/phone/id_card) |
|
||||
| `vrt_vr_verifiers` | 核销员 |
|
||||
| `vrt_vr_verifications` | 核销记录 |
|
||||
| `vrt_vr_audit_log` | 操作审计日志 |
|
||||
|
||||
ShopXO 原生表:
|
||||
| 表名 | 用途 |
|
||||
|------|------|
|
||||
| `goods` | 商品(含 `vr_goods_config` 扩展 JSON 字段) |
|
||||
| `goods_spec_base` | SKU(库存/价格),`extends` 含 `seat_key` |
|
||||
| `goods_spec_value` | spec 维度值(4维度:场馆/分区/座位号/场次) |
|
||||
| `order` | 订单(含 `extension_data` JSON 字段) |
|
||||
| `order_detail` | 订单明细 |
|
||||
|
||||
### 1.4 spec 四维度说明
|
||||
|
||||
ShopXO 每个 GoodsSpecBase(SKU)由 4 个 spec type-value 联合确定:
|
||||
|
||||
| type | 说明 | 示例 value |
|
||||
|------|------|-----------|
|
||||
| `$vr-场馆` | 场馆名 | `VR 体验馆` |
|
||||
| `$vr-分区` | 场馆+演播厅+分区 | `VR 体验馆-1号演播厅-VIP区` |
|
||||
| `$vr-座位号` | 完整路径座位名 | `VR 体验馆-1号演播厅-VIP区-A-1排3座` |
|
||||
| `$vr-场次` | 场次时间 | `15:00-16:59` |
|
||||
|
||||
**注意**:spec value 是**完整路径字符串**,不是 `"A_3"` 或 `"roomId_A_3"` 这种短格式。
|
||||
|
||||
### 1.5 座位的唯一标识(seatKey)
|
||||
|
||||
前后端共用同一个格式:`{roomId}_{rowLabel}_{colNum}`
|
||||
- `roomId`:`rooms[].id`,来自 `vr_goods_config.template_snapshot.rooms`
|
||||
- `rowLabel`:座位行标签,`A`/`B`/`C`(由 map 行索引计算:`String.fromCharCode(65 + rowIndex)`)
|
||||
- `colNum**:列号(从 1 开始:`colIndex + 1`)
|
||||
|
||||
示例:`"room_001_A_3"` = room_001 的 A排 第3列
|
||||
|
||||
seatKey 对应 `GoodsSpecBase.extends.seat_key`,用于关联 GoodsSpecBase 和前端座位 DOM。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状与已知问题
|
||||
|
||||
### Phase 0/1 完成情况
|
||||
|
||||
✅ `Goods.php` 判断 `item_type='ticket'` → 渲染 `ticket_detail.html`
|
||||
✅ `ticket_detail.html` 座位图渲染 + 选座 JS + 观演人表单
|
||||
✅ `SeatSkuService::GetGoodsViewData()` 返回座位图数据
|
||||
✅ `TicketService::onOrderPaid()` 支付成功后生成 `vr_tickets`
|
||||
✅ 4 个后台管理控制器(座位模板/票/核销员/核销记录)
|
||||
✅ 基础防超卖幂等保护
|
||||
|
||||
### Phase 2 待修复问题(源自 Council 评估 + 大头确认)
|
||||
|
||||
| # | 问题 | 优先级 | 状态 |
|
||||
|---|------|--------|------|
|
||||
| Issue 1 | 购买提交流程失效(GET→POST 机制错误 + spec 格式错误 + 缺 seatSpecMap) | **P0** | 待修复 |
|
||||
| Issue 2 | 缩放时舞台不跟随 | **P1** | 待修复 |
|
||||
| Issue 3 | spec 加载(loadSoldSeats 空 stub + 无 sold_seats API) | **P1** | 待修复 |
|
||||
| Issue 4 | 商品详情/图片加载 | **P2** | 待修复 |
|
||||
| Issue 5 | GetGoodsViewData 只返回第一个场次 | **P2** | 待修复 |
|
||||
|
||||
**核心问题说明**(Issue 1 P0):
|
||||
Issue 1 不是单一 bug,而是三层叠加问题:
|
||||
1. `submit()` 用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时调用 `BuyDataStorage`
|
||||
2. spec 格式错误:只传 1 维度而非 4 维度
|
||||
3. **最严重**:前端根本没有 seatSpecMap,无法把座位 DOM 映射到正确的 GoodsSpecBase
|
||||
|
||||
---
|
||||
|
||||
## 三、商品118 vr_goods_config(原始数据库数据)
|
||||
|
||||
存储位置:`goods` 表 `vr_goods_config` JSON 字段(商品 ID = 118)
|
||||
|
||||
这是从数据库直接读取的原始数据,**所有其他数据结构均派生于此**。
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"version": 3.0,
|
||||
"template_id": 4,
|
||||
"selected_rooms": ["room_001", "room_002"],
|
||||
"selected_sections": {
|
||||
"room_001": ["A", "B"],
|
||||
"room_002": ["A"]
|
||||
},
|
||||
"sessions": [
|
||||
{ "start": "15:00", "end": "16:59" },
|
||||
{ "start": "18:00", "end": "20:59" }
|
||||
],
|
||||
"template_snapshot": {
|
||||
"venue": {
|
||||
"name": "VR 演唱会馆",
|
||||
"address": "北京市朝阳区建国路88号",
|
||||
"location": { "lng": "116.45792", "lat": "39.90745" },
|
||||
"images": [
|
||||
"/static/attachments/202603/venue_001.jpg",
|
||||
"/static/attachments/202603/venue_002.jpg"
|
||||
]
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room_001",
|
||||
"name": "1号演播厅",
|
||||
"map": [
|
||||
"AAAAA_____BBBBB",
|
||||
"AAAAA_____BBBBB",
|
||||
"AAAAA_____BBBBB",
|
||||
"CCCCCCCCCCCCCCC",
|
||||
"CCCCCCCCCCCCCCC"
|
||||
],
|
||||
"sections": [
|
||||
{ "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||
{ "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||
{ "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||
],
|
||||
"seats": {
|
||||
"A": { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||
"B": { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||
"C": { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "room_002",
|
||||
"name": "2号演播厅(副厅)",
|
||||
"map": [
|
||||
"DDDDDDD",
|
||||
"DDDDDDD",
|
||||
"EEEEEEE"
|
||||
],
|
||||
"sections": [
|
||||
{ "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||
{ "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||
],
|
||||
"seats": {
|
||||
"D": { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||
"E": { "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 含义 | 前端是否可用 |
|
||||
|------|------|------------|
|
||||
| `version` | 协议版本(当前 3.0) | ❌ 内部使用 |
|
||||
| `template_id` | 关联座位模板 ID | ❌ 内部使用 |
|
||||
| `selected_rooms` | 启用的房间 ID 列表 | ✅ 用于初始化 |
|
||||
| `selected_sections` | 每个房间选中的分区字符 | ✅ 用于默认高亮 |
|
||||
| `sessions` | 场次列表(start/end) | ✅ **场次选择器数据源** |
|
||||
| `template_snapshot.venue` | 场馆信息 | ✅ Banner/详情展示 |
|
||||
| `template_snapshot.rooms[].id` | 房间唯一 ID | ✅ **seatKey 构造必需** |
|
||||
| `template_snapshot.rooms[].map` | 座位图字符矩阵 | ✅ **座位图渲染必需** |
|
||||
| `template_snapshot.rooms[].sections` | 分区列表(char→name/price/color) | ✅ **图例+分区选择器** |
|
||||
| `template_snapshot.rooms[].seats` | char→座位属性映射 | ✅ **查座位详情** |
|
||||
|
||||
### map 格式说明
|
||||
|
||||
```
|
||||
"AAAAA_____BBBBB"
|
||||
↓分解为字符数组↓
|
||||
['A','A','A','A','A','_','_','_','_','_','B','B','B','B','B']
|
||||
←VIP区×5→←空位×5→←看台区×5→
|
||||
|
||||
字符含义:
|
||||
A/B/C/D/E = 座位(通过 rooms[i].seats[char] 查属性)
|
||||
'_' / '-' = 空位(不渲染座位)
|
||||
其他非字母 = 不渲染
|
||||
```
|
||||
|
||||
### rooms.seats 与 rooms.sections 的关系
|
||||
|
||||
同一个 char 在不同房间代表不同分区:
|
||||
- `room_001` 的 `A` = VIP区(红色,380元)
|
||||
- `room_002` 的 `D` = 互动区(橙色,280元)
|
||||
|
||||
**分区信息在 `sections[]` 里**,不要直接用 char 本身判断分区名称或价格。
|
||||
|
||||
---
|
||||
|
||||
## 四、后端注入的模板数据
|
||||
|
||||
`Goods.php` 在渲染 `ticket_detail.html` 前,通过 `SeatSkuService::GetGoodsViewData()` 向模板注入以下变量:
|
||||
|
||||
```php
|
||||
MyViewAssign([
|
||||
'vr_seat_template' => $viewData['vr_seat_template'], // 座位图原始数据
|
||||
'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表
|
||||
'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 【待新增】座位→4维spec映射
|
||||
]);
|
||||
```
|
||||
|
||||
模板中接收方式:
|
||||
```javascript
|
||||
var vrSeatTemplate = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||
var goodsSpecData = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||
var seatSpecMap = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||
```
|
||||
|
||||
### 4.1 vr_seat_template(透传 template_snapshot)
|
||||
|
||||
```javascript
|
||||
{
|
||||
venue: {
|
||||
name: "VR 演唱会馆",
|
||||
address: "北京市朝阳区建国路88号",
|
||||
location: { lng: "116.45792", lat: "39.90745" },
|
||||
images: ["/static/attachments/202603/venue_001.jpg"]
|
||||
},
|
||||
rooms: [
|
||||
{
|
||||
id: "room_001",
|
||||
name: "1号演播厅",
|
||||
map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"],
|
||||
sections: [
|
||||
{ char: "A", name: "VIP区", price: 380, color: "#f06292" },
|
||||
{ char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
|
||||
{ char: "C", name: "普通区", price: 80, color: "#81c784" }
|
||||
],
|
||||
seats: { /* 同第二章 seats */ }
|
||||
},
|
||||
{
|
||||
id: "room_002",
|
||||
name: "2号演播厅(副厅)",
|
||||
map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"],
|
||||
sections: [ /* 同第二章 sections */ ],
|
||||
seats: { /* 同第二章 seats */ }
|
||||
}
|
||||
],
|
||||
sessions: [
|
||||
{ start: "15:00", end: "16:59" },
|
||||
{ start: "18:00", end: "20:59" }
|
||||
],
|
||||
selectedRooms: ["room_001", "room_002"],
|
||||
selectedSections: { "room_001": ["A", "B"], "room_002": ["A"] }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 goods_spec_data(场次列表)
|
||||
|
||||
```javascript
|
||||
// 来源:goods.vr_goods_config.sessions + ShopXO GoodsSpecBase.price
|
||||
[
|
||||
{ spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" },
|
||||
{ spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" }
|
||||
]
|
||||
```
|
||||
|
||||
### 4.3 seatSpecMap(待新增,核心数据结构)
|
||||
|
||||
**来源**:`GetGoodsViewData()` 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends`,动态构建
|
||||
|
||||
**用途**:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 GoodsSpecBase
|
||||
|
||||
```javascript
|
||||
// key 格式:{roomId}_{rowLabel}_{colNum}
|
||||
// 示例:room_001_A_3 = room_001 的 A排 第3列
|
||||
|
||||
{
|
||||
"room_001_A_1": {
|
||||
spec_base_id: 10001,
|
||||
price: 380,
|
||||
inventory: 1, // 0 = 已售,1 = 可购
|
||||
rowLabel: "A",
|
||||
colNum: 3,
|
||||
roomId: "room_001",
|
||||
section: { char: "A", name: "VIP区", color: "#f06292" },
|
||||
// === 4维 spec 数组(submit() 时直接使用)===
|
||||
spec: [
|
||||
{ type: "$vr-场馆", value: "VR 演唱会馆" },
|
||||
{ type: "$vr-分区", value: "VR 演唱会馆-1号演播厅-VIP区" },
|
||||
{ type: "$vr-座位号", value: "VR 演唱会馆-1号演播厅-VIP区-A-1排1座" },
|
||||
{ type: "$vr-场次", value: "15:00-16:59" }
|
||||
]
|
||||
},
|
||||
"room_001_A_2": { /* 同上,A排第2座 */ },
|
||||
"room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ },
|
||||
"room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* 互动区 */ },
|
||||
// ...每个可购座位一行
|
||||
}
|
||||
```
|
||||
|
||||
#### seatSpecMap 生成逻辑(GetGoodsViewData 中实现)
|
||||
|
||||
```php
|
||||
// 1. 查询所有有效 GoodsSpecBase(含 extends.seat_key)
|
||||
$specs = Db::name('GoodsSpecBase')
|
||||
->where('goods_id', $goodsId)
|
||||
->where('inventory', '>', 0) // 只取有库存的
|
||||
->select();
|
||||
|
||||
// 2. 查询对应的 GoodsSpecValue(4个维度的 type/value)
|
||||
$specIds = array_column($specs->toArray(), 'id');
|
||||
$specValues = Db::name('GoodsSpecValue')
|
||||
->whereIn('goods_spec_base_id', $specIds)
|
||||
->select();
|
||||
|
||||
// 3. 按 spec_base_id 分组,构建 4维 spec 数组
|
||||
$specByBaseId = [];
|
||||
foreach ($specValues as $sv) {
|
||||
$specByBaseId[$sv['goods_spec_base_id']][] = [
|
||||
'type' => $sv['type'],
|
||||
'value' => $sv['value'],
|
||||
];
|
||||
}
|
||||
|
||||
// 4. 构建 seatSpecMap
|
||||
$seatSpecMap = [];
|
||||
foreach ($specs as $spec) {
|
||||
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||
$seatKey = $extends['seat_key'] ?? '';
|
||||
if (empty($seatKey)) continue;
|
||||
|
||||
$seatSpecMap[$seatKey] = [
|
||||
'spec_base_id' => intval($spec['id']),
|
||||
'price' => floatval($spec['price']),
|
||||
'inventory' => intval($spec['inventory']),
|
||||
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、产品形态:多维度 spec 选择器 + 多座位选择
|
||||
|
||||
### 5.1 界面结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 顶部 Banner(venue.images) │
|
||||
│ │
|
||||
│ 场次选择 │
|
||||
│ [●15:00-16:59 ¥380] [ 18:00-20:59 ¥280 ] │
|
||||
│ │
|
||||
│ 场馆/分区选择(spec 选择器交互) │
|
||||
│ [●1号演播厅] [ 2号演播厅 ] │
|
||||
│ [●VIP区380] [ 看台区180 ] [ 普通区80 ] │
|
||||
│ │
|
||||
│ ─────────── 座位图(多选)───────────────────── │
|
||||
│ 舞 台 │
|
||||
│ A排 [■■■■■] ← 可选(VIP,红色) │
|
||||
│ B排 [■■■■■] ← 可选(看台,蓝色) │
|
||||
│ C排 [灰掉] ← 不在当前分区 │
|
||||
│ │
|
||||
│ 图例:[■]可选 [██]已售 [░░]不可选 │
|
||||
│ │
|
||||
│ ─────────── 观演人表单 ───────────────────────── │
|
||||
│ 第1张票:张三 138****000 身份证(选填) │
|
||||
│ 第2张票:李四 139****111 身份证(选填) │
|
||||
│ │
|
||||
│ ─────────── 底部价格栏 ───────────────────────── │
|
||||
│ 已选 2 座,合计 ¥760 [提交订单] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 spec 选择器交互(参考原生 ShopXO spec 选择器行为)
|
||||
|
||||
用户切换场次/场馆/分区时,未在当前选择分支内的座位自动变灰/隐藏:
|
||||
|
||||
```
|
||||
切换场次 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位
|
||||
切换场馆 → 重置分区/座位 → 用 seatSpecMap 过滤出该场馆所有座位
|
||||
切换分区 → 只灰掉其他分区座位 → 用 seatSpecMap 过滤出该分区座位
|
||||
点击座位 → 复选/取消 → 更新 selectedSeats[]
|
||||
```
|
||||
|
||||
```javascript
|
||||
// 过滤函数
|
||||
function filterSeatMap(currentSession, currentVenueName, currentSectionChar) {
|
||||
Object.entries(seatSpecMap).forEach(function([seatKey, seatInfo]) {
|
||||
var spec = seatInfo.spec; // 4维数组
|
||||
|
||||
var hasSession = spec.some(function(s) {
|
||||
return s.type === '$vr-场次' && s.value === currentSession;
|
||||
});
|
||||
var hasVenue = spec.some(function(s) {
|
||||
return s.type === '$vr-场馆' && s.value.includes(currentVenueName);
|
||||
});
|
||||
var hasSection = !currentSectionChar || spec.some(function(s) {
|
||||
return s.type === '$vr-分区' && s.value.includes(currentSectionChar);
|
||||
});
|
||||
var isAvailable = seatInfo.inventory > 0;
|
||||
|
||||
var seatEl = document.querySelector('[data-seat-key="' + seatKey + '"]');
|
||||
if (!seatEl) return;
|
||||
|
||||
if (hasSession && hasVenue && hasSection) {
|
||||
seatEl.classList.toggle('sold', !isAvailable);
|
||||
seatEl.classList.toggle('disabled', false);
|
||||
} else {
|
||||
seatEl.classList.add('disabled');
|
||||
seatEl.classList.remove('sold');
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 从 vr_seat_template 渲染座位图
|
||||
|
||||
```javascript
|
||||
function renderSeatMap() {
|
||||
var rooms = vrSeatTemplate.rooms;
|
||||
|
||||
rooms.forEach(function(room) {
|
||||
room.map.forEach(function(rowStr, rowIndex) {
|
||||
var rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B
|
||||
var chars = rowStr.split(''); // 逐字符(PHP mb_str_split 兼容)
|
||||
|
||||
chars.forEach(function(char, colIndex) {
|
||||
if (char === '_' || char === '-') {
|
||||
// 渲染空白格子
|
||||
return;
|
||||
}
|
||||
var colNum = colIndex + 1; // 列号从 1 开始
|
||||
var seatKey = room.id + '_' + rowLabel + '_' + colNum; // "room_001_A_3"
|
||||
var seatInfo = room.seats[char]; // 查到座位属性
|
||||
|
||||
// 创建座位 DOM 元素
|
||||
var seatEl = document.createElement('div');
|
||||
seatEl.className = 'vr-seat';
|
||||
seatEl.dataset.seatKey = seatKey;
|
||||
seatEl.dataset.rowLabel = rowLabel;
|
||||
seatEl.dataset.colNum = colNum;
|
||||
seatEl.dataset.char = char;
|
||||
seatEl.dataset.roomId = room.id;
|
||||
seatEl.style.backgroundColor = seatInfo.color;
|
||||
seatEl.textContent = rowLabel + colNum;
|
||||
|
||||
// 点击事件:选座/取消
|
||||
seatEl.addEventListener('click', function() { toggleSeat(seatEl, seatKey); });
|
||||
|
||||
document.getElementById('room_' + room.id + '_seats').appendChild(seatEl);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、submit() 正确实现(P0 Issue 1 核心修复)
|
||||
|
||||
### 6.1 当前错误代码
|
||||
|
||||
原始 `ticket_detail.html` 中的 `submit()` 使用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时存储数据,导致购买流程失效。
|
||||
|
||||
### 6.2 修复后的 submit()
|
||||
|
||||
```javascript
|
||||
// var self = this; — 原始代码第6行已有此声明
|
||||
submit: function() {
|
||||
var self = this;
|
||||
|
||||
// 1. 收集观演人
|
||||
var inputs = document.querySelectorAll('#attendeeList input');
|
||||
var attendeeData = [];
|
||||
inputs.forEach(function(input) {
|
||||
var idx = parseInt(input.dataset.index);
|
||||
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||
attendeeData[idx][input.dataset.field] = input.value;
|
||||
});
|
||||
|
||||
// 2. 验证已选座位和观演人数量匹配
|
||||
if (this.selectedSeats.length === 0) {
|
||||
alert('请至少选择一个座位');
|
||||
return;
|
||||
}
|
||||
if (this.selectedSeats.length !== attendeeData.length) {
|
||||
alert('座位数与观演人信息数量不匹配');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 构建 ShopXO 原生 goods_data 格式
|
||||
//
|
||||
// ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(BuyService.php 第86行)
|
||||
// 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层!
|
||||
//
|
||||
// ⚠️ 【必须】直接传 JSON 字符串,不需要 base64
|
||||
// BuyService.php 第60行:!is_array($_POST['goods_data']) → json_decode()
|
||||
// ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64
|
||||
//
|
||||
// ⚠️ 【必须】spec 是完整的 4维数组,不是 1 维!
|
||||
// 从 seatSpecMap[seatKey].spec 读取,不要自己构造
|
||||
//
|
||||
// ⚠️ requestUrl 来自 PHP 模板注入:var requestUrl = '<?php echo Config("shopxo.host_url"); ?>';
|
||||
// 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url
|
||||
//
|
||||
var goodsDataList = this.selectedSeats.map(function(seat, i) {
|
||||
var seatInfo = seatSpecMap[seat.seatKey]; // 从注入的 seatSpecMap 查
|
||||
if (!seatInfo) {
|
||||
console.error('seatSpecMap missing key:', seat.seatKey);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
goods_id: self.goodsId,
|
||||
spec: seatInfo.spec, // 4维完整 spec 数组!从 seatSpecMap 来!
|
||||
stock: 1,
|
||||
order_base: { // ← 必须嵌套!不能平铺!
|
||||
extension_data: {
|
||||
attendee: {
|
||||
real_name: attendeeData[i]?.real_name || '',
|
||||
phone: attendeeData[i]?.phone || '',
|
||||
id_card: attendeeData[i]?.id_card || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
// 4. 过滤无效座位
|
||||
if (goodsDataList.length === 0) {
|
||||
alert('座位信息无效,请重新选择');
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 隐藏表单 POST 到 ShopXO Buy 链路
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = requestUrl + '?s=index/buy/index';
|
||||
document.body.appendChild(form);
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'goods_data';
|
||||
input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理
|
||||
form.appendChild(input);
|
||||
|
||||
form.submit(); // POST → Buy::Index → BuyDataStorage → 跳转确认页
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 ShopXO Buy 链路完整数据流(已验证可用)
|
||||
|
||||
```
|
||||
submit() POST goods_data(含 4维spec + extension_data)
|
||||
│
|
||||
├─→ Buy::Index (POST) → BuyDataStorage(user_id, data_post) [存入 session, TTL=21600s]
|
||||
│ ↑
|
||||
│ goods_data 是数组,json_encode 存入 session
|
||||
│
|
||||
└─→ 跳转 Buy::Index (GET) → BuyDataRead → 显示确认页
|
||||
│
|
||||
┌───────────────────────────────┘
|
||||
│
|
||||
└─→ form submit → Buy::Add → BuyService::OrderInsert($params)
|
||||
│
|
||||
BuyTypeGoodsList($params) → BuyGoods($params)
|
||||
│
|
||||
foreach($params['goods_data'] as $v) ← 多 SKU 原生遍历
|
||||
│
|
||||
GoodsSpecificationsHandle($v) → GoodsSpecDetail()
|
||||
│ 4维 type-value 匹配 GoodsSpecValue 表
|
||||
↓
|
||||
OrderInsertHandle($order_data)
|
||||
│
|
||||
BuyService.php 第773行:
|
||||
'extension_data' => json_encode($v['order_base']['extension_data'])
|
||||
│
|
||||
Db::name('order')->insertGetId($order) ← extension_data 写入 Order 表
|
||||
│
|
||||
微信支付...
|
||||
│
|
||||
┌────────────────────────────────┘
|
||||
│
|
||||
└─→ 支付成功 → Hook: plugins_service_order_pay_success_handle_end
|
||||
│
|
||||
TicketService::onOrderPaid($params)
|
||||
│
|
||||
Db::name('order')->find($order_id)
|
||||
↓
|
||||
json_decode($order['extension_data']) → 观演人信息
|
||||
↓
|
||||
foreach($order_goods as $og) {
|
||||
issueTicket($order, $og) // 幂等保护:seat_info 查重
|
||||
}
|
||||
│
|
||||
Db::name('vr_tickets')->insertGetId([
|
||||
'order_id' => $order['id'],
|
||||
'seat_info' => $spec_name,
|
||||
'real_name' => $attendee['real_name'],
|
||||
'phone' => $attendee['phone'],
|
||||
'id_card' => $attendee['id_card'],
|
||||
'ticket_code'=> $uuid,
|
||||
'qr_data' => AES加密(payload),
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、完整修复清单
|
||||
|
||||
| 优先级 | Issue | 任务 | 依赖 | 负责 |
|
||||
|--------|-------|------|------|------|
|
||||
| **P0** | Issue 1 | 重构 `GetGoodsViewData()` 新增 `seatSpecMap` | 后端 | BackendArchitect |
|
||||
| **P0** | Issue 1 | 前端 JS 用 `seatSpecMap` 替代 `specBaseIdMap` | P0 前置 | FrontendDev |
|
||||
| **P0** | Issue 1 | 修复 `submit()`:GET→POST + 正确 4维 spec 数组 | P0 前置 | FrontendDev |
|
||||
| **P0** | Issue 1 | Goods.php `MyViewAssign` 加入 `seatSpecMap` | P0 前置 | BackendArchitect |
|
||||
| **P1** | Issue 1 | 实现场次/场馆/分区 spec 选择器 UI + `filterSeatMap()` | P0 前置 | FrontendDev |
|
||||
| **P1** | Issue 1 | `selectSession()` / `selectVenue()` / `selectSection()` 联动逻辑 | P1 前置 | FrontendDev |
|
||||
| **P1** | Issue 2 | 缩放时舞台跟随(zoom wrapper 方案) | 无 | FrontendDev |
|
||||
| **P1** | Issue 3 | 新增 `sold_seats` API 端点 | 无 | BackendArchitect |
|
||||
| **P1** | Issue 3 | 前端 `loadSoldSeats()` 调用 API + 标记 `.sold` | P1 前置 | FrontendDev |
|
||||
| **P2** | Issue 4 | 商品详情图片展示(确认需求,补充 CSS) | 无 | FrontendDev |
|
||||
| **P2** | Issue 5 | `GetGoodsViewData()` 返回数组而非 `validConfigs[0]` | 无 | BackendArchitect |
|
||||
| **P2** | 审计 | 验证 `onOrderPaid` spec 匹配 + 幂等保护(FOR UPDATE) | 无 | BackendArchitect |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键代码索引
|
||||
|
||||
| 文件 | 行号 | 说明 |
|
||||
|------|------|------|
|
||||
| `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead |
|
||||
| `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + JSON decode(非 base64) |
|
||||
| `BuyService.php` | 86 | `foreach($params['goods_data'] as $v)` — 多 SKU 原生遍历 |
|
||||
| `BuyService.php` | 104-109 | GoodsSpecDetail — 4维 type-value 匹配 GoodsSpecValue |
|
||||
| `BuyService.php` | 773 | `extension_data => json_encode($v['order_base']['extension_data'])` |
|
||||
| `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 |
|
||||
| `buy/index.html` | 871 | 原生 form hidden goods_data field(JSON 字符串,非 base64) |
|
||||
| `TicketService.php` | 21-22 | Hook: `plugins_service_order_pay_success_handle_end` → `onOrderPaid` |
|
||||
| `TicketService.php` | 141-143 | `issueTicket` — 从 `$order['extension_data']` 读观演人 |
|
||||
| `SeatSkuService.php` | 40-45 | `SPEC_DIMS = ['$vr-场馆','$vr-分区','$vr-座位号','$vr-场次']` |
|
||||
| `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug |
|
||||
| `SeatSkuService.php` | ~131 | BatchGenerate — 4维 spec value 构建(完整路径字符串) |
|
||||
| `Hook.php` | 21-22 | `plugins_service_order_pay_success_handle_end` → TicketService::onOrderPaid |
|
||||
|
||||
---
|
||||
|
||||
## 九、第一性原则(设计决策记录)
|
||||
|
||||
1. **座位唯一性靠 ShopXO 原生 inventory**:每个 GoodsSpecBase 的 `inventory=1`,ShopXO 在 `OrderInsertHandle` 中扣库存,防超卖由 ShopXO 原生保证,不需要自己实现锁。
|
||||
|
||||
2. **`spec_base_id_map` 是性能缓存**:理想情况下 `onOrderPaid` 通过 `seat_key` 查询 GoodsSpecBase 即可,不需要 map 字段。但保留是合理的优化。
|
||||
|
||||
3. **`extension_data` 存储完全在 ShopXO 生态内**:不新建表,不扩展 ShopXO 字段,`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路 ShopXO 原生。
|
||||
|
||||
4. **`onOrderPaid` spec 匹配存在潜在 bug**(⚠️ 未来需关注):
|
||||
- `BatchGenerate` 写入 GoodsSpecValue.value 格式:`"VR 演唱会馆-1号演播厅-VIP区-A-1排3座"`(长路径字符串)
|
||||
- 前端 seatKey 格式:`"room_001_A_3"`(短格式)
|
||||
- 两者不匹配,`issueTicket` 第57-77行的反向 spec 查找会失效
|
||||
- 目前不影响功能(幂等靠 `seat_info` 字段,不依赖 spec_base_id)
|
||||
- 未来如需精确关联,需修复 BatchGenerate 的 value 写入格式
|
||||
|
||||
5. **最小修复原则**:Issue 1 的修复只需改 `submit()` 函数(POST + 正确 4维 spec 格式 + extension_data)。不需要重构 spec 系统,不需要绕过 Buy 链路。
|
||||
|
||||
---
|
||||
|
||||
*本文档为 vr-shopxo-plugin Phase 2 完整实现文档,Agent 可独立阅读并推进事务。*
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
# Phase 3 P0 — 5维 Spec 重构:演播室层补全
|
||||
|
||||
> 版本:v1.0 | 日期:2026-04-22 | 状态:**P0 阻塞**
|
||||
|
||||
---
|
||||
|
||||
## 一、问题定义
|
||||
|
||||
### 1.1 现象
|
||||
|
||||
当前商品详情页(ticket_detail.html)可以选择:
|
||||
- 场次
|
||||
- 场馆
|
||||
- 分区(A区/B区/C区)
|
||||
- 座位
|
||||
|
||||
但设计文档要求的层级是:
|
||||
```
|
||||
场次 → 场馆 → 演播室 → 分区 → 座位号
|
||||
```
|
||||
|
||||
**演播室(第3层)完全消失**。用户在整个购买流程中感知不到这一层的存在。
|
||||
|
||||
### 1.2 影响
|
||||
|
||||
- seat_map JSON 没有 `rooms` 字段,只有 flat `sections[]`
|
||||
- GoodsSpecType 里没有 `$vr-演播室` 记录
|
||||
- SPEC_DIMS 常量只有 4 维(缺演播室)
|
||||
- buildSeatSpecMap() 无法输出演播室维度
|
||||
- 前后端代码虽有 `rooms` fallback 预留,但从未真正启用
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型现状
|
||||
|
||||
### 2.1 当前 seat_map JSON(goods_id=112,座位模板 ID=1)
|
||||
|
||||
```json
|
||||
{
|
||||
"venue": {
|
||||
"name": "国家体育馆",
|
||||
"address": "北京市朝阳区奥体中心",
|
||||
"image": ""
|
||||
},
|
||||
"sections": [
|
||||
{"char": "A", "name": "VIP区", "color": "#e74c3c"},
|
||||
{"char": "B", "name": "看台", "color": "#3498db"},
|
||||
{"char": "C", "name": "普通", "color": "#2ecc71"}
|
||||
],
|
||||
"map": ["AAAAAA", "BBBBBB", "CCCCCC"],
|
||||
"seats": {
|
||||
"A": {"price": 899, "color": "#e74c3c", "label": "VIP"},
|
||||
"B": {"price": 599, "color": "#3498db", "label": "看台"},
|
||||
"C": {"price": 299, "color": "#2ecc71", "label": "普通"}
|
||||
},
|
||||
"row_labels": ["A", "B", "C"]
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:`sections`、`map`、`seats` 都是 flat 结构,没有 `rooms` 嵌套层。
|
||||
|
||||
### 2.2 目标 seat_map JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"venue": {
|
||||
"name": "国家体育馆",
|
||||
"address": "北京市朝阳区奥体中心",
|
||||
"image": ""
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room_001",
|
||||
"name": "主厅",
|
||||
"sections": [
|
||||
{"char": "A", "name": "VIP区", "color": "#e74c3c"},
|
||||
{"char": "B", "name": "看台", "color": "#3498db"},
|
||||
{"char": "C", "name": "普通", "color": "#2ecc71"}
|
||||
],
|
||||
"map": ["AAAAAA", "BBBBBB", "CCCCCC"],
|
||||
"seats": {
|
||||
"A": {"price": 899, "color": "#e74c3c", "label": "VIP"},
|
||||
"B": {"price": 599, "color": "#3498db", "label": "看台"},
|
||||
"C": {"price": 299, "color": "#2ecc71", "label": "普通"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**变更说明**:
|
||||
- `sections`、`map`、`seats` 从 flat 移入 `rooms[0]`
|
||||
- `rooms[].id` = 演播室标识(`room_001`)
|
||||
- `rooms[].name` = 演播室名称(`主厅`)
|
||||
- 保留 flat `sections/map/seats` 作为 fallback(Admin.php:646 和 ticket_detail.html:262 已有兼容逻辑)
|
||||
- 未来可扩展多个 room(多厅模式)
|
||||
|
||||
### 2.3 当前 GoodsSpecType(goods_id=112)
|
||||
|
||||
| ID | name | 含义 | value 示例 |
|
||||
|-----|----------------|-------------|-----------|
|
||||
| 1942 | `$vr-场馆` | 场馆 | `[{"name":"国家体育馆","images":""}]` |
|
||||
| 1943 | `$vr-分区` | 分区 | `[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]` |
|
||||
| 1944 | `$vr-时段` | 场次时间 | `[{"name":"2026-05-01 19:00","images":""}]` |
|
||||
| 1945 | `$vr-座位号` | 座位号 | `[{"name":"待选座位","images":""}]` |
|
||||
|
||||
**注意**:数据库里是 `$vr-时段`,SPEC_DIMS 里是 `$vr-场次`,需统一命名。
|
||||
|
||||
### 2.4 GoodsSpecValue(goods_id=112)
|
||||
|
||||
**当前:0 条**。
|
||||
|
||||
BatchGenerate() 虽然写了 GoodsSpecBase(SKU 存在,有价格/库存),但没有写 GoodsSpecValue(维度连接表),导致 buildSeatSpecMap() 只能从 seat_map JSON 反推维度,演播室完全丢失。
|
||||
|
||||
---
|
||||
|
||||
## 三、目标 5 维 Spec 结构
|
||||
|
||||
### 3.1 SPEC_DIMS 常量(目标值)
|
||||
|
||||
```php
|
||||
const SPEC_DIMS = [
|
||||
'$vr-场次', // 第1维:场次时间(来自 GoodsSpecType)
|
||||
'$vr-场馆', // 第2维:场馆名(来自 GoodsSpecType)
|
||||
'$vr-演播室', // 第3维:演播室(新增!)
|
||||
'$vr-分区', // 第4维:分区/区号(来自 seat_map.rooms[].sections)
|
||||
'$vr-座位号', // 第5维:座位号(来自 seat_key 的 row_col 部分)
|
||||
];
|
||||
```
|
||||
|
||||
**命名统一**:`$vr-场次` 替代 `$vr-时段`。
|
||||
|
||||
### 3.2 seat_key 格式(不变)
|
||||
|
||||
```
|
||||
{room_id}_{row_label}_{col_num}
|
||||
例:room_001_A_1 → 主厅 A排1号
|
||||
```
|
||||
|
||||
### 3.3 GoodsSpecType 目标记录(goods_id=112)
|
||||
|
||||
| 顺序 | name | 来源 | value 说明 |
|
||||
|-----|----------------|-------------|-----------|
|
||||
| 1 | `$vr-场次` | 商品规格维度 | 场次时间列表 |
|
||||
| 2 | `$vr-场馆` | 商品规格维度 | 场馆名 |
|
||||
| 3 | `$vr-演播室` | seat_map.rooms[].name | 演播室列表 |
|
||||
| 4 | `$vr-分区` | seat_map.rooms[].sections[].name | 分区列表 |
|
||||
| 5 | `$vr-座位号` | seat_key row_col | 座位号(自动生成)|
|
||||
|
||||
### 3.4 buildSeatSpecMap() 目标输出
|
||||
|
||||
```php
|
||||
$seatSpecMap['room_001_A_1'] = [
|
||||
'spec_base_id' => 123,
|
||||
'price' => 899.00,
|
||||
'inventory' => 1,
|
||||
'spec' => [
|
||||
['type' => '$vr-场次', 'value' => '2026-05-01 19:00'],
|
||||
['type' => '$vr-场馆', 'value' => '国家体育馆'],
|
||||
['type' => '$vr-演播室', 'value' => '主厅'], // ← 新增
|
||||
['type' => '$vr-分区', 'value' => 'VIP区'],
|
||||
['type' => '$vr-座位号', 'value' => 'A1'],
|
||||
],
|
||||
'venueName' => '国家体育馆',
|
||||
'roomId' => 'room_001',
|
||||
'roomName' => '主厅', // ← 新增
|
||||
'section' => ['char' => 'A', 'name' => 'VIP区', 'color' => '#e74c3c'],
|
||||
'rowLabel' => 'A',
|
||||
'colNum' => 1,
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、受影响文件清单
|
||||
|
||||
### 4.1 数据库(需 Migration)
|
||||
|
||||
| 表 | 操作 | 说明 |
|
||||
|----|------|------|
|
||||
| `vrt_goods_spec_type` | 清空 goods_id=112 的旧记录,重新插入5条 | 加 `$vr-演播室`,统一 `$vr-场次` |
|
||||
| `vrt_goods_spec_base` | 保留(已有 sku + seat_key) | 不改结构 |
|
||||
| `vrt_goods_spec_value` | 清空重建,按5维生成 | 连接 spec_base_id 和维度 |
|
||||
| `vrt_vr_seat_templates` | 更新 seat_map JSON | 加 rooms 层 |
|
||||
| `vrt_vr_goods_config` | 检查 config JSON 是否受影响 | 通常只存 template_id 和快照 |
|
||||
|
||||
> 当前数据量极小(1个模板,0条 GoodsSpecValue),可直接 truncate 后重生成。
|
||||
|
||||
### 4.2 PHP 文件
|
||||
|
||||
| 文件 | 行号 | 改动 |
|
||||
|------|------|------|
|
||||
| `SeatSkuService.php` | 29 | `SPEC_DIMS` 改为5维,加 `$vr-演播室`,`$vr-场次` 替代 `$vr-时段` |
|
||||
| `SeatSkuService.php` | ~171-178 | `batchGenerate()` 按5维提取维度,需加演播室提取 |
|
||||
| `SeatSkuService.php` | ~270 | `whereIn('name', SPEC_DIMS)` 过滤5个维度 |
|
||||
| `SeatSkuService.php` | ~306 | 插入缺失维度逻辑,按5维顺序 |
|
||||
| `SeatSkuService.php` | ~522-700 | `buildSeatSpecMap()` 完全重写:从 rooms[] 结构读取,加 roomName 提取 |
|
||||
| `SeatSkuService.php` | ~648-665 | switch case 加 `$vr-演播室` |
|
||||
| `BaseService.php` | 187-190 | 维度默认值数组加 `$vr-演播室` |
|
||||
| `TicketService.php` | 65,68 | `$vr-座位号` / `$vr-分区` 不变 |
|
||||
| `Admin.php` | ~176-191 | VenueSave 保存 seat_map 时自动包 rooms 层(fallback已有) |
|
||||
|
||||
### 4.3 前端文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `ticket_detail.html` | `specTypeList` 增加 `$vr-演播室` 选择器;`renderAllSelectors()` 渲染演播室选择器 |
|
||||
| `ticket_detail.html` | `filterSeats()` 增加 `currentRoom` 过滤条件 |
|
||||
| `ticket_detail.html` | `submit()` 的 spec 数组加 `$vr-演播室` 维度 |
|
||||
|
||||
### 4.4 不需要改的文件(fallback 已存在)
|
||||
|
||||
- `Admin.php:646-653` — 已有 `$seatMap['rooms']` fallback,会自动适配新 JSON
|
||||
- `ticket_detail.html:262` — 已有 `seatMapData.rooms[0].map` fallback
|
||||
- `ticket_detail.html:269-272` — 已有 `rooms` fallback 逻辑
|
||||
|
||||
---
|
||||
|
||||
## 五、Migration 执行步骤
|
||||
|
||||
> 假设 goods_id=112,座位模板 ID=1。
|
||||
|
||||
### Step 1:更新 seat_map JSON(座位模板)
|
||||
|
||||
```sql
|
||||
-- 查看当前 seat_map
|
||||
SELECT id, name, seat_map FROM vrt_vr_seat_templates WHERE id=1;
|
||||
|
||||
-- 更新 JSON 结构:加 rooms 层
|
||||
-- 旧结构 flat sections/map/seats → 移入 rooms[0]
|
||||
```
|
||||
|
||||
JSON 转换伪代码:
|
||||
```php
|
||||
$old = json_decode($old_seat_map, true);
|
||||
$new = [
|
||||
'venue' => $old['venue'],
|
||||
'rooms' => [[
|
||||
'id' => 'room_001',
|
||||
'name' => '主厅',
|
||||
'sections' => $old['sections'] ?? [],
|
||||
'map' => $old['map'] ?? [],
|
||||
'seats' => $old['seats'] ?? [],
|
||||
]],
|
||||
// 保留 flat fallback(兼容旧代码)
|
||||
'sections' => $old['sections'] ?? [],
|
||||
'map' => $old['map'] ?? [],
|
||||
'seats' => $old['seats'] ?? [],
|
||||
];
|
||||
```
|
||||
|
||||
### Step 2:重建 GoodsSpecType(5维)
|
||||
|
||||
```sql
|
||||
DELETE FROM vrt_goods_spec_type WHERE goods_id=112;
|
||||
|
||||
INSERT INTO vrt_goods_spec_type (goods_id, name, value, add_time) VALUES
|
||||
(112, '$vr-场次', '[{"name":"2026-05-01 19:00","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-场馆', '[{"name":"国家体育馆","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-演播室', '[{"name":"主厅","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-分区', '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-座位号', '[{"name":"待选座位","images":""}]', UNIX_TIMESTAMP());
|
||||
```
|
||||
|
||||
### Step 3:重建 GoodsSpecValue(连接 goods_spec_base 和维度)
|
||||
|
||||
当前 GoodsSpecBase 有 sku + extends.seat_key。需要生成 5 条 GoodsSpecValue 记录,每条对应一个维度。
|
||||
|
||||
GoodsSpecValue 表结构:
|
||||
```sql
|
||||
-- goods_spec_base_id → 哪个 SKU
|
||||
-- name → 维度名(如 $vr-演播室)
|
||||
-- value → 维度值(如 主厅)
|
||||
-- md5_key → 唯一键
|
||||
```
|
||||
|
||||
生成逻辑(参考 buildSeatSpecMap):
|
||||
1. 遍历所有 GoodsSpecBase(goods_id=112, inventory>0)
|
||||
2. 从 extends.seat_key 解析 room_id, row_label, col_num
|
||||
3. 从 seat_map JSON 反查 roomName(通过 room_id)
|
||||
4. 生成 5 条 GoodsSpecValue
|
||||
|
||||
### Step 4:验证
|
||||
|
||||
1. 访问商品详情页,检查 specTypeList 是否包含 5 个维度
|
||||
2. 检查前端演播室选择器是否正确渲染
|
||||
3. 选择座位后 submit,检查 goods_data 中的 spec 数组是否有 5 个维度
|
||||
4. 检查 BuyService::BuyGoods 能正确解析 5 维 goods_data
|
||||
|
||||
---
|
||||
|
||||
## 六、前端交互变更
|
||||
|
||||
### 6.1 新的选择器层级
|
||||
|
||||
```
|
||||
[场次选择器] ← goods_spec_data(已有)
|
||||
[场馆选择器] ← specTypeList['$vr-场馆'].options(已有)
|
||||
[演播室选择器] ← specTypeList['$vr-演播室'].options(新增!)
|
||||
[分区选择器] ← specTypeList['$vr-分区'].options(已有)
|
||||
[座位图] ← 按 currentRoom/currentSection 过滤(已有 filterSeats,需加 room 过滤)
|
||||
```
|
||||
|
||||
### 6.2 filterSeats() 变更
|
||||
|
||||
```javascript
|
||||
// 现有
|
||||
if (self.currentVenue) { matchVenue = ... }
|
||||
if (self.currentSection) { matchSection = ... }
|
||||
|
||||
// 新增
|
||||
if (self.currentRoom) {
|
||||
matchRoom = false;
|
||||
for (var i = 0; i < seatInfo.spec.length; i++) {
|
||||
if (seatInfo.spec[i].type === '$vr-演播室' && seatInfo.spec[i].value === self.currentRoom) {
|
||||
matchRoom = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 submit() spec 数组格式(不变)
|
||||
|
||||
```javascript
|
||||
spec: seatInfo.spec // 5维完整数组
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、已知约束
|
||||
|
||||
1. **当前是单 room 模式**(rooms[0]),演播室选择器默认选主厅,用户不可切换。未来可扩展多 room。
|
||||
2. **GoodsSpecValue 为 0 的根因**:BatchGenerate() 没有写 GoodsSpecValue,只有 GoodsSpecBase。这是之前就存在的问题,不是本次引入的。本次修复 BatchGenerate 的同时也要补全 GoodsSpecValue。
|
||||
3. **命名统一**:`$vr-时段` → `$vr-场次`,涉及 DB 数据和 SPEC_DIMS 常量。
|
||||
|
||||
---
|
||||
|
||||
## 八、验收标准
|
||||
|
||||
- [ ] seat_map JSON 有 `rooms[]` 结构,sections/map/seats 移入 rooms[0]
|
||||
- [ ] GoodsSpecType 有 5 条记录,包含 `$vr-演播室`
|
||||
- [ ] SPEC_DIMS 是 5 维数组
|
||||
- [ ] buildSeatSpecMap() 输出 seatSpecMap 包含 roomName 和 `$vr-演播室` 维度
|
||||
- [ ] 前端有演播室选择器
|
||||
- [ ] filterSeats() 按 currentRoom 过滤
|
||||
- [ ] submit() 提交的 spec 数组有 5 个维度
|
||||
- [ ] BuyService::BuyGoods 能正确处理 5 维 goods_data
|
||||
|
||||
---
|
||||
|
||||
## 九、相关文档
|
||||
|
||||
- `docs/SESSION_REPORT_20260421_PHASE2_FIX.md` — Phase 2 会话记录
|
||||
- `docs/FULL_PLAN.md` — 完整开发计划
|
||||
- `docs/PLAN_GHOST_SPEC_FIX.md` — 幽灵 spec 修复(相关)
|
||||
- `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` — 核心服务
|
||||
- `shopxo/app/plugins/vr_ticket/admin/Admin.php` — 座位模板管理
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
# Phase 3 前端执行计划
|
||||
|
||||
> 日期:2026-04-21 | 状态:✅ 已完成
|
||||
> 关联:PLAN_PHASE3_FRONTEND.md + Issue #17
|
||||
> 策略:谨慎保守,稳扎稳打
|
||||
|
||||
---
|
||||
|
||||
## 一、目标
|
||||
|
||||
**1 天内上线可演示的多座位下单 Demo**,验证购物车路线可行性。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状盘点
|
||||
|
||||
| 文件 | 当前状态 | 问题 |
|
||||
|------|---------|------|
|
||||
| `ticket_detail.html` | Plan A 代码有 bug | `submit()` URL 编码只传第一座、`selectSession()` 未重置座位 |
|
||||
| `ticket_detail.html` | 桩代码 | `loadSoldSeats()` 无实现 |
|
||||
| `ticket_detail.html` | 内联样式 | CSS 未分离,色值硬编码 |
|
||||
|
||||
---
|
||||
|
||||
## 三、执行步骤
|
||||
|
||||
### Step 1:修复 `submit()` 函数(P0)
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||
|
||||
**改动**:替换 `submit()` 函数,改走购物车 API。
|
||||
|
||||
```javascript
|
||||
submit: function() {
|
||||
// 1. 前置检查
|
||||
if (this.selectedSeats.length === 0) {
|
||||
alert('请先选择座位');
|
||||
return;
|
||||
}
|
||||
if (!this.userId) {
|
||||
alert('请先登录');
|
||||
location.href = this.requestUrl + '?s=index/user/logininfo';
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 收集观演人信息
|
||||
var inputs = document.querySelectorAll('#attendeeList input');
|
||||
var attendeeData = {};
|
||||
inputs.forEach(function(input) {
|
||||
var idx = input.dataset.index;
|
||||
var field = input.dataset.field;
|
||||
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||
attendeeData[idx][field] = input.value;
|
||||
});
|
||||
|
||||
// 3. 构建 goodsParamsList
|
||||
var self = this;
|
||||
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,
|
||||
stock: 1
|
||||
};
|
||||
});
|
||||
|
||||
// 4. 逐座提交到购物车(避免并发竞态,逐座串行提交)
|
||||
function submitNext(index) {
|
||||
if (index >= goodsParamsList.length) {
|
||||
// 全部成功 → 跳转购物车
|
||||
location.href = self.requestUrl + '?s=index/cart/index';
|
||||
return;
|
||||
}
|
||||
|
||||
var params = goodsParamsList[index];
|
||||
$.post(__goods_cart_save_url__, params, function(res) {
|
||||
if (res.code === 0 && res.data && res.data.id) {
|
||||
submitNext(index + 1);
|
||||
} else {
|
||||
alert('座位 [' + self.selectedSeats[index].label + '] 提交失败:' + (res.msg || '库存不足'));
|
||||
}
|
||||
}).fail(function() {
|
||||
alert('网络错误,请重试');
|
||||
});
|
||||
}
|
||||
|
||||
submitNext(0);
|
||||
}
|
||||
```
|
||||
|
||||
**保守策略**:
|
||||
- 使用**串行** `submitNext()` 递归,避免并发竞态
|
||||
- 每个座位单独请求,成功后提交下一个
|
||||
- 任意失败立即中断并弹窗提示
|
||||
|
||||
**验收测试**:
|
||||
- [ ] 选择 3 个座位 → 点击提交 → 购物车页显示 3 条商品
|
||||
- [ ] 座位 2 库存不足 → 弹窗提示,座位 1 不在购物车
|
||||
|
||||
---
|
||||
|
||||
### Step 2:修复场次切换状态重置(P0)
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||
|
||||
**改动**:在 `selectSession()` 函数开头添加状态重置。
|
||||
|
||||
```javascript
|
||||
selectSession: function(el) {
|
||||
// 【新增】切换场次时重置已选座位
|
||||
this.selectedSeats = [];
|
||||
|
||||
// 移除其他选中样式
|
||||
document.querySelectorAll('.vr-session-item').forEach(function(item) {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
el.classList.add('selected');
|
||||
this.currentSession = el.dataset.specId;
|
||||
this.sessionSpecId = el.dataset.specBaseId;
|
||||
|
||||
// 隐藏座位图和观演人区域(等待渲染)
|
||||
document.getElementById('seatSection').style.display = 'none';
|
||||
document.getElementById('selectedSection').style.display = 'none';
|
||||
document.getElementById('attendeeSection').style.display = 'none';
|
||||
|
||||
this.renderSeatMap();
|
||||
this.loadSoldSeats();
|
||||
}
|
||||
```
|
||||
|
||||
**保守策略**:
|
||||
- 重置后隐藏座位图和观演人区域,避免旧数据残留
|
||||
- 渲染完成后由 `updateSelectedUI()` 显示
|
||||
|
||||
**验收测试**:
|
||||
- [ ] 选择场次 A → 选 2 个座位 → 切换场次 B → 确认已选座位清零
|
||||
- [ ] 切换回场次 A → 确认已选座位仍然清零(严格隔离)
|
||||
|
||||
---
|
||||
|
||||
### Step 3:实现 `loadSoldSeats()`(P1)
|
||||
|
||||
#### 3.1 后端接口
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/controller/Index.php`
|
||||
|
||||
**新增方法**:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 获取场次已售座位列表
|
||||
* @method POST
|
||||
* @param goods_id 商品ID
|
||||
* @param spec_base_id 规格ID(场次)
|
||||
* @return json {code:0, data:{sold_seats:['A_1','A_2','B_5']}}
|
||||
*/
|
||||
public function SoldSeats()
|
||||
{
|
||||
// 鉴权
|
||||
if (!IsMobileLogin()) {
|
||||
return json_encode(['code' => 401, 'msg' => '请先登录']);
|
||||
}
|
||||
|
||||
// 获取参数
|
||||
$goodsId = input('goods_id', 0, 'intval');
|
||||
$specBaseId = input('spec_base_id', 0, 'intval');
|
||||
|
||||
if (empty($goodsId) || empty($specBaseId)) {
|
||||
return json_encode(['code' => 400, 'msg' => '参数错误']);
|
||||
}
|
||||
|
||||
// 查询已支付订单中的座位
|
||||
// 简化版:直接从已支付订单 item 的 extension_data 解析
|
||||
$orderService = new \app\service\OrderService();
|
||||
// 注意:此处需根据实际的 QR 票订单表结构查询
|
||||
|
||||
$soldSeats = [];
|
||||
return json_encode(['code' => 0, 'data' => ['sold_seats' => $soldSeats]]);
|
||||
}
|
||||
```
|
||||
|
||||
**保守策略**:
|
||||
- 第一版只返回空数组(不查数据库)
|
||||
- 后续迭代再接入真实数据
|
||||
|
||||
#### 3.2 前端调用
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||
|
||||
**改动 `loadSoldSeats()`**:
|
||||
|
||||
```javascript
|
||||
loadSoldSeats: function() {
|
||||
if (!this.currentSession || !this.goodsId) return;
|
||||
|
||||
var self = this;
|
||||
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||
goods_id: this.goodsId,
|
||||
spec_base_id: this.sessionSpecId
|
||||
}, function(res) {
|
||||
if (res.code === 0 && res.data && res.data.sold_seats) {
|
||||
res.data.sold_seats.forEach(function(seatKey) {
|
||||
self.soldSeats[seatKey] = true;
|
||||
});
|
||||
self.markSoldSeats();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
markSoldSeats: function() {
|
||||
var self = this;
|
||||
document.querySelectorAll('.vr-seat').forEach(function(el) {
|
||||
var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||
if (self.soldSeats[seatKey]) {
|
||||
el.classList.add('sold');
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**验收测试**:
|
||||
- [ ] 后端接口返回 `{"code":0,"data":{"sold_seats":["A_1","A_2"]}}` → A_1、A_2 标记为灰色已售
|
||||
|
||||
---
|
||||
|
||||
### Step 4:CSS 文件分离(P1)
|
||||
|
||||
#### 4.1 新建 CSS 文件
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/static/css/ticket.css`
|
||||
|
||||
**内容**(从 `ticket_detail.html` 的 `<style>` 块抽取):
|
||||
|
||||
```css
|
||||
/* VR票务 - 票务商品详情页样式 */
|
||||
/* 从 ticket_detail.html 内联样式抽取,2026-04-21 */
|
||||
|
||||
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.vr-ticket-header { margin-bottom: 20px; }
|
||||
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
||||
.vr-event-subtitle { color: #666; font-size: 14px; }
|
||||
|
||||
.vr-seat-section { margin-bottom: 30px; }
|
||||
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||
|
||||
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
||||
.vr-stage {
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
||||
padding: 15px 40px;
|
||||
margin: 0 auto 25px;
|
||||
max-width: 600px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
||||
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
||||
|
||||
.vr-seat {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
color: #fff;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
||||
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
||||
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
||||
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
||||
.vr-seat.space { background: transparent !important; cursor: default; }
|
||||
|
||||
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
||||
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
||||
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
||||
|
||||
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
||||
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
||||
.vr-selected-item {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #e8f4ff; border: 1px solid #b8d4f0;
|
||||
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
||||
}
|
||||
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
||||
|
||||
.vr-sessions { margin-bottom: 20px; }
|
||||
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||
.vr-session-item {
|
||||
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
||||
cursor: pointer; text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vr-session-item:hover { border-color: #409eff; }
|
||||
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
||||
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
||||
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
||||
|
||||
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
||||
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
||||
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||
|
||||
.vr-purchase-bar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: #fff; border-top: 1px solid #e8e8e8;
|
||||
padding: 12px 20px; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
.vr-purchase-info { font-size: 14px; color: #666; }
|
||||
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
||||
.vr-purchase-btn {
|
||||
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
||||
color: #fff; border: none; border-radius: 20px;
|
||||
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
||||
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||
```
|
||||
|
||||
#### 4.2 注册 Hook
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/hook/ViewGoodsCss.php`(新建)
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace app\plugins\vr_ticket\hook;
|
||||
|
||||
/**
|
||||
* 票务商品详情页 CSS 注入
|
||||
*/
|
||||
class ViewGoodsCss
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
return 'plugins/vr_ticket/css/ticket.css';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3 Service 注册 Hook
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/service/VrTicketService.php`
|
||||
|
||||
在 `CssData()` 或类似方法中添加:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 获取插件 CSS
|
||||
*/
|
||||
public function CssData()
|
||||
{
|
||||
return [
|
||||
'plugins/vr_ticket/css/ticket.css'
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **注意**:ShopXO 的 `plugins_css_data` 钩子注册方式需确认,可能需要在插件配置或 Service 中声明。请先验证 ShopXO 官方文档中插件 CSS 注入的标准方式。
|
||||
|
||||
#### 4.4 删除内联样式
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||
|
||||
删除 `<style>` 块(Line 3-118),保留注释占位:
|
||||
|
||||
```html
|
||||
<!-- VR票务样式已移至 plugins/vr_ticket/css/ticket.css -->
|
||||
```
|
||||
|
||||
**验收测试**:
|
||||
- [ ] `ticket_detail.html` 页面正常渲染,无样式丢失
|
||||
- [ ] 浏览器 DevTools Network 标签可见 `ticket.css` 请求
|
||||
|
||||
---
|
||||
|
||||
### Step 5:座位图缩放/拖拽交互(P2)
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||
|
||||
**功能**:`vr-seat-map-wrapper` 支持滚轮缩放 + 鼠标拖拽。
|
||||
|
||||
```javascript
|
||||
bindEvents: function() {
|
||||
var wrapper = document.querySelector('.vr-seat-map-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
var scale = 1;
|
||||
var isDragging = false;
|
||||
var startX, startY, translateX = 0, translateY = 0;
|
||||
|
||||
// 滚轮缩放
|
||||
wrapper.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
var delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
scale = Math.max(0.5, Math.min(3, scale + delta));
|
||||
var inner = wrapper.querySelector('.vr-seat-rows');
|
||||
if (inner) {
|
||||
inner.style.transform = 'scale(' + scale + ') translate(' + translateX + 'px, ' + translateY + 'px)';
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// 拖拽平移
|
||||
wrapper.addEventListener('mousedown', function(e) {
|
||||
isDragging = true;
|
||||
startX = e.clientX - translateX;
|
||||
startY = e.clientY - translateY;
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
translateX = e.clientX - startX;
|
||||
translateY = e.clientY - startY;
|
||||
var inner = wrapper.querySelector('.vr-seat-rows');
|
||||
if (inner) {
|
||||
inner.style.transform = 'scale(' + scale + ') translate(' + translateX + 'px, ' + translateY + 'px)';
|
||||
}
|
||||
});
|
||||
document.addEventListener('mouseup', function() {
|
||||
isDragging = false;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**验收测试**:
|
||||
- [ ] 滚轮向上滚动 → 座位图放大
|
||||
- [ ] 滚轮向下滚动 → 座位图缩小
|
||||
- [ ] 鼠标按住拖拽 → 座位图平移
|
||||
|
||||
---
|
||||
|
||||
## 四、文件清单
|
||||
|
||||
| 操作 | 文件 | 类型 |
|
||||
|------|------|------|
|
||||
| 修改 | `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 改 |
|
||||
| 新建 | `shopxo/app/plugins/vr_ticket/controller/Index.php` 方法 | 改 |
|
||||
| 新建 | `shopxo/app/plugins/vr_ticket/static/css/ticket.css` | 新 |
|
||||
| 新建 | `shopxo/app/plugins/vr_ticket/hook/ViewGoodsCss.php` | 新 |
|
||||
| 修改 | `shopxo/app/plugins/vr_ticket/service/VrTicketService.php` | 改 |
|
||||
|
||||
---
|
||||
|
||||
## 五、技术风险
|
||||
|
||||
| 风险 | 严重 | 缓解 |
|
||||
|------|------|------|
|
||||
| 购物车 `CartSave` 接口返回格式不一致 | 🔴 | Step 1 加 `console.log(res)` 临时调试 |
|
||||
| `plugins_css_data` 钩子注册方式不确定 | 🟡 | Step 4 前先查 ShopXO 文档确认 |
|
||||
| 已售座位数据查询依赖订单表结构 | 🟡 | Step 3 第一版返回空数组,后续迭代接入 |
|
||||
|
||||
---
|
||||
|
||||
## 六、验收测试总表
|
||||
|
||||
### P0(Step 1 + Step 2)
|
||||
|
||||
| # | 测试场景 | 预期结果 |
|
||||
|---|---------|---------|
|
||||
| 1 | 选择 3 个座位 → 提交 | 购物车页显示 3 条商品 |
|
||||
| 2 | 座位 2 库存不足 | 弹窗提示,已选座位清零 |
|
||||
| 3 | 选择场次 A → 选 2 座 → 切换场次 B | 已选座位清零,购买栏归零 |
|
||||
| 4 | 切换回场次 A | 座位图重新渲染,无旧数据残留 |
|
||||
|
||||
### P1(Step 3 + Step 4)
|
||||
|
||||
| # | 测试场景 | 预期结果 |
|
||||
|---|---------|---------|
|
||||
| 5 | `SoldSeats()` 返回 `["A_1","A_2"]` | A_1、A_2 标记灰色已售 |
|
||||
| 6 | 访问 `ticket_detail.html` | DevTools Network 可见 `ticket.css` 请求 |
|
||||
| 7 | 页面各区块布局 | 与内联样式版本一致 |
|
||||
|
||||
### P2(Step 5)
|
||||
|
||||
| # | 测试场景 | 预期结果 |
|
||||
|---|---------|---------|
|
||||
| 8 | 滚轮缩放 | 座位图平滑缩放(0.5x - 3x) |
|
||||
| 9 | 鼠标拖拽 | 座位图平滑平移 |
|
||||
|
||||
---
|
||||
|
||||
## 七、执行顺序
|
||||
|
||||
```
|
||||
Step 1 → Step 2 → Step 3 → Step 4 → Step 5
|
||||
↑ ↑ ↑ ↑
|
||||
P0 P0 P1 P1 P2
|
||||
```
|
||||
|
||||
**建议**:
|
||||
1. 先完成 Step 1 + Step 2,立即浏览器验证
|
||||
2. Step 3 需要后端配合,可与前端并行准备
|
||||
3. Step 4 可在 Step 1-2 验证通过后再做
|
||||
4. Step 5 作为可选优化项
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
# Phase 3 前端模板开发计划
|
||||
|
||||
> 日期:2026-04-20 | 状态:进行中
|
||||
> 背景:Council 调研结论 + CSS 样式机制确认 → Demo 快速落地
|
||||
|
||||
---
|
||||
|
||||
## 一、调研结论摘要(Council 651e0bf2)
|
||||
|
||||
### Q2 — 单订单多SKU(多座位选择的前提)
|
||||
|
||||
**结论:✅ 可行,走购物车路线**
|
||||
|
||||
ShopXO `BuyService.php:86` 循环处理 `goods_data` 数组,每行独立 `spec_base_id`。现有 `ticket_detail.html` Plan A 代码已写好,但 `submit()` 函数有 bug:只把第一个座位编码进 URL,后续座位丢失。
|
||||
|
||||
**最小改动(Demo 1天可上线):**
|
||||
- 修复 `submit()`:将 `goodsParamsList` 整体编码,POST 到购物车 `CartSave`,再跳转合并支付
|
||||
- 绕过 `OrderSplitService` 拆单风险(购物车结算路径不触发按仓库拆单)
|
||||
|
||||
### Q1 — ShopXO 自定义模板最佳实践
|
||||
|
||||
**结论:原生 PHP + 内联 JS,渐进增强**
|
||||
|
||||
- ShopXO view/goods/ 模板使用原生 PHP + 原生 JS,session/buy 控制器直接 render
|
||||
- 不走 DIY 设计器(只支持静态 HTML 区块,无法参数化)
|
||||
- H5 直接浏览器预览,无需构建
|
||||
|
||||
### Q3 — 第三方无代码构建服务
|
||||
|
||||
**结论:辅助有限,座位图等核心交互必须手写**
|
||||
|
||||
- 无代码服务适合静态展示区块(票务介绍、艺人信息图)
|
||||
- 座位图等高交互组件无法用无代码工具精确生成
|
||||
- 生成代码后需后处理:路径替换 + 变量注入
|
||||
|
||||
### Q4 — uni-app 兼容性技术栈选型
|
||||
|
||||
**结论:fork shopxo-uniapp,票务页面自研**
|
||||
|
||||
- fork `shopxo-uniapp` → `vr-shopxo-uniapp`
|
||||
- 票务页面(ticket-seat / ticket-wallet / ticket-verify)自研 Vue 3 组件
|
||||
- 商城标准页面复用 shopxo-uniapp 原生实现
|
||||
- CSS 一致(H5/小程序都基于 WebView)
|
||||
|
||||
---
|
||||
|
||||
## 二、CSS 样式注入机制(ShopXO 官方能力)
|
||||
|
||||
### 三层注入体系
|
||||
|
||||
| 层级 | 机制 | 甲方操作入口 |
|
||||
|------|------|------------|
|
||||
| **CSS 变量** | `header_style_root.html` 定义 `:root` 变量,后台主题配置可改 | ShopXO 后台「主题配色」 |
|
||||
| **插件 CSS Hook** | `plugins_css_data` 钩子注入独立 CSS 文件 | 替换 `static/plugins/vr_ticket/css/ticket.css` |
|
||||
| **内联 `<style>`** | 当前 `.vr-ticket-page` 样式块,完全隔离 | 直接修改 `ticket_detail.html` |
|
||||
|
||||
### CSS 变量体系(ShopXO 官方)
|
||||
|
||||
`header_style_root.html` 定义了完整的 CSS 变量系统:
|
||||
|
||||
```css
|
||||
/* 主色 */
|
||||
--color-main: #E22C08; /* 可在后台改为甲方品牌色 */
|
||||
--color-main-light: #ffe3de;
|
||||
--color-main-hover: #EA6B52;
|
||||
|
||||
/* 圆角 */
|
||||
--border-radius-sm: 0.2rem;
|
||||
--border-radius: 0.4rem;
|
||||
--border-radius-lg: 0.8rem;
|
||||
|
||||
/* 阴影 */
|
||||
--box-shadow: 0 5px 20px rgba(50,55,58,0.1);
|
||||
--box-shadow-sm: 0 2px 8px rgba(50,55,58,0.1);
|
||||
--box-shadow-lg: 0 8px 34px rgba(50,55,58,0.1);
|
||||
```
|
||||
|
||||
vr_ticket 模板内的 `.vr-ticket-page` 可以直接引用这些变量,实现主题色统一。例如:
|
||||
```css
|
||||
.vr-purchase-btn {
|
||||
background: var(--color-main); /* 继承 ShopXO 主题色 */
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--box-shadow-sm);
|
||||
}
|
||||
```
|
||||
|
||||
### 插件 CSS Hook(推荐方案)
|
||||
|
||||
在插件 service 中注册 `plugins_css_data` 钩子,加载独立 CSS 文件:
|
||||
|
||||
```php
|
||||
// plugins/vr_ticket/hook/ViewGoodsSpiderCss.php
|
||||
public function handle()
|
||||
{
|
||||
return 'plugins/vr_ticket/css/ticket.css';
|
||||
}
|
||||
```
|
||||
|
||||
甲方样式微调时,只需替换 `static/plugins/vr_ticket/css/ticket.css`,不需要改 PHP 模板。
|
||||
|
||||
### 当前 ticket_detail.html 样式结构
|
||||
|
||||
```
|
||||
ticket_detail.html
|
||||
├── <style> 完全独立的内联样式块(.vr-ticket-page 等)
|
||||
├── HTML 结构(.vr-ticket-page #vrTicketApp)
|
||||
├── 内联 JS(vrTicketApp 对象)
|
||||
└── ModuleInclude('public/footer')
|
||||
```
|
||||
|
||||
样式完全隔离,不受 ShopXO 升级影响。甲方设计师可以专注修改 CSS,不需要理解 PHP 模板逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 三、Demo 交付计划(最小可行方案)
|
||||
|
||||
### 目标:1天内上线可演示的多座位下单 Demo
|
||||
|
||||
### 当前代码状态
|
||||
|
||||
- `ticket_detail.html` 已有 Plan A 代码(submit 函数存在 URL 编码 bug)
|
||||
- 座位图渲染正常(A/B/C 三排 + 舞台 + 颜色分区 + 选座 UI + 观演人表单)
|
||||
- `loadSoldSeats()` 是 TODO,需要后端配合
|
||||
|
||||
### Demo 交付清单
|
||||
|
||||
#### P0 — 必须完成(Demo 当天)
|
||||
|
||||
| 任务 | 文件 | 说明 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| **修复 submit() bug** | `ticket_detail.html` | 当前只传第一个座位,需整体编码 goodsParamsList | 🔴 P0 |
|
||||
| **购物车路由接通** | `ticket_detail.html` | 改用 `CartSave` API 提交多座位,跳转合并支付 | 🔴 P0 |
|
||||
| **场次切换重置已选座位** | `ticket_detail.html` | `selectSession()` 调用座位重置逻辑(已有代码未调用) | 🔴 P0 |
|
||||
| **座位类型图例** | `ticket_detail.html` | 已完成 ✅,确认正常显示 | ✅ 已完成 |
|
||||
| **购买栏按钮状态联动** | `ticket_detail.html` | 已实现 ✅,`disabled` 状态根据选座数量变化 | ✅ 已完成 |
|
||||
|
||||
#### P1 — Demo 当天完成后继续
|
||||
|
||||
| 任务 | 文件 | 说明 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| **loadSoldSeats() 实现** | `ticket_detail.html` + 后端 | AJAX 调用后端接口,标记已售座位 | 🟡 P1 |
|
||||
| **座位图缩放/拖拽交互** | `ticket_detail.html` | 原生 JS < 200 行实现 | 🟡 P1 |
|
||||
| **CSS 样式文件分离** | `static/vr_ticket/css/ticket.css` | 从内联 `<style>` 抽离,通过 `plugins_css_data` 钩子注册 | 🟡 P1 |
|
||||
| **甲方主题色变量接入** | `ticket_detail.html` <style> | 将硬编码色值改为 `var(--color-main)` 等变量 | 🟡 P1 |
|
||||
|
||||
#### P2 — 后续迭代
|
||||
|
||||
| 任务 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| shopxo-uniapp fork | 建立 `vr-shopxo-uniapp` 项目骨架 | 🟢 P2 |
|
||||
| ticket-seat.vue | uni-app 选座核心组件 | 🟢 P2 |
|
||||
| B 端核销页 | 小程序扫码核销页面 | 🟢 P2 |
|
||||
|
||||
---
|
||||
|
||||
## 四、关键技术细节
|
||||
|
||||
### 4.1 submit() 修复方案
|
||||
|
||||
**当前 bug:** `location.href = checkoutUrl + '&goods_params=' + encodeURIComponent(goodsParams)`
|
||||
URL 方式只能传字符串,多座位数据会被截断或丢失。
|
||||
|
||||
**修复方案:** 走购物车 API + 合并支付
|
||||
|
||||
```javascript
|
||||
submit: function() {
|
||||
// 1. 收集所有座位数据(现有代码正常)
|
||||
var goodsParamsList = this.selectedSeats.map(function(seat, i) {
|
||||
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: { seatKey: seat.seatKey, label: seat.label, price: seat.price }
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// 2. 逐座提交到购物车
|
||||
var deferreds = goodsParamsList.map(function(params) {
|
||||
return $.post(__goods_cart_save_url__, {
|
||||
goods_id: params.goods_id,
|
||||
spec_id: params.spec_base_id,
|
||||
stock: params.stock,
|
||||
// extension_data 作为自定义字段存储
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 全部成功后跳转合并支付
|
||||
$.when.apply($, deferreds).done(function() {
|
||||
location.href = __root__ + '?s=index/cart/index';
|
||||
}).fail(function() {
|
||||
alert('座位已被占用,请重新选择');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**为什么走购物车路线:**
|
||||
- `BuyCart` → `BuyTypeGoodsList` → 直接调用 `BuyGoods`,完美支持多 `goods_data` 行
|
||||
- 购物车结算路径不触发 `OrderSplitService` 按仓库拆单(只按商品拆)
|
||||
- `plugins_service_order_pay_success_handle_end` 钩子正常触发,QR 票生成不受影响
|
||||
|
||||
### 4.2 CSS 变量主题化方案
|
||||
|
||||
当前 `ticket_detail.html` 内联样式中的硬编码色值,全部改为 CSS 变量:
|
||||
|
||||
```css
|
||||
/* 改造前 */
|
||||
.vr-purchase-btn { background: linear-gradient(135deg, #409eff, #3b8ef8); }
|
||||
|
||||
/* 改造后 */
|
||||
.vr-purchase-btn {
|
||||
background: linear-gradient(135deg, var(--color-main), var(--color-main-hover));
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--box-shadow-sm);
|
||||
}
|
||||
```
|
||||
|
||||
甲方想要调整主题色时,有三条路:
|
||||
1. **后台改**:ShopXO 管理后台 → 主题配色 → 自动同步到所有 `var(--color-*)` 变量
|
||||
2. **文件改**:替换 `static/plugins/vr_ticket/css/ticket.css`
|
||||
3. **代码改**:直接修改 `ticket_detail.html` 的 `<style>` 块
|
||||
|
||||
### 4.3 specBaseIdMap 降级策略
|
||||
|
||||
```javascript
|
||||
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||
```
|
||||
|
||||
- 如果 `seatKey`(如 "A_1")在 `specBaseIdMap` 中有对应记录 → 座位级 SKU(精确到每个座位)
|
||||
- 如果 `specBaseIdMap` 中没有(如后台未批量创建座位规格)→ 降级到 `sessionSpecId`(Zone 级别,同一 zone 全部座位共享一个 SKU)
|
||||
|
||||
**后台需要提前创建座位规格**,vr_ticket 后台管理界面需增加「批量生成座位规格」功能。
|
||||
|
||||
---
|
||||
|
||||
## 五、文件清单
|
||||
|
||||
| 文件 | 当前状态 | 下一步 |
|
||||
|------|---------|--------|
|
||||
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | Plan A 代码有 bug | 修复 submit(),接购物车 |
|
||||
| `shopxo/app/plugins/vr_ticket/static/css/ticket.css` | 不存在 | 从 ticket_detail.html 抽离样式 |
|
||||
| `docs/council-research-output.md` | ✅ 已完成 | 无需修改 |
|
||||
| `shopxo/app/service/BuyService.php` | 参考 | 无需修改(走购物车路线) |
|
||||
| `shopxo/app/service/SeatSkuService.php` | 有缺陷(单模板模式) | 等待 Issue #15/#16 修复 |
|
||||
|
||||
---
|
||||
|
||||
## 六、技术风险
|
||||
|
||||
| 风险 | 严重程度 | 缓解 |
|
||||
|------|---------|------|
|
||||
| `submit()` 只传第一座位(已发现) | 🔴 高 | 修复 submit(),改走购物车 API |
|
||||
| OrderSplitService 拆单(多座位变多笔支付) | 🔴 高 | 购物车路线绕过 |
|
||||
| 座位级 SKU 后台未创建(specBaseIdMap 空) | 🟡 中 | 降级到 Zone 级别 + 后台增加批量生成功能 |
|
||||
| 甲方主题色调整后样式不一致 | 🟡 中 | CSS 变量化,所有色值引用 `var(--color-*)` |
|
||||
| shopxo-uniapp fork 官方更新同步成本 | 🟢 低 | 票务页面与商城页面目录隔离 |
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
# VR Ticket 插件 Phase 2 - 会话工作报告
|
||||
|
||||
**会话时间**: 2026-04-21
|
||||
**会话 ID**: 9ef6fb5b-c23e-477a-b139-339d172fe223
|
||||
**主要任务**: 修复购买提交流程 + 实现 4 维规格选择器
|
||||
|
||||
---
|
||||
|
||||
## 一、本次完成的工作
|
||||
|
||||
### 1.1 数据库层修复
|
||||
|
||||
#### 问题:错误的 `type` 字段
|
||||
- **原因**: 之前为了区分规格维度,尝试在 `vrt_goods_spec_value` 表添加了 `type` 字段
|
||||
- **用户要求**: 禁止修改数据库结构,必须使用现有字段
|
||||
- **解决方案**: 完全回滚 `type` 字段,通过 `GoodsSpecType.name` + 值匹配来确定维度
|
||||
|
||||
#### 最终方案
|
||||
```
|
||||
GoodsSpecType.name = "$vr-场次"
|
||||
GoodsSpecType.value = '[{"name":"08:00-23:59",...}]'
|
||||
|
||||
GoodsSpecValue.value = "08:00-23:59" // 通过值匹配确定属于哪个维度
|
||||
```
|
||||
|
||||
### 1.2 后端修改 (SeatSkuService.php)
|
||||
|
||||
#### 修改 1: 移除 `BatchGenerate()` 中的 type 字段插入
|
||||
```php
|
||||
// 修复前(错误)
|
||||
$valueBatch[] = [
|
||||
'type' => self::SPEC_DIMS[$idx] ?? '', // ❌ 数据库没有 type 列
|
||||
'value' => (string)$specVal,
|
||||
...
|
||||
];
|
||||
|
||||
// 修复后(正确)
|
||||
$valueBatch[] = [
|
||||
'value' => (string)$specVal, // ✅ 只插入 value
|
||||
'md5_key' => md5((string)$specVal),
|
||||
'add_time' => $now,
|
||||
];
|
||||
```
|
||||
|
||||
#### 修改 2: `buildSeatSpecMap()` - 通过值匹配确定维度
|
||||
```php
|
||||
// 从 GoodsSpecType 读取维度定义
|
||||
$specTypes = Db::name('GoodsSpecType')
|
||||
->where('goods_id', $goodsId)
|
||||
->order('id', 'asc')
|
||||
->select();
|
||||
|
||||
// 构建 name => [values] 映射
|
||||
$dimValuesByName = [];
|
||||
foreach ($specTypes as $type) {
|
||||
$values = json_decode($type['value'] ?? '[]', true);
|
||||
foreach ($values as $v) {
|
||||
if (isset($v['name'])) {
|
||||
$dimValuesByName[$type['name']][] = $v['name'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通过值匹配确定维度
|
||||
foreach ($specValues as $sv) {
|
||||
$value = $sv['value'];
|
||||
foreach ($dimValuesByName as $name => $values) {
|
||||
if (in_array($value, $values)) {
|
||||
// $value 属于维度 $name
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 修改 3: `GetGoodsViewData()` - 返回 `specTypeList`
|
||||
```php
|
||||
return [
|
||||
'vr_seat_template' => $seatTemplate ?: null,
|
||||
'goods_spec_data' => $goodsSpecData,
|
||||
'seatSpecMap' => $seatSpecMap,
|
||||
'specTypeList' => $specTypeList, // 新增:4维规格类型列表
|
||||
'goods_config' => $config,
|
||||
];
|
||||
```
|
||||
|
||||
#### 修改 4: 修复缺失的 `buildSeatSpecMap()` 调用
|
||||
```php
|
||||
// ❌ 问题:函数定义了但从未调用
|
||||
private static function buildSeatSpecMap(...) { ... } // 第522行定义
|
||||
|
||||
// ✅ 修复:在 GetGoodsViewData 中调用
|
||||
$seatSpecMap = self::buildSeatSpecMap($goodsId, $seatTemplate);
|
||||
```
|
||||
|
||||
### 1.3 前端修改 (ticket_detail.html)
|
||||
|
||||
#### 新增:场馆和分区选择器
|
||||
```html
|
||||
<!-- HTML 容器 -->
|
||||
<div id="venueSelector"><!-- JS 动态渲染 --></div>
|
||||
<div id="sectionSelector"><!-- JS 动态渲染 --></div>
|
||||
```
|
||||
|
||||
#### 新增:JavaScript 函数
|
||||
- `renderAllSelectors()` - 渲染场次/场馆/分区选择器
|
||||
- `selectVenue()` - 选择场馆
|
||||
- `selectSection()` - 选择分区
|
||||
- `filterSeats()` - 根据选择过滤座位
|
||||
|
||||
### 1.4 样式修复
|
||||
|
||||
#### 问题:CSS 不生效
|
||||
- **原因**: ShopXO 静态文件必须在 `public/plugins/` 目录,不在 `app/plugins/`
|
||||
- **解决**: 同步 CSS 到 `public/plugins/vr_ticket/static/css/ticket.css`
|
||||
|
||||
---
|
||||
|
||||
## 二、关键经验教训
|
||||
|
||||
### 2.1 数据库设计原则
|
||||
| ❌ 错误做法 | ✅ 正确做法 |
|
||||
|-----------|-----------|
|
||||
| 添加 `type` 列来区分维度 | 使用 `GoodsSpecType.name` 区分 |
|
||||
| 依赖插入顺序匹配维度 | 通过值匹配确定维度 |
|
||||
| 修改数据库结构 | 适配现有数据库结构 |
|
||||
|
||||
### 2.2 ShopXO 插件开发规范
|
||||
```
|
||||
✅ 静态文件位置: public/plugins/vr_ticket/static/
|
||||
❌ 静态文件位置: app/plugins/vr_ticket/static/
|
||||
```
|
||||
|
||||
**重要**: 修改 `app/` 目录后,需要同步到 `public/` 目录才能生效!
|
||||
|
||||
### 2.3 代码调试经验
|
||||
1. **Undefined variable 错误**: 检查变量是否在使用前被赋值
|
||||
2. **CSS 不生效**: 检查浏览器缓存、文件路径、实际访问路径
|
||||
3. **数据库错误**: 确保 SQL 语句与实际表结构匹配
|
||||
|
||||
### 2.4 函数调用遗漏问题
|
||||
```php
|
||||
// ❌ 容易犯的错误:定义函数但不调用
|
||||
private static function buildSeatSpecMap(...) { ... }
|
||||
|
||||
// ✅ 正确做法:确保在正确的位置调用
|
||||
public static function GetGoodsViewData(...) {
|
||||
$seatSpecMap = self::buildSeatSpecMap(...); // 调用!
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、技术架构决策
|
||||
|
||||
### 3.1 规格维度识别方案
|
||||
```
|
||||
维度识别流程:
|
||||
GoodsSpecType.name → 确定有哪些维度(场馆、分区、座位号、场次)
|
||||
GoodsSpecType.value → 每个维度的可选值列表
|
||||
GoodsSpecValue.value → 具体 SKU 的值
|
||||
↓
|
||||
在 dimValuesByName 中匹配
|
||||
↓
|
||||
确定属于哪个维度
|
||||
```
|
||||
|
||||
### 3.2 数据流
|
||||
```
|
||||
商品保存 → BatchGenerate()
|
||||
→ 生成 GoodsSpecBase (含 extends.seat_key)
|
||||
→ 生成 GoodsSpecValue (只有 value)
|
||||
→ 生成 GoodsSpecType (name + value 数组)
|
||||
|
||||
商品展示 → GetGoodsViewData()
|
||||
→ buildSeatSpecMap() (通过值匹配构建 seatSpecMap)
|
||||
→ 返回 specTypeList (前端选择器用)
|
||||
→ 返回 seatSpecMap (座位映射用)
|
||||
|
||||
用户选择 → filterSeats()
|
||||
→ 根据 currentSession/currentVenue/currentSection 过滤
|
||||
|
||||
用户提交 → submit()
|
||||
→ 从 seatSpecMap[key].spec 获取 4 维规格
|
||||
→ POST 提交完整规格数组
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、已删除的临时文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `sql/fix_spec_value.sql` | 不再需要的 SQL 修复脚本 |
|
||||
| `sql/fix_spec_type.sql` | 不再需要的 SQL 修复脚本 |
|
||||
| `shopxo/app/plugins/vr_ticket/regenerate_spec.php` | 临时数据生成脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 五、后续待办
|
||||
|
||||
### P0 (阻塞)
|
||||
- [ ] 完整购买流程测试
|
||||
- [ ] 验证 POST 提交 4 维 spec 数组
|
||||
|
||||
### P1
|
||||
- [ ] 场次/场馆/分区选择器联动过滤
|
||||
- [ ] 缩放时舞台跟随
|
||||
|
||||
### P2
|
||||
- [ ] 商品详情图片展示
|
||||
- [ ] 多场次支持
|
||||
|
||||
---
|
||||
|
||||
## 六、相关文档
|
||||
|
||||
| 文档 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 完整开发计划 | `docs/FULL_PLAN.md` | Phase 2 完整方案 |
|
||||
| Agent 执行 Prompt | `docs/AGENT_PROMPT.md` | 执行指南 |
|
||||
| 阶段评估报告 | `docs/COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md` | 之前的问题评估 |
|
||||
|
||||
---
|
||||
|
||||
## 七、Git 提交记录
|
||||
|
||||
```bash
|
||||
fix: 移除 type 字段插入(数据库已回滚)
|
||||
fix: GetGoodsViewData 使用 GoodsSpecType.name 通过值匹配确定维度
|
||||
chore: 删除不再需要的 SQL 修复文件
|
||||
chore: 删除临时脚本
|
||||
feat: 添加场馆和分区选择器 + specTypeList 支持
|
||||
fix: 添加缺失的 buildSeatSpecMap() 调用
|
||||
fix: 优化规格选择器样式 - 处理长名称显示和添加 tooltip
|
||||
fix: CSS 文件路径 - 同步到 public/plugins/ 目录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*报告生成时间: 2026-04-21T14:03:33+08:00*
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
# 商品详情扩展字段数据字典与前端使用说明
|
||||
|
||||
> 日期:2026-04-21
|
||||
> 用途:前端 agent(antigravity / cursor)拿到商品详情页时,扩展字段里有哪些可用数据?如何用?
|
||||
|
||||
---
|
||||
|
||||
## 一、核心数据结构全貌
|
||||
|
||||
商品详情页加载时,PHP 后端向模板注入以下变量:
|
||||
|
||||
| 模板变量名 | 来源 | 说明 |
|
||||
|-----------|------|------|
|
||||
| `$goods` | ShopXO GoodsService | ShopXO 原生商品数据(id/title/price/content/images 等) |
|
||||
| `$vr_seat_template` | `SeatSkuService::GetGoodsViewData()` | 票务插件扩展数据 |
|
||||
| `$goods_spec_data` | `SeatSkuService::GetGoodsViewData()` | 场次列表 |
|
||||
|
||||
---
|
||||
|
||||
## 二、vr_goods_config(goods 表扩展字段)
|
||||
|
||||
存储位置:`goods.vr_goods_config`(JSON 字段)
|
||||
|
||||
这是商品发布时由管理员配置的数据快照,**前端只能读,不能写**。
|
||||
|
||||
### 完整 JSON 示例(商品 118,VR 演唱会)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"version": 3.0,
|
||||
"template_id": 4,
|
||||
"selected_rooms": ["room_001", "room_002"],
|
||||
"selected_sections": {
|
||||
"room_001": ["A", "B"],
|
||||
"room_002": ["A"]
|
||||
},
|
||||
"sessions": [
|
||||
{ "start": "15:00", "end": "16:59" },
|
||||
{ "start": "18:00", "end": "20:59" }
|
||||
],
|
||||
"template_snapshot": {
|
||||
"venue": {
|
||||
"name": "VR 体验馆",
|
||||
"address": "北京市朝阳区建国路88号",
|
||||
"location": { "lng": "116.45792", "lat": "39.90745" },
|
||||
"images": [
|
||||
"/static/attachments/202603/venue_001.jpg",
|
||||
"/static/attachments/202603/venue_002.jpg"
|
||||
]
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room_001",
|
||||
"name": "1号演播厅",
|
||||
"map": [
|
||||
"AAAAA_____BBBBB",
|
||||
"AAAAA_____BBBBB",
|
||||
"AAAAA_____BBBBB",
|
||||
"CCCCCCCCCCCCCCC",
|
||||
"CCCCCCCCCCCCCCC"
|
||||
],
|
||||
"sections": [
|
||||
{ "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||
{ "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||
{ "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||
],
|
||||
"seats": {
|
||||
"A": { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||
"B": { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||
"C": { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "room_002",
|
||||
"name": "2号演播厅(副厅)",
|
||||
"map": [
|
||||
"DDDDDDD",
|
||||
"DDDDDDD",
|
||||
"EEEEEEE"
|
||||
],
|
||||
"sections": [
|
||||
{ "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||
{ "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||
],
|
||||
"seats": {
|
||||
"D": { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||
"E": "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 字段说明表
|
||||
|
||||
#### 顶层字段
|
||||
|
||||
| 字段 | 类型 | 前端可用性 | 说明 |
|
||||
|------|------|-----------|------|
|
||||
| `version` | float | ⚪ 不需要 | 协议版本,用于兼容判断 |
|
||||
| `template_id` | int | ⚪ 不需要 | 关联的座位模板 ID,内部使用 |
|
||||
| `selected_rooms` | string[] | ✅ 可用 | 用户在后台选中的房间 ID 列表 |
|
||||
| `selected_sections` | object | ✅ 可用 | key=房间ID,value=该房间选中的分区字符数组 |
|
||||
| `sessions` | object[] | ✅ 可用(**重要**) | 场次列表,每个场次有 start/end/price |
|
||||
| `template_snapshot` | object | ✅ 可用(**核心**) | 座位图的完整快照,前端渲染数据来源 |
|
||||
|
||||
#### template_snapshot.venue
|
||||
|
||||
| 字段 | 前端可用性 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `name` | ✅ 可用 | 场馆名称(用于展示) |
|
||||
| `address` | ✅ 可用 | 场馆地址(用于展示) |
|
||||
| `location.lng/lat` | ⚠️ 可选 | 经纬度,用于地图展示 |
|
||||
| `images` | ✅ 可用 | 场馆图片列表(用于顶部 Banner) |
|
||||
|
||||
#### template_snapshot.rooms[](每个房间)
|
||||
|
||||
| 字段 | 前端可用性 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `id` | ✅ 可用(**重要**) | 房间唯一 ID,用于前端 seatKey 构造 |
|
||||
| `name` | ✅ 可用 | 房间名称(用于场馆切换选择器) |
|
||||
| `map` | ✅ 可用(**核心**) | 座位图字符矩阵,用于渲染座位行 |
|
||||
| `sections[]` | ✅ 可用 | 分区列表(char→name/price/color,用于图例 + 分区切换) |
|
||||
| `seats` | ✅ 可用 | char→座位属性映射,用于查找座位详情 |
|
||||
|
||||
#### template_snapshot.rooms[].map 格式说明
|
||||
|
||||
`map` 是一个字符串数组,每行对应座位图的一行:
|
||||
|
||||
```json
|
||||
["AAAAA_____BBBBB", "AAAAA_____BBBBB"]
|
||||
```
|
||||
|
||||
- 字符 `'A'` / `'B'` / `'C'` = 座位(char),通过 `seats[char]` 查到座位属性(分区/价格/颜色)
|
||||
- 字符 `'_'` = 空位(不渲染座位元素)
|
||||
- 字符 `'-'` = 空位(不渲染座位元素)
|
||||
- 其他非字母字符 = 不渲染
|
||||
|
||||
**如何从 map 渲染座位**:
|
||||
```javascript
|
||||
// map = ["AAAAA_____BBBBB", "AAAAA_____BBBBB"]
|
||||
map.forEach(function(rowStr, rowIndex) {
|
||||
var rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B, ...
|
||||
var chars = rowStr.split(''); // ['A','A','A','A','A','_',...,'B','B','B','B','B']
|
||||
chars.forEach(function(char, colIndex) {
|
||||
if (char === '_' || char === '-') return; // 跳过空位
|
||||
var seatInfo = rooms[i].seats[char]; // 查到座位属性
|
||||
// colIndex + 1 = colNum(列号,从1开始)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**注意**:PHP `mb_str_split()` 在某些环境不可用,用 `split('')` 即可(座位字符都是 ASCII)。
|
||||
|
||||
---
|
||||
|
||||
## 三、GetGoodsViewData() 注入的模板数据
|
||||
|
||||
这是后端处理后注入到模板的变量,**前端可以直接使用**。
|
||||
|
||||
### 3.1 注入变量总览
|
||||
|
||||
```php
|
||||
// Goods.php 票务判断块
|
||||
$viewData = \app\plugins\vr_ticket\service\SeatSkuService::GetGoodsViewData($goods_id);
|
||||
MyViewAssign([
|
||||
'vr_seat_template' => $viewData['vr_seat_template'], // 座位图数据
|
||||
'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表
|
||||
// 【待新增】
|
||||
'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 座位→规格映射
|
||||
]);
|
||||
```
|
||||
|
||||
### 3.2 vr_seat_template(注入后模板中访问 `$vr_seat_template`)
|
||||
|
||||
```javascript
|
||||
// PHP 模板输出(JSON 注入)
|
||||
var vrSeatTemplate = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||
var seatSpecMap = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||
var goodsSpecData = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||
```
|
||||
|
||||
#### vr_seat_template 数据结构
|
||||
|
||||
```javascript
|
||||
{
|
||||
// === 直接透传 template_snapshot(来源:goods.vr_goods_config)===
|
||||
venue: {
|
||||
name: "VR 体验馆",
|
||||
address: "北京市朝阳区建国路88号",
|
||||
location: { lng: "116.45792", lat: "39.90745" },
|
||||
images: ["/static/attachments/202603/venue_001.jpg"]
|
||||
},
|
||||
rooms: [
|
||||
{
|
||||
id: "room_001",
|
||||
name: "1号演播厅",
|
||||
map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"],
|
||||
sections: [
|
||||
{ char: "A", name: "VIP区", price: 380, color: "#f06292" },
|
||||
{ char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
|
||||
{ char: "C", name: "普通区", price: 80, color: "#81c784" }
|
||||
],
|
||||
seats: {
|
||||
A: { char: "A", name: "VIP区", price: 380, color: "#f06292" },
|
||||
B: { char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
|
||||
C: { char: "C", name: "普通区", price: 80, color: "#81c784" }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "room_002",
|
||||
name: "2号演播厅(副厅)",
|
||||
map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"],
|
||||
sections: [
|
||||
{ char: "D", name: "互动区", price: 280, color: "#ffb74d" },
|
||||
{ char: "E", name: "站票区", price: 50, color: "#90a4ae" }
|
||||
],
|
||||
seats: {
|
||||
D: { char: "D", name: "互动区", price: 280, color: "#ffb74d" },
|
||||
E: { char: "E", name: "站票区", price: 50, color: "#90a4ae" }
|
||||
}
|
||||
}
|
||||
],
|
||||
sessions: [
|
||||
{ start: "15:00", end: "16:59" },
|
||||
{ start: "18:00", end: "20:59" }
|
||||
],
|
||||
// === 来自 goods.vr_goods_config 的原始选择数据 ===
|
||||
selectedRooms: ["room_001", "room_002"],
|
||||
selectedSections: {
|
||||
"room_001": ["A", "B"],
|
||||
"room_002": ["A"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### goods_spec_data(场次列表)
|
||||
|
||||
```javascript
|
||||
// 来源:goods.vr_goods_config.sessions + GoodsSpecBase.price
|
||||
[
|
||||
{ spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" },
|
||||
{ spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" }
|
||||
]
|
||||
// ⚠️ 注意:spec_id 是 GoodsSpecBase ID(场次级别,非座位级别)
|
||||
// 前端不需要直接使用 spec_id,直接使用 sessions 数组即可
|
||||
```
|
||||
|
||||
### 3.3 seatSpecMap(待新增:GetGoodsViewData 返回的核心数据)
|
||||
|
||||
**来源**:`GetGoodsViewData()` 查询 GoodsSpecBase + GoodsSpecValue + GoodsSpecBase.extends,动态构建
|
||||
|
||||
**用途**:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 spec_base_id
|
||||
|
||||
```javascript
|
||||
// key 格式:{roomId}_{rowLabel}_{colNum}
|
||||
// 例如:room_001_A_3 = room_001 的 A排 第3列
|
||||
|
||||
{
|
||||
"room_001_A_1": {
|
||||
spec_base_id: 10001,
|
||||
price: 380,
|
||||
inventory: 1,
|
||||
rowLabel: "A",
|
||||
colNum: 3,
|
||||
roomId: "room_001",
|
||||
section: { char: "A", name: "VIP区", color: "#f06292" },
|
||||
// === 4维 spec 数组(submit() 时直接使用)===
|
||||
spec: [
|
||||
{ type: "$vr-场馆", value: "VR 体验馆" },
|
||||
{ type: "$vr-分区", value: "VR 体验馆-1号演播厅-VIP区" },
|
||||
{ type: "$vr-座位号", value: "VR 体验馆-1号演播厅-VIP区-A-1排3座" },
|
||||
{ type: "$vr-场次", value: "15:00-16:59" }
|
||||
]
|
||||
},
|
||||
"room_001_A_2": { spec_base_id: 10002, price: 380, inventory: 1, /* ... */ },
|
||||
"room_001_A_3": { /* 同上,A排第3座 */ },
|
||||
"room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ },
|
||||
"room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* ... */ },
|
||||
// ... 每个可购座位一行
|
||||
}
|
||||
```
|
||||
|
||||
#### seatSpecMap 生成逻辑(GetGoodsViewData 中实现)
|
||||
|
||||
```php
|
||||
// 1. 查询所有有效 GoodsSpecBase(含 extends.seat_key)
|
||||
$specs = Db::name('GoodsSpecBase')
|
||||
->where('goods_id', $goodsId)
|
||||
->where('inventory', '>', 0) // 只取有库存的
|
||||
->select();
|
||||
|
||||
// 2. 查询对应的 GoodsSpecValue(4个维度的值)
|
||||
$specIds = array_column($specs->toArray(), 'id');
|
||||
$specValues = Db::name('GoodsSpecValue')
|
||||
->whereIn('goods_spec_base_id', $specIds)
|
||||
->select();
|
||||
|
||||
// 3. 按 spec_base_id 分组,构建 4维 spec 数组
|
||||
$specByBaseId = [];
|
||||
foreach ($specValues as $sv) {
|
||||
$specByBaseId[$sv['goods_spec_base_id']][] = [
|
||||
'type' => $sv['type'], // "$vr-场馆" / "$vr-分区" / "$vr-座位号" / "$vr-场次"
|
||||
'value' => $sv['value'], // 完整路径字符串
|
||||
];
|
||||
}
|
||||
|
||||
// 4. 构建 seatSpecMap
|
||||
$seatSpecMap = [];
|
||||
foreach ($specs as $spec) {
|
||||
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||
$seatKey = $extends['seat_key'] ?? ''; // "room_001_A_3" 格式
|
||||
if (empty($seatKey)) continue;
|
||||
|
||||
$seatSpecMap[$seatKey] = [
|
||||
'spec_base_id' => intval($spec['id']),
|
||||
'price' => floatval($spec['price']),
|
||||
'inventory' => intval($spec['inventory']),
|
||||
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、前端数据使用对照表
|
||||
|
||||
### 4.1 渲染座位图(使用 vr_seat_template)
|
||||
|
||||
```
|
||||
数据来源:vr_seat_template.rooms[].map
|
||||
↓
|
||||
渲染流程:
|
||||
rooms[i].map.forEach((rowStr, rowIndex) => {
|
||||
chars = rowStr.split('') // 逐字符
|
||||
chars.forEach((char, colIndex) => {
|
||||
if (char === '_') → 跳过(空位)
|
||||
seatInfo = rooms[i].seats[char] // 通过 char 查座位属性
|
||||
seatKey = rooms[i].id + '_' + rowLabel + '_' + (colIndex+1)
|
||||
// rowLabel = String.fromCharCode(65 + rowIndex) // A/B/C...
|
||||
});
|
||||
});
|
||||
|
||||
前端关键变量:
|
||||
- rooms[i].id → roomId(用于 seatKey 构造)
|
||||
- rooms[i].map → 座位行渲染数据
|
||||
- rooms[i].seats[char] → 座位属性(name/price/color)
|
||||
- rooms[i].sections → 图例 + 分区切换
|
||||
- vrSeatTemplate.selectedRooms → 当前选中的房间列表
|
||||
- vrSeatTemplate.selectedSections → 当前选中的分区
|
||||
```
|
||||
|
||||
### 4.2 构建 spec 数组(使用 seatSpecMap)
|
||||
|
||||
```
|
||||
数据来源:seatSpecMap[seatKey]
|
||||
↓
|
||||
选中座位后:
|
||||
seatKey = clickedEl.dataset.rowLabel + '_' + clickedEl.dataset.colNum
|
||||
= "room_001_A_3"
|
||||
seatInfo = seatSpecMap[seatKey]
|
||||
|
||||
submit() 时使用:
|
||||
goods_data[i].spec = seatInfo.spec // 4维完整 spec 数组!
|
||||
goods_data[i].stock = 1
|
||||
|
||||
ShopXO BuyService 匹配:
|
||||
→ GoodsSpecValue WHERE type="$vr-场馆" AND value="VR 体验馆"
|
||||
AND type="$vr-分区" AND value="VR 体验馆-1号演播厅-VIP区"
|
||||
AND type="$vr-座位号" AND value="VR 体验馆-1号演播厅-VIP区-A-1排3座"
|
||||
AND type="$vr-场次" AND value="15:00-16:59"
|
||||
→ 返回 spec_base_id → 拿到 inventory=1, price=380
|
||||
```
|
||||
|
||||
### 4.3 spec 选择器联动过滤(使用 seatSpecMap)
|
||||
|
||||
```
|
||||
数据来源:seatSpecMap(所有座位的完整信息)
|
||||
↓
|
||||
filterSeatMap(currentSession, currentVenueId, currentSectionChar):
|
||||
|
||||
seatSpecMap 的每一个 entry:
|
||||
seatInfo.spec 是一个4元素数组
|
||||
|
||||
判断逻辑(某座位是否在当前选择分支内):
|
||||
hasSession = spec.some(s => s.type==='$vr-场次' && s.value===currentSessionValue)
|
||||
hasVenue = spec.some(s => s.type==='$vr-场馆' && s.value.includes(currentVenueName))
|
||||
hasSection = !currentSectionChar || spec.some(s => s.type==='$vr-分区' && s.value.includes(currentSectionChar))
|
||||
isAvailable = seatInfo.inventory > 0
|
||||
|
||||
结果:
|
||||
hasSession && hasVenue && hasSection && isAvailable → 可选(正常显示)
|
||||
hasSession && hasVenue && hasSection && !isAvailable → 已售(灰色+sold class)
|
||||
否则 → 不在分支内(灰色+disabled class)
|
||||
```
|
||||
|
||||
### 4.4 加载已售座位(使用 seatSpecMap.inventory)
|
||||
|
||||
```
|
||||
数据来源:seatSpecMap[seatKey].inventory
|
||||
↓
|
||||
页面初始化时,遍历 seatSpecMap:
|
||||
Object.entries(seatSpecMap).forEach(([seatKey, seatInfo]) => {
|
||||
if (seatInfo.inventory <= 0) {
|
||||
// 该座位已售
|
||||
document.querySelector(`[data-seat-key="${seatKey}"]`).classList.add('sold');
|
||||
}
|
||||
});
|
||||
|
||||
⚠️ 注意:inventory 字段来自 GoodsSpecBase,库存扣减由 ShopXO 原生处理。
|
||||
这是当前座位的实时库存,优先于任何前端缓存。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、前端完整数据流图
|
||||
|
||||
```
|
||||
后端 GetGoodsViewData()
|
||||
│
|
||||
├── vr_seat_template.venue ──────────────────→ 顶部 Banner / 场馆信息
|
||||
├── vr_seat_template.rooms[].map ─────────────────→ 座位图渲染
|
||||
├── vr_seat_template.rooms[].sections ────────────→ 图例 + 分区选择器
|
||||
├── vr_seat_template.selectedSections ────────────→ 默认选中的分区(用于高亮)
|
||||
├── goods_spec_data / vr_seat_template.sessions ──→ 场次选择器
|
||||
└── seatSpecMap (新增) ─────────────────────────────→ 核心!
|
||||
│
|
||||
├── seatSpecMap[seatKey].spec ────────→ submit() 构造 goods_data.spec
|
||||
├── seatSpecMap[seatKey].inventory ──→ 标记已售 / 灰色
|
||||
├── seatSpecMap[seatKey].price ──────→ 计算总价
|
||||
└── filterSeatMap() ─────────────────→ spec 选择器联动过滤
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
### 6.1 roomId 从哪里来?
|
||||
|
||||
`rooms[i].id`(来自 template_snapshot.rooms)就是 roomId。这是 UUID 或字符串 ID。
|
||||
|
||||
**前端构造 seatKey 时必须使用这个 ID**:
|
||||
```javascript
|
||||
// 正确:从 rooms[i].id 取
|
||||
var roomId = rooms[i].id; // "room_001"
|
||||
|
||||
// 错误:硬编码或自行生成
|
||||
var roomId = "room_001"; // ❌ 如果 rooms 结构变了就错了
|
||||
```
|
||||
|
||||
### 6.2 colNum 从哪里来?
|
||||
|
||||
colNum 是列号(从 1 开始),不是数组索引:
|
||||
```javascript
|
||||
// 正确
|
||||
var colNum = colIndex + 1; // 0-based 数组索引 → 1-based 列号
|
||||
|
||||
// seatKey 格式:{roomId}_{rowLabel}_{colNum}
|
||||
// 例如:room_001_A_3 = room_001 的 A排 第3列
|
||||
```
|
||||
|
||||
### 6.3 同一个 char 在不同房间代表不同分区
|
||||
|
||||
room_001 的 "A" 是 VIP区(红色),room_002 的 "D" 是互动区(橙色)。
|
||||
|
||||
**分区信息在 rooms[i].sections 里**,不要直接用 char 本身判断分区。
|
||||
|
||||
### 6.4 map 中下划线数量的处理
|
||||
|
||||
`"AAAAA_____BBBBB"` 中有 5 个下划线。座位图渲染时:
|
||||
```javascript
|
||||
chars.forEach(function(char, colIndex) {
|
||||
if (char === '_' || char === '-') {
|
||||
// 渲染一个空白格子(不绑定座位)
|
||||
return;
|
||||
}
|
||||
// 渲染座位,colNum = colIndex + 1
|
||||
});
|
||||
```
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
# VR 票务 spec 选择器 + 多座位选择 — 数据结构与实现说明
|
||||
|
||||
> 日期:2026-04-21
|
||||
> 背景:修正 COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md 中 spec 描述的根本性错误
|
||||
|
||||
---
|
||||
|
||||
## 一、核心结论(先说清楚)
|
||||
|
||||
原报告里所有关于 spec 的示例都是错的,原因:**spec 是 4 维度联合索引,而不是 1 个维度;前端目前根本拿不到完整的 4 维 spec 映射**。
|
||||
|
||||
我们实际上要做的产品形态是:
|
||||
> **一个风格化、带座位图的多维度 spec 规格选择器**
|
||||
>
|
||||
> - 界面同时具备原生 ShopXO spec 选择器的交互(场次/场馆/分区可选择,不在分支下的选项自动隐藏/变灰)
|
||||
> - 又有自己的多座位视图(在座位图上直接点选多个座位)
|
||||
> - 最终在 submit() 时,把选中座位的完整 4 维 spec 数组提交到 Buy 链路
|
||||
|
||||
---
|
||||
|
||||
## 二、ShopXO spec 的真实结构
|
||||
|
||||
### 2.1 四维 SPEC_DIMS
|
||||
|
||||
ShopXO 的每个 GoodsSpecBase 记录,通过 4 个维度的 spec 值联合确定:
|
||||
|
||||
```php
|
||||
const SPEC_DIMS = ['$vr-场馆', '$vr-分区', '$vr-座位号', '$vr-场次'];
|
||||
```
|
||||
|
||||
### 2.2 一个座位的完整 spec 数组(示例)
|
||||
|
||||
以商品118的某个座位为例(A排3座,VIP区,VR体验馆-1号厅,场次15:00-16:59):
|
||||
|
||||
```json
|
||||
{
|
||||
"spec_base_id": 1001,
|
||||
"price": 380.00,
|
||||
"inventory": 1,
|
||||
"spec": [
|
||||
{ "type": "$vr-场馆", "value": "VR体验馆-1号厅" },
|
||||
{ "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" },
|
||||
{ "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排3座" },
|
||||
{ "type": "$vr-场次", "value": "15:00-16:59" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **关键**:spec value 不是 `"A_3"` 或 `"roomId_A_3"` 这种短格式,
|
||||
> 而是**完整路径字符串** `"VR体验馆-1号厅-VIP区-A-1排3座"`。
|
||||
> 这个字符串由 BatchGenerate 第131行构建:
|
||||
> `$val_seat = "{$venueName}-{$roomName}-{$char}-{$rowLabel}{$col}"`
|
||||
|
||||
### 2.3 从前端座位元素到 spec_base_id 的正确路径
|
||||
|
||||
前端一个座位 DOM 元素,持有以下数据:
|
||||
- `roomId` = "room_001"(来自 template_snapshot.rooms[i].id)
|
||||
- `rowLabel` = "A"(座位行标签)
|
||||
- `colNum` = 3(座位列号)
|
||||
- `char` = "A"(座位类型 char,对应 sections[i].char)
|
||||
|
||||
要找到对应的 GoodsSpecBase,需要用以下映射关系:
|
||||
|
||||
```
|
||||
GoodsSpecBase.extends = {"seat_key": "room_001_A_3"}
|
||||
↓ GetGoodsViewData() 动态构建
|
||||
spec_base_id_map = {"room_001_A_3": 1001}
|
||||
↓
|
||||
前端 seatKey = room_001 + "_" + "A" + "_" + 3 = "room_001_A_3"
|
||||
↓
|
||||
spec_base_id_map["room_001_A_3"] = 1001 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、前端实际能拿到的数据(现状 vs 需要的)
|
||||
|
||||
### 3.1 当前 GetGoodsViewData() 返回的数据(不完整)
|
||||
|
||||
```php
|
||||
[
|
||||
'vr_seat_template' => [
|
||||
'seat_map' => [...], // template_snapshot.rooms(座位图原始数据)
|
||||
'spec_base_id_map' => [...], // ⚠️ 键名格式不对(roomId_row_col),且前端没有对应生成逻辑
|
||||
],
|
||||
'goods_spec_data' => [
|
||||
['spec_id' => 2001, 'spec_name' => '15:00-16:59', 'price' => 380],
|
||||
['spec_id' => 2002, 'spec_name' => '18:00-20:59', 'price' => 480],
|
||||
]
|
||||
]
|
||||
```
|
||||
|
||||
**缺失的信息**:
|
||||
1. ❌ `goods_spec_data` 只有场次维度,缺少场馆/分区/座位号三个维度
|
||||
2. ❌ `spec_base_id_map` 的 key 格式(`roomId_row_col`)前端无法构造
|
||||
3. ❌ 前端不知道哪个 spec_id 对应哪个座位
|
||||
|
||||
### 3.2 前端需要的完整数据结构(GetGoodsViewData 应返回)
|
||||
|
||||
```php
|
||||
[
|
||||
'vr_seat_template' => [
|
||||
'venue' => $config['template_snapshot']['venue'],
|
||||
'rooms' => $config['template_snapshot']['rooms'], // 座位图原始数据
|
||||
'sessions' => $config['sessions'], // 场次列表
|
||||
],
|
||||
|
||||
// 【修复】重构后的 seatSpecMap:room_row_col → 完整规格信息
|
||||
// 用途:前端选中座位后,直接查表组装 4 维 spec 数组
|
||||
'seatSpecMap' => [
|
||||
'room_001_A_3' => [
|
||||
'spec_base_id' => 1001,
|
||||
'price' => 380.00,
|
||||
'inventory' => 1,
|
||||
'spec' => [
|
||||
['type' => '$vr-场馆', 'value' => 'VR体验馆-1号厅'],
|
||||
['type' => '$vr-分区', 'value' => 'VR体验馆-1号厅-VIP区'],
|
||||
['type' => '$vr-座位号', 'value' => 'VR体验馆-1号厅-VIP区-A-1排3座'],
|
||||
['type' => '$vr-场次', 'value' => '15:00-16:59'],
|
||||
],
|
||||
'rowLabel' => 'A',
|
||||
'colNum' => 3,
|
||||
'roomId' => 'room_001',
|
||||
'section' => ['char' => 'A', 'name' => 'VIP区', 'color' => '#f06292'],
|
||||
],
|
||||
'room_001_B_5' => [
|
||||
'spec_base_id' => 1002,
|
||||
'price' => 180.00,
|
||||
// ... 同上
|
||||
],
|
||||
// 每个可购座位一行
|
||||
],
|
||||
|
||||
// 当前商品的全部场次(用户需要先选场次)
|
||||
'sessions' => [
|
||||
['spec_id' => 2001, 'spec_name' => '15:00-16:59', 'price' => 380, 'start' => '15:00', 'end' => '16:59'],
|
||||
['spec_id' => 2002, 'spec_name' => '18:00-20:59', 'price' => 480, 'start' => '18:00', 'end' => '20:59'],
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
### 3.3 seatSpecMap 的生成逻辑(在 GetGoodsViewData 中实现)
|
||||
|
||||
```php
|
||||
// GetGoodsViewData() 中新增:
|
||||
// 1. 查询当前商品所有 GoodsSpecBase(含 extends.seat_key)
|
||||
$specs = Db::name('GoodsSpecBase')
|
||||
->where('goods_id', $goodsId)
|
||||
->where('inventory', '>', 0)
|
||||
->select();
|
||||
|
||||
// 2. 查询每个 spec_base_id 对应的 4 维 GoodsSpecValue
|
||||
$specValues = Db::name('GoodsSpecValue')
|
||||
->whereIn('goods_spec_base_id', array_column($specs->toArray(), 'id'))
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 3. 按 spec_base_id 分组,构建 4 维 spec 数组
|
||||
$specByBaseId = [];
|
||||
foreach ($specValues as $sv) {
|
||||
$specByBaseId[$sv['goods_spec_base_id']][] = [
|
||||
'type' => $sv['type'],
|
||||
'value' => $sv['value'],
|
||||
];
|
||||
}
|
||||
|
||||
// 4. 构建 seatSpecMap:seat_key → 完整规格
|
||||
$seatSpecMap = [];
|
||||
foreach ($specs as $spec) {
|
||||
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||
$seatKey = $extends['seat_key'] ?? '';
|
||||
if (empty($seatKey)) continue;
|
||||
|
||||
$seatSpecMap[$seatKey] = [
|
||||
'spec_base_id' => intval($spec['id']),
|
||||
'price' => floatval($spec['price']),
|
||||
'inventory' => intval($spec['inventory']),
|
||||
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、前端 spec 选择器的完整交互
|
||||
|
||||
### 4.1 UI 结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 场次选择(Tab 或下拉) │
|
||||
│ [15:00-16:59] [18:00-20:59] │
|
||||
│ │
|
||||
│ 选完场次后 → 座位图自动切换到该场次的可用座位 │
|
||||
│ │
|
||||
│ 场馆选择(单选) │
|
||||
│ [VR体验馆-1号厅 ✓] [VR体验馆-2号厅] │
|
||||
│ │
|
||||
│ 分区选择(单选 / 多选)灰色表示不在分支内 │
|
||||
│ [VIP区 ✓] [看台区] [普通区-已售罄] │
|
||||
│ │
|
||||
│ ─────────── 座位图(多选)──────────────── │
|
||||
│ [舞台 - 固定位置] │
|
||||
│ A排 [■■■■] ← 可选座位(VIP) │
|
||||
│ B排 [■■--■■] ← 部分已售 │
|
||||
│ C排 [已灰掉] ← 不在当前分区或已售 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 spec 选择器的联动逻辑
|
||||
|
||||
**维度优先级**:`场次 > 场馆 > 演播室 > 分区`
|
||||
|
||||
每次用户切换选项时,过滤规则:
|
||||
|
||||
```
|
||||
场次切换 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位
|
||||
场馆切换 → 重置分区/座位 → 用 seatSpecMap 过滤出该场馆所有座位
|
||||
分区切换 → 只高亮/过滤座位 → 用 seatSpecMap 过滤出该分区座位(灰色其他)
|
||||
座位点击 → 选中/取消 → 更新 selectedSeats[]
|
||||
```
|
||||
|
||||
**灰色/隐藏逻辑**(参考原生 ShopXO spec 选择器):
|
||||
```javascript
|
||||
// 某座位"可亮"的条件:该座位的 spec 数组 包含 当前已选场次 + 当前已选场馆 + (当前已选分区 或 未选分区)
|
||||
// 具体实现在 selectSession() / selectVenue() / selectSection() 中调用 filterSeatMap()
|
||||
function filterSeatMap(sessionSpecId, venueId, sectionChar) {
|
||||
document.querySelectorAll('.vr-seat:not(.space)').forEach(function(el) {
|
||||
var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||
var seatInfo = seatSpecMap[seatKey];
|
||||
if (!seatInfo) { el.classList.add('disabled'); return; }
|
||||
|
||||
var spec = seatInfo.spec;
|
||||
var hasSession = spec.some(s => s.type === '$vr-场次' && s.value === currentSessionSpec);
|
||||
var hasVenue = spec.some(s => s.type === '$vr-场馆' && s.value.includes(currentVenue));
|
||||
var hasSection = !sectionChar || spec.some(s => s.type === '$vr-分区' && s.value.includes(sectionChar));
|
||||
var isAvailable = seatInfo.inventory > 0;
|
||||
|
||||
if (hasSession && hasVenue && hasSection && isAvailable) {
|
||||
el.classList.remove('disabled', 'sold');
|
||||
} else {
|
||||
el.classList.add(isAvailable ? 'disabled' : 'sold');
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、submit() 时如何组装 spec 数组
|
||||
|
||||
用户选了 2 个座位(A排3座 + A排5座,都是VIP区,15:00场次,VR体验馆-1号厅):
|
||||
|
||||
```javascript
|
||||
// 选中座位后的 selectedSeats[] 数据结构
|
||||
[
|
||||
{ seatKey: 'room_001_A_3', price: 380, rowLabel: 'A', colNum: 3, section: {...} },
|
||||
{ seatKey: 'room_001_A_5', price: 380, rowLabel: 'A', colNum: 5, section: {...} },
|
||||
]
|
||||
|
||||
// submit() 构造 goods_data
|
||||
var goodsDataList = selectedSeats.map(function(seat) {
|
||||
var seatInfo = seatSpecMap[seat.seatKey]; // 从注入的 seatSpecMap 查
|
||||
return {
|
||||
goods_id: self.goodsId,
|
||||
spec: seatInfo.spec, // 4维完整 spec 数组!不是1维!
|
||||
stock: 1,
|
||||
order_base: {
|
||||
extension_data: {
|
||||
attendee: { real_name: '...', phone: '...', id_card: '...' }
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**生成的 goods_data(简化展示)**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"goods_id": 118,
|
||||
"spec": [
|
||||
{ "type": "$vr-场馆", "value": "VR体验馆-1号厅" },
|
||||
{ "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" },
|
||||
{ "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排3座" },
|
||||
{ "type": "$vr-场次", "value": "15:00-16:59" }
|
||||
],
|
||||
"stock": 1,
|
||||
"order_base": {
|
||||
"extension_data": {
|
||||
"attendee": { "real_name": "张三", "phone": "13800138000", "id_card": "" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"goods_id": 118,
|
||||
"spec": [
|
||||
{ "type": "$vr-场馆", "value": "VR体验馆-1号厅" },
|
||||
{ "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" },
|
||||
{ "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排5座" },
|
||||
{ "type": "$vr-场次", "value": "15:00-16:59" }
|
||||
],
|
||||
"stock": 1,
|
||||
"order_base": { "extension_data": { "attendee": { "real_name": "李四", ... } } }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
ShopXO 的 `GoodsSpecificationsHandle` 通过 4 个 type-value 组合在 GoodsSpecValue 表中精确匹配到对应的 GoodsSpecBase,拿到 `inventory=1` 和 `price=380`。
|
||||
|
||||
---
|
||||
|
||||
## 六、需要修改的文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `SeatSkuService::GetGoodsViewData()` | 新增 `seatSpecMap` 生成逻辑,返回完整 4 维 spec 映射 |
|
||||
| `Goods.php`(票务判断块) | `MyViewAssign` 中加入 `seatSpecMap` 和 `sessions` |
|
||||
| `ticket_detail.html` JS | 新增 `selectSession()` / `selectVenue()` / `selectSection()` 联动逻辑;`filterSeatMap()` 过滤;`submit()` 使用 `seatSpecMap` 组装 spec |
|
||||
| `ticket_detail.html` HTML | 新增场次/场馆/分区选择器的 DOM 结构 |
|
||||
| `ticket_detail.css` | spec 选择器样式(选中态/灰色态/隐藏态) |
|
||||
|
||||
---
|
||||
|
||||
## 七、修复优先级
|
||||
|
||||
| 优先级 | 任务 | 依赖 |
|
||||
|--------|------|------|
|
||||
| P0 | 重构 `GetGoodsViewData()` 返回 `seatSpecMap` | 后端 |
|
||||
| P0 | 前端用 `seatSpecMap` 替代错误的 `specBaseIdMap` | 前端 |
|
||||
| P0 | `submit()` 使用 `seatSpecMap[seatKey].spec` | 前端 |
|
||||
| P1 | 实现场次/场馆/分区选择器 UI + 联动逻辑 | 前端 |
|
||||
| P1 | `filterSeatMap()` 实现灰色/隐藏过滤 | 前端 |
|
||||
| P2 | `loadSoldSeats()` → 使用 `seatSpecMap` + inventory 字段 | 前端 |
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
# ShopXO 酷炫前端模板实现方案调研报告
|
||||
|
||||
> 调研日期:2026-04-20
|
||||
> 状态:**Round 3 收敛版本**
|
||||
> 参与:FrontendDev (Q1/Q4)、BackendArchitect (Q2)、ProductManager (Q3)、FirstPrinciples (Q4 集成)
|
||||
|
||||
---
|
||||
|
||||
## Q2 结论:单订单多 SKU 支持
|
||||
|
||||
### 核心答案
|
||||
|
||||
**ShopXO 订单模型技术上支持同一商品多规格(多 SKU)出现在同一订单中**,但现有 vr_ticket 模板的 `submit()` 只传单行,完整多座位下单需要做两件事:① 让前端传多行 `goods_data`,② 阻止 `OrderSplitService` 按 warehouse 拆单。
|
||||
|
||||
### 证据来源
|
||||
|
||||
| 文件 | 关键代码 | 说明 |
|
||||
|------|---------|------|
|
||||
| `BuyService.php:86` | `foreach($params['goods_data'] as $v)` | 循环处理每个商品项,每项独立 spec_base_id |
|
||||
| `BuyService.php:423-435` | `extension_data` JSON 序列化 | 每行 item 支持挂载座位/观演人扩展数据 |
|
||||
| `BuyService.php:101` | `md5(goods_id + spec implode)` | 内部用 goods_id+spec 组合生成唯一行 ID |
|
||||
| `OrderSplitService.php:52-53` | `GoodsWarehouseAggregate()` | **拆单触发点**:按仓库分组,多 SKU 同一仓库则合并 |
|
||||
| `OrderSplitService.php:289-310` | 按 spec MD5 找 spec_base_id | spec_base_id 已可不同(座位级 SKU) |
|
||||
| `ticket_detail.html:413-436` | `goodsParamsList.map()` | Plan A 代码已写好,但 URL 只传第一行(bug) |
|
||||
|
||||
### 最小可行方案(Multi-Seat Now)
|
||||
|
||||
**改动点仅 3 处,全在 ticket_detail.html,不碰 ShopXO 核心:**
|
||||
|
||||
```
|
||||
ticket_detail.html submit() 函数
|
||||
|
||||
BEFORE: location.href = checkoutUrl + '&goods_params=' + encodeURIComponent(goodsParams)
|
||||
AFTER: goodsParamsList 整体 base64 编码,拆分成多条 goods_data 逐条 POST 到 CartSave,
|
||||
然后跳转到合并支付流程(ShopXO 购物车天然支持多商品同单)
|
||||
```
|
||||
|
||||
**为什么走购物车路线更稳:**
|
||||
- `BuyCart` → `BuyTypeGoodsList` → 直接调用 `BuyGoods`,完美支持多 `goods_data` 行
|
||||
- 不需要hook `OrderSplitService`,购物车结算路径不触发按仓库拆单(只按商品拆)
|
||||
- 核销逻辑不受影响:支付成功后 `plugins_service_order_pay_success_handle_end` 钩子正常触发
|
||||
|
||||
### 理想方案(Multi-Seat Proper)
|
||||
|
||||
在插件中挂载 `plugins_service_buy_group_goods_handle` 钩子,拦截 `OrderSplitService`,将同一 goods_id + 不同 spec_base_id 的多行合并进同一个 order_base:
|
||||
|
||||
```
|
||||
plugins_service_buy_group_goods_handle:
|
||||
- 按 goods_id 聚合,而非按 warehouse_id
|
||||
- 每个 goods_id 只生成一条 order_base,goods_items 内嵌多个 spec_base_id 不同的行
|
||||
- extension_data 按座位索引扁平化存储
|
||||
```
|
||||
|
||||
**ShopXO 官方立场**:这是非标准用法,建议走购物车路线。
|
||||
|
||||
### 最大风险点
|
||||
|
||||
1. **OrderSplitService 按仓库拆单** — 如果场次商品和周边商品挂在不同仓库,多座位票务订单会被拆成多个子单。用户会收到多笔支付通知,体验割裂。→ **最小方案走购物车绕过此风险**。
|
||||
2. **座位级 SKU 未在 ShopXO 后台创建** — `specBaseIdMap` 依赖数据库中已存在的 `sxo_goods_spec_base` 记录。如果模板生成的 seatKey(如 "A_1")没有对应的 spec_base_id,`submit()` 会降级到 Zone 级别 SKU(同一 zone 全部座位共享一个 spec),失去座位粒度。→ **需要后台管理员先为每个座位创建规格**。
|
||||
|
||||
### 优先级与依赖
|
||||
|
||||
- **Q2 是 Q4 的前提** — Q4 的"多座位选座流程"依赖 Q2 的多 SKU 订单能力。
|
||||
- Q2 本身不依赖 Q1,可以独立推进。
|
||||
- Q3 和 Q4 无依赖,但 Q3 生成的代码需适配 Q4 选型(H5 vs uni-app)。
|
||||
|
||||
---
|
||||
|
||||
## Q1 结论:ShopXO 自定义模板最佳实践
|
||||
|
||||
### 核心答案
|
||||
|
||||
**票务详情页不走 DIY 设计器,直接修改 `ticket_detail.html` 的 PHP+原生 JS**;uni-app 端 fork `shopxo-uniapp` 改写 `goods-detail.vue`,无需经过 ShopXO 模板中间层。
|
||||
|
||||
### 证据来源
|
||||
|
||||
| 文件/文档 | 结论 |
|
||||
|----------|------|
|
||||
| `docs/02_FRONTEND_CUSTOMIZATION.md` | DIY 设计器只支持静态 HTML 区块嵌入,无法参数化;uni-app 完全独立于 ShopXO 模板 |
|
||||
| `docs/12_UNIAPP_FRONTEND_RESEARCH.md` | shopxo-uniapp 是独立 Vue 项目,通过 API 对接 ShopXO;CSS 在 H5/小程序完全一致(WebView 同源) |
|
||||
| `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` | ShopXO view/goods/ 模板使用原生 PHP + 原生 JS,session/buy 等控制器直接 render |
|
||||
| `ticket_detail.html` | 当前已实现:场次选择 + 座位图渲染 + 观演人表单 + 购买栏 |
|
||||
|
||||
### 最小可行方案
|
||||
|
||||
**H5 端**:在现有 `ticket_detail.html` 基础上增强,引入:
|
||||
- 座位类型图例(已完成)
|
||||
- 已售座位 AJAX 实时标记(待实现 `loadSoldSeats()`)
|
||||
- 座位缩放/拖拽交互(原生 JS,<200 行)
|
||||
- 动态场次切换时重置已选座位(已写但未调用)
|
||||
|
||||
**技术栈**:`原生 HTML + 内联 CSS + 内联 JS`,无框架依赖,ShopXO 模板系统直接渲染,无需构建。
|
||||
|
||||
### 理想方案
|
||||
|
||||
**uni-app 端**:
|
||||
1. Fork `shopxo-uniapp` → `vr-shopxo-uniapp`
|
||||
2. 重写 `pages/goods-detail/goods-detail.vue`,接入 vr_ticket API
|
||||
3. 新建 `pages/ticket-seat/ticket-seat.vue`(选座主流程)
|
||||
4. 新建 `pages/ticket-wallet/ticket-wallet.vue`(票夹)
|
||||
5. H5 本地预览 = 小程序编译效果,CSS 完全一致
|
||||
|
||||
**关键约束(uni-app 开发规范)**:
|
||||
- 用 `rpx` 不用 `vw/vh`
|
||||
- 用 `<view>` 不用 `<div>`
|
||||
- 避免 `calc()` 混用单位
|
||||
- `position: fixed` 吸顶在 H5 正常,小程序需 shopxo-uniapp 已有方案
|
||||
|
||||
### 最大风险点
|
||||
|
||||
1. **shopxo-uniapp fork 同步成本** — 官方 shopxo-uniapp 更新后需要手动同步,未来维护成本高。→ 建议在 fork 分支上做票务专属页面,官方页面保持独立升级路径。
|
||||
2. **ShopXO 版本 vs shopxo-diy 版本匹配** — shopxo-diy v1.4.2 ↔ ShopXO v6.8.0,如果使用 DIY 设计器管理非票务页面,版本必须严格匹配。
|
||||
|
||||
### 优先级与依赖
|
||||
|
||||
- Q1 是 Q4 的技术基础,Q1 的结论直接支撑 Q4 的技术选型决策。
|
||||
- Q3 依赖 Q1 的约束条件输出。
|
||||
|
||||
---
|
||||
|
||||
## Q3 结论:第三方无代码构建服务提示词策略
|
||||
|
||||
### 核心答案
|
||||
|
||||
**用"模板 + 示例 + 约束"三层结构撰写 Prompt**,ShopXO 模板的特殊性(模块化 PHP 标签、ShopXO 资源路径 API)在 Prompt 中明确声明,生成代码后只需做两件事后处理:① 替换静态资源路径为 `ModuleInclude()` 调用,② 注入座位图数据结构(从 PHP 模板变量传入)。
|
||||
|
||||
### Prompt 三层结构
|
||||
|
||||
```
|
||||
【第一层:角色定义】
|
||||
你是一个 ShopXO v6.8.0 模板开发者,擅长编写票务商品详情页。
|
||||
|
||||
【第二层:约束清单】
|
||||
- HTML 结构:使用 <?php echo ModuleInclude('public/header'); ?> 包裹页面头
|
||||
- 样式:全部内联 <style>,CSS 类名前缀 vr- 避免冲突
|
||||
- JS 数据注入:const app = <?php echo json_encode($php_var); ?>
|
||||
- 资源路径:静态资源用 <?php echo ModuleInclude('images/foo.png'); ?>
|
||||
- 不使用:Vue/React CDN、外部 CDN(ShopXO 必须离线可用)
|
||||
|
||||
【第三层:具体需求】
|
||||
[座位图 UI 规格:rpx 规范、颜色、尺寸 + 交互事件定义]
|
||||
[ShopXO 数据契约:goods_spec_data、vr_seat_template、extension_data]
|
||||
```
|
||||
|
||||
### 生成代码后处理步骤
|
||||
|
||||
1. **路径替换**:全局搜索 `src="/static/` → `<?php echo ModuleInclude('static/') ?>`
|
||||
2. **变量注入点**:在 `<script>` 顶部注入 `var seatMap = <?php echo json_encode($vr_seat_template['seat_map']); ?>`
|
||||
3. **事件绑定**:`onclick` 属性需改为 `onclick="vrTicketApp.toggleSeat(this)"` 格式(原生 JS)
|
||||
4. **样式隔离**:检查是否覆盖 `.goods-detail-*` 等 ShopXO 全局类名,如有则加 `.vr-ticket-page` 限定符
|
||||
|
||||
### 最大风险点
|
||||
|
||||
1. **无代码服务生成的 UI 过于复杂** — 座位图等高交互组件无法用无代码工具精确生成,强行生成会导致大量调试工作。→ **无代码服务适合静态展示区块(票务商品介绍、艺人信息图),座位图选座交互必须手写**。
|
||||
2. **ShopXO 离线可用约束** — ShopXO 运行在企业内部/私有化部署场景,所有资源必须本地化,无代码服务默认 CDN 引用必须全部替换。
|
||||
|
||||
---
|
||||
|
||||
## Q4 结论:uni-app 兼容性技术栈选型
|
||||
|
||||
### 核心答案
|
||||
|
||||
**推荐方案:一套 shopxo-uniapp fork + 条件编译**,票务页面走独立路由(H5/小程序双端),商城标准页面复用 shopxo-uniapp 原生实现。
|
||||
|
||||
### 技术选型对比
|
||||
|
||||
| 维度 | 原生 HTML 模板 | uni-app fork shopxo-uniapp | Flutter / React Native |
|
||||
|------|---------------|---------------------------|----------------------|
|
||||
| H5 本地预览 | ✅ 直接浏览器打开 | ✅ HBuilderX H5 运行 | ❌ 需真机调试 |
|
||||
| 微信小程序 | ❌ 不支持 | ✅ 一键编译 | ✅ 需分别开发 |
|
||||
| ShopXO API 对接 | 需手动 HTTP | shopxo-uniapp 已封装 | 需手动 HTTP |
|
||||
| 学习成本 | 低 | 中(需熟悉 Vue) | 高 |
|
||||
| 座位图等复杂交互 | 原生 JS 手写 | Vue 组件手写 | 手写 |
|
||||
| 开发速度 | 快(单文件) | 中 | 慢 |
|
||||
|
||||
**最终推荐**:`fork shopxo-uniapp`,用 Vue 3 + SCSS,票务页面自研,其他页面复用。
|
||||
|
||||
### 票务页面与商城标准页面共存方案
|
||||
|
||||
```
|
||||
vr-shopxo-uniapp/
|
||||
├── pages/index/index.vue ← 改写:底部 Tab 新增「票务」Tab
|
||||
├── pages/goods-detail/ ← 改写:票务商品跳 ticket-seat 页面
|
||||
├── pages/ticket-seat/ ← 新建:选座 + 购票主流程(Vue 组件)
|
||||
├── pages/ticket-wallet/ ← 新建:票夹(我的票)
|
||||
├── pages/ticket-verify/ ← 新建:B 端核销
|
||||
├── App.vue ← request_url 指向目标商城
|
||||
└── pages.json ← 路由配置
|
||||
```
|
||||
|
||||
**H5/小程序一致性**:uni-app H5 和小程序都基于 WebView,CSS 渲染一致。关键:用 `rpx`,用 `<view>`,避免浏览器私有前缀。
|
||||
|
||||
### 最大风险点
|
||||
|
||||
1. **shopxo-uniapp 官方更新同步** — 100+ forks,官方更新需手动 cherry-pick 到 vr fork。建议将票务专属页面与商城原生页面放在不同目录,改动隔离,升级时只同步商城页面。
|
||||
2. **ShopXO 版本与 shopxo-uniapp 版本匹配** — shopxo-uniapp 的 API 契约随 ShopXO 后端版本变化,vr_ticket 插件如使用 shopxo-uniapp,请确认 ShopXO 版本(当前 v6.8.0),使用对应的 shopxo-uniapp 版本。
|
||||
|
||||
### 优先级与依赖
|
||||
|
||||
- **Q4 依赖 Q2(多座位选座)和 Q1(H5 模板基础)**
|
||||
- Q4 本身是最终落地执行层,前三个 Q 的结论在 Q4 中整合实现
|
||||
|
||||
---
|
||||
|
||||
## 优先级矩阵
|
||||
|
||||
| 优先级 | 任务 | 负责 Agent | 前置条件 |
|
||||
|--------|------|-----------|---------|
|
||||
| P0 | Q2 多 SKU — 走购物车路线打通多座位下单 | BackendArchitect | 无 |
|
||||
| P1 | Q4 uni-app fork — 建立项目骨架 | FrontendDev | Q1 结论 |
|
||||
| P2 | Q4 ticket-seat.vue — 选座核心组件 | FrontendDev | P0 完成 |
|
||||
| P3 | Q1 ticket_detail.html 增强 — 已售座位实时标记 | FrontendDev | 无 |
|
||||
| P4 | Q3 提示词策略落地 — 无代码服务辅助静态区块 | ProductManager | Q1 结论 |
|
||||
| P5 | Q2 理想方案 — 插件 hook 拦截 OrderSplitService | BackendArchitect | P0 验证 |
|
||||
|
||||
---
|
||||
|
||||
## 最小可行方案 vs 理想方案对比
|
||||
|
||||
| 维度 | 最小可行方案 | 理想方案 |
|
||||
|------|------------|---------|
|
||||
| 多座位下单 | 购物车路线(不碰 OrderSplitService) | 插件 hook 拦截,实现原生多 SKU 单订单 |
|
||||
| 前端 H5 | 增强 ticket_detail.html(< 3 处改动) | 迁移到 uni-app H5 |
|
||||
| 前端小程序 | shopxo-uniapp fork,票务页面 Vue 自研 | 完整迁移,小程序体验与 H5 一致 |
|
||||
| 座位图 | 原生 JS,< 200 行 | Vue 组件,含缩放/拖拽/动画 |
|
||||
| 观演人表单 | HTML + JS,支持动态增减 | Vue 组件化,数据校验 |
|
||||
| 核销 B 端 | 复用现有后台核销页面 | 新建小程序核销页面(扫码 + API) |
|
||||
| 交付周期 | 1 天(可上线 demo) | 2-3 周(完整票务流程) |
|
||||
|
||||
---
|
||||
|
||||
## 最大技术风险点汇总
|
||||
|
||||
| 风险 | 严重程度 | 缓解措施 |
|
||||
|------|---------|---------|
|
||||
| OrderSplitService 拆单导致多座位订单被拆 | 高 | 最小方案走购物车绕过;理想方案用插件 hook 拦截 |
|
||||
| 座位级 SKU 未在后台创建 | 中 | 后台管理界面增加「批量生成座位规格」功能 |
|
||||
| shopxo-uniapp fork 同步成本 | 中 | 票务页面与商城页面目录隔离,改动隔离升级 |
|
||||
| 无代码服务无法生成高交互组件 | 低(已有认知) | 座位图等核心交互手写,静态区块用无代码辅助 |
|
||||
| ShopXO 版本不匹配 shopxo-diy | 低(不走 DIY) | 不使用 DIY 设计器 |
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `shopxo/app/service/BuyService.php` | 订单创建入口,多 SKU 关键代码 |
|
||||
| `shopxo/app/service/OrderSplitService.php` | 拆单逻辑,多座位订单被拆的风险点 |
|
||||
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 当前票务详情页模板,Q2 多 SKU Plan A 代码已在此 |
|
||||
| `docs/12_UNIAPP_FRONTEND_RESEARCH.md` | uni-app 调研存档,Q1/Q4 依赖此文档 |
|
||||
| `docs/02_FRONTEND_CUSTOMIZATION.md` | ShopXO DIY 设计器局限性证明 |
|
||||
| `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` | 模板渲染机制调查 |
|
||||
| `docs/09_SHOPXO_HOOKS_REFERENCE.md` | 插件钩子清单,Q2 理想方案所需 hook 在此 |
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
1. **多座位下单可行**:走购物车路线,1 天内可上线多座位下单 Demo。
|
||||
2. **uni-app 是最终目标**:fork shopxo-uniapp 票务页面自研,商城页面复用,H5 预览 = 小程序编译效果。
|
||||
3. **无代码服务辅助有限**:适合静态展示区块,座位图等核心交互必须手写。
|
||||
4. **Immediate Action**:BackendArchitect 提交 Q2 Plan A(购物车路线),FrontendDev 启动 shopxo-uniapp fork 项目骨架。
|
||||
133
plan.md
133
plan.md
|
|
@ -1,80 +1,109 @@
|
|||
# Plan — ShopXO 酷炫前端模板实现方案调研
|
||||
# Plan — 调研「场馆删除后编辑商品出现规格重复错误」问题
|
||||
|
||||
> 版本:v1.0 | 日期:2026-04-20 | Agent:council/ProductManager + council/FrontendDev + council/BackendArchitect + council/FirstPrinciples
|
||||
> 版本:v1.3 | 日期:2026-04-20 | Agent:council/FrontendDev + council/SecurityEngineer + council/BackendArchitect
|
||||
|
||||
---
|
||||
|
||||
## 任务概述
|
||||
## BackendArchitect(Task B1-B6)
|
||||
|
||||
vr-shopxo-plugin 项目推进 Phase 3 前端模板调研,聚焦 4 个方向:
|
||||
- Q1:ShopXO 自定义模板最佳实践
|
||||
- Q2:单订单多 SKU 支持(多座位选择前提)
|
||||
- Q3:第三方无代码构建服务提示词策略
|
||||
- Q4:uni-app 兼容性技术栈选型
|
||||
当票务商品关联的场馆模板被硬删除后,编辑商品时出现「规格不允许重复」错误。
|
||||
|
||||
**输出目标**:`docs/council-research-output.md`
|
||||
**根因调查分工**:
|
||||
- FrontendDev:前端规格项构建与 fallback 行为
|
||||
- BackendArchitect:后端规格去重逻辑、`spec_base_id_map` 解析
|
||||
- SecurityEngineer:安全风险评估(P1 vs P2)
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
## FrontendDev 任务清单
|
||||
|
||||
### 全体 Round 1(规划并行,限时 2-3 分钟)
|
||||
|
||||
- [ ] [Claimed: council/ProductManager] **Task P1**: ProductManager 创建本 plan.md 并 merge main
|
||||
- [ ] [ ] **Task F1**: FrontendDev — 分析 `ticket_detail.html` 现有结构,制定 UI 改进方案
|
||||
- [ ] **Task B1**: BackendArchitect — 分析 ShopXO 订单模型是否支持单订单多 SKU
|
||||
- [ ] **Task S1**: FirstPrinciples — 拍板 Q2 结论,识别最大技术风险
|
||||
|
||||
### 全体 Round 2(执行调研)
|
||||
|
||||
- [ ] [ ] **Task P2**: ProductManager — 综合 Q1/Q3/Q4 结论,输出 `council-research-output.md`
|
||||
- [ ] **Task F2**: FrontendDev — 输出 H5 模板技术栈选型报告 → `docs/frontend-template-research.md`
|
||||
- [ ] **Task B2**: BackendArchitect — 输出 ShopXO 多 SKU 调研报告 → `docs/backend-multi-sku-research.md`
|
||||
- [ ] **Task S2**: FirstPrinciples — 评审所有报告,给出最终拍板结论
|
||||
|
||||
### 全体 Round 3(收敛)
|
||||
|
||||
- [ ] [ ] **Task P3**: ProductManager — 整合所有输出到 `council-research-output.md`,merge main
|
||||
- [ ] [ ] 所有 Agent 投票 `[CONSENSUS: YES/NO]`
|
||||
- [x] [Done: council/FrontendDev] **Task 1**: 读取 `ticket_detail.html`,分析前端构建规格项的过程
|
||||
- [x] [Done: council/FrontendDev] **Task 2**: 当模板不存在时,前端如何处理 `template_snapshot` 和 `spec_base_id_map`?
|
||||
- [x] [Done: council/FrontendDev] **Task 3**: `loadSoldSeats()` 函数实际实现了吗?soldSeats 数据如何填充?
|
||||
- [x] [Done: council/FrontendDev] **Task 4**: 编辑模式下(已有 vr_goods_config),前端是否正确处理已删除场馆的旧规格?
|
||||
- [x] [Done: council/FrontendDev] **Task 5**: 给出前端根因分析(含具体文件路径和行号)
|
||||
- [x] [Done: council/FrontendDev] **Task 6**: 给出修复方案
|
||||
- [x] [Done: council/FrontendDev] **Task 7**: 将调研报告写入 `reviews/council-ghost-spec-FrontendDev.md`
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
## SecurityEngineer 任务清单
|
||||
|
||||
```
|
||||
Q2结论 ──→ Q4是否能做多座位选择
|
||||
Q1结论 ──→ Q3/Q4技术栈基础
|
||||
Q3/Q4 ──→ 最小可行方案 vs 理想方案
|
||||
```
|
||||
- [x] [Done: council/SecurityEngineer] **Task S1**: 读取 AdminGoodsSaveHandle.php — 安全审计:保存时是否拒绝脏数据
|
||||
- [x] [Done: council/SecurityEngineer] **Task S2**: 读取 SeatSkuService.php — 幽灵 spec 注入路径分析
|
||||
- [x] [Done: council/SecurityEngineer] **Task S3**: 读取 AdminGoodsSave.php — ShopXO 入口安全检查
|
||||
- [x] [Done: council/SecurityEngineer] **Task S4**: 输出安全审计报告 → `reviews/SecurityEngineer-GHOST_SPEC_SECURITY.md`
|
||||
- [x] [Done: council/SecurityEngineer] **Task S5**: 更新 `reviews/council-ghost-spec-summary.md`
|
||||
|
||||
**关键风险**:Q2(多SKU)结论将直接影响 Q4 多座位选择能否落地。
|
||||
### 优先级定义
|
||||
|
||||
| 级别 | 含义 |
|
||||
|------|------|
|
||||
| **P1** | 安全漏洞:脏数据注入、XSS、权限绕过、数据覆盖 |
|
||||
| **P2** | 功能缺陷:用户体验问题、错误提示不友好 |
|
||||
| **P3** | 改进建议:代码健壮性优化 |
|
||||
|
||||
---
|
||||
|
||||
## 阶段划分
|
||||
## BackendArchitect 任务清单
|
||||
|
||||
| 阶段 | 状态 | 说明 |
|
||||
- [x] [Done: council/BackendArchitect] **Task B1**: AdminGoodsSaveHandle.php 全链路追踪 — vr_goods_config 读取/解析/snapshot 重建
|
||||
- [x] [Done: council/BackendArchitect] **Task B2**: spec_base_id_map 如何被转换成规格项(已验证:存储在模板表,与幽灵 spec 无关)
|
||||
- [x] [Done: council/BackendArchitect] **Task B3**: SeatSkuService GetGoodsViewData 模板不存在时的 fallback(单模板处理,多模板有缺陷)
|
||||
- [x] [Done: council/BackendArchitect] **Task B4**: 幽灵 spec 产生环节 + 清理时机(保存时未清理,写回 DB)
|
||||
- [x] [Done: council/BackendArchitect] **Task B5**: 商品保存规格去重逻辑(GoodsService.php:1859)
|
||||
- [x] [Done: council/BackendArchitect] **Task B6**: 根因分析报告(含行号)→ `reviews/council-ghost-spec-BackendArchitect.md`
|
||||
- [x] [Done: council/BackendArchitect] **Task B7**: 将调研报告写入 `reviews/council-ghost-spec-BackendArchitect.md`
|
||||
|
||||
---
|
||||
|
||||
## 阶段划分 ✅
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| **Draft** | 🔄 进行中 | Round 1 — 各 Agent 并行规划 |
|
||||
| **Research** | ⬜ 待开始 | Round 2 — 执行调研 |
|
||||
| **Finalize** | ⬜ 待开始 | Round 3 — 收敛共识 |
|
||||
| **Draft** | Task 1-7(FrontendDev)+ Task S1-S3 + Task B1-B6(并行)| ✅ 完成 |
|
||||
| **Review** | Task 7 + Task S4 + Task B7(输出各自报告)| ✅ 完成 |
|
||||
| **Finalize** | Task S5:汇总到 `reviews/council-ghost-spec-summary.md` | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 输出文件
|
||||
## 根因结论
|
||||
|
||||
| 文件 | Agent | 截止轮次 |
|
||||
|------|-------|---------|
|
||||
| `docs/council-research-output.md` | ProductManager | Round 3 |
|
||||
| `docs/frontend-template-research.md` | FrontendDev | Round 2 |
|
||||
| `docs/backend-multi-sku-research.md` | BackendArchitect | Round 2 |
|
||||
| `docs/firstprinciples-verdict.md` | FirstPrinciples | Round 2 |
|
||||
| 优先级 | 根因 | 文件:行号 |
|
||||
|--------|------|-----------|
|
||||
| **P1(功能)** | 无效 config 块未从数组移除,`continue` 后脏数据写回 DB | AdminGoodsSaveHandle.php:88-89 + 148-150 |
|
||||
| **P2** | GetGoodsViewData 单模板模式,多模板时覆盖有效块 | SeatSkuService.php:368 + 386-388 |
|
||||
| **P3** | BatchGenerate 对无效 template_id 返回 code=-2,阻断保存 | AdminGoodsSaveHandle.php:164-170 |
|
||||
| **P4** | 前端过滤后 configs 为空时用户无声失去配置 | AdminGoodsSave.php:196-229 |
|
||||
| **P5** | loadSoldSeats 未实现(TODO 注释) | ticket_detail.html:375-383 |
|
||||
| **安全评估** | 无 P1 安全漏洞,属于 P2 功能缺陷 | SecurityEngineer-GHOST_SPEC_SECURITY.md |
|
||||
|
||||
---
|
||||
|
||||
## 关键文件参考
|
||||
## 关键文件
|
||||
|
||||
- `docs/12_UNIAPP_FRONTEND_RESEARCH.md` — 现有 uni-app 调研(需更新)
|
||||
- `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` — 现有模板渲染调研
|
||||
- `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` — 当前模板
|
||||
- `docs/02_FRONTEND_CUSTOMIZATION.md` — 前端定制历史文档
|
||||
| 文件 | 关注点 |
|
||||
|------|--------|
|
||||
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | P1 根因:continue 不删除脏 config |
|
||||
| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | GetGoodsViewData:P2 根因,多模板处理缺陷 |
|
||||
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php` | 前端过滤逻辑:P4 体验问题 |
|
||||
| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | VenueDelete:硬删除逻辑(第 888 行) |
|
||||
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | loadSoldSeats 未实现(P5) |
|
||||
| `shopxo/app/service/GoodsService.php` | 规格列值去重检测(第 1859 行) |
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### P1 Fix(立即实施)
|
||||
1. AdminGoodsSaveHandle.php:88 — `continue` 改为 `unset($configs[$i])`
|
||||
2. AdminGoodsSaveHandle.php:145 后 — 添加 `$configs = array_values($configs);`
|
||||
3. AdminGoodsSaveHandle.php:148 — 写回前加 `if (!empty($configs))`
|
||||
4. AdminGoodsSaveHandle.php:158-173 — BatchGenerate 前增加模板存在性显式校验
|
||||
|
||||
### P2 Fix(高优先级)
|
||||
1. SeatSkuService.php GetGoodsViewData — 遍历所有有效配置块,不只处理 `$vrGoodsConfig[0]`
|
||||
2. 修改 DB 写回逻辑为写回 `validConfigs` 而非 `[$config]`
|
||||
|
||||
### P3 Fix(中优先级)
|
||||
1. AdminGoodsSave.php — configs 为空时提示用户重新选择场馆
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
# 幽灵 Spec 问题调研报告
|
||||
|
||||
> 日期:2026-04-20 | 来源:独立验证(验证 Council 调研结果)
|
||||
|
||||
---
|
||||
|
||||
## 一、问题概述
|
||||
|
||||
**症状**:删除场馆后,编辑商品时即便场馆已置空,提交保存时仍不自动清除对应的 spec。
|
||||
|
||||
**Council 结论**:根因在 `AdminGoodsSaveHandle.php:88-89` 的 `continue` 语句,导致无效 config 块残留并写回 DB。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据流分析
|
||||
|
||||
### 2.1 读取链路(商品编辑页加载)
|
||||
|
||||
```
|
||||
ShopXO 商品编辑页
|
||||
↓
|
||||
AdminGoodsSave::handle() 返回 Vue 组件 HTML
|
||||
- 从 vr_seat_templates WHERE status=1 读取有效模板列表
|
||||
- 从 goods.vr_goods_config 读取原始配置
|
||||
AdminGoodsSave.php:196-202 (前端 JS 过滤)
|
||||
.filter(c => validTemplateIds.has(c.template_id)) ← 关键过滤
|
||||
.filter(...validRoomIds...) ← 过滤无效 room ID
|
||||
↓
|
||||
Vue 表单展示清洗后的配置
|
||||
↓
|
||||
用户修改配置,提交 vr_goods_config_base64
|
||||
```
|
||||
|
||||
### 2.2 保存链路(商品保存)
|
||||
|
||||
```
|
||||
前端提交 vr_goods_config_base64
|
||||
↓
|
||||
AdminGoodsSaveHandle.php:29-35 (save_handle 时机)
|
||||
base64_decode → 写入 $data['vr_goods_config']
|
||||
↓
|
||||
ShopXO 原生 GoodsSpecificationsInsert (事务内)
|
||||
生成 GoodsSpecType / GoodsSpecBase / GoodsSpecValue(原生规格)
|
||||
↓
|
||||
AdminGoodsSaveHandle.php:55-179 (save_thing_end 时机)
|
||||
├─ 从 DB 读 vr_goods_config(最新数据)
|
||||
├─ 遍历 configs[],重建 template_snapshot(无效 template_id 则 continue)
|
||||
├─ 写回 vr_goods_config 到 goods 表 ← 脏数据写回!
|
||||
├─ 清空 GoodsSpecType/GoodsSpecBase/GoodsSpecValue
|
||||
└─ 逐模板 BatchGenerate(无效 template_id 静默跳过)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Council 调研结果的验证
|
||||
|
||||
### 3.1 Council 发现的核心问题(正确)
|
||||
|
||||
**文件**:`shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`
|
||||
|
||||
```php
|
||||
// 第 77-90 行
|
||||
foreach ($configs as $i => &$config) {
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
$selectedRooms = $config['selected_rooms'] ?? [];
|
||||
|
||||
if ($templateId > 0 && (!empty($selectedRooms) || empty($config['template_snapshot']) || empty($config['template_snapshot']['rooms']))) {
|
||||
$template = Db::name('vr_seat_templates')->find($templateId); // 第 83 行
|
||||
|
||||
if (empty($template)) {
|
||||
continue; // ← BUG:只跳过本次循环,config 块仍留在 $configs 数组中
|
||||
}
|
||||
// ... snapshot 重建逻辑
|
||||
}
|
||||
}
|
||||
unset($config);
|
||||
|
||||
// 第 148-150 行:无条件写回 DB
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode($configs, ...),
|
||||
]);
|
||||
```
|
||||
|
||||
**问题**:
|
||||
1. 当 `template_id` 指向已硬删除的模板时,`find()` 返回 null
|
||||
2. `continue` 只跳过 snapshot 重建,但 config 块仍保留在 `$configs` 数组
|
||||
3. 第 148-150 行将含无效 `template_id` 的 config 块写回 DB
|
||||
|
||||
### 3.2 前端过滤是否有效?
|
||||
|
||||
**Council 遗漏的关键点**:后台商品编辑页(AdminGoodsSave.php)本身的前端过滤。
|
||||
|
||||
查看 `AdminGoodsSave.php:196-202`:
|
||||
|
||||
```javascript
|
||||
if (AppData.vrGoodsConfig && Array.isArray(AppData.vrGoodsConfig)) {
|
||||
// 从 vr_seat_templates WHERE status=1 获取有效模板 ID
|
||||
const validTemplateIds = new Set((AppData.templates || []).map(t => t.id));
|
||||
|
||||
configs.value = AppData.vrGoodsConfig
|
||||
// 过滤掉模板已删除的配置
|
||||
.filter(c => validTemplateIds.has(c.template_id))
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- `validTemplateIds` 只包含 `status=1` 的有效模板
|
||||
- 硬删除的模板不在 `vr_seat_templates` 表中
|
||||
- 所以 `.filter(c => validTemplateIds.has(c.template_id))` **会正确移除无效模板的配置**
|
||||
|
||||
**结论**:前端过滤是有效的,但问题出在后端的 `save_thing_end` 时机从数据库重新读取数据。
|
||||
|
||||
### 3.3 真实的问题场景
|
||||
|
||||
1. **商品创建时**:用户配置了场馆 A(template_id=5)和场馆 B(template_id=6)
|
||||
2. **场馆 A 被硬删除**:vr_seat_templates 表中无记录
|
||||
3. **用户编辑商品**:
|
||||
- 前端读取 DB 中的 vr_goods_config(仍含场馆 A 的配置)
|
||||
- 前端过滤后只提交场馆 B 的配置
|
||||
4. **后端 save_handle**:接收前端提交的只含场馆 B 的配置
|
||||
5. **后端 save_thing_end**:
|
||||
- 从 DB 读取 vr_goods_config → **此时读到的是旧数据(含场馆 A)**
|
||||
- 遍历时场馆 A 的 template_id=5 查不到模板,continue 跳过
|
||||
- **场馆 A 的 config 块残留在数组中**
|
||||
- 写回 DB → **场馆 A 的脏配置被写回!**
|
||||
|
||||
**关键发现**:save_thing_end 从 DB 读取的是 goods 表中的数据,而非 save_handle 时提交的 `$data['vr_goods_config']`。如果 goods 表中原本就有脏数据,问题就会累积。
|
||||
|
||||
---
|
||||
|
||||
## 四、"规格不允许重复" 的来源
|
||||
|
||||
该错误信息来自 `GoodsService.php:1859`,是 ShopXO 原生规格验证逻辑。
|
||||
|
||||
**可能场景**:
|
||||
1. 商品曾以普通商品(有原生 spec)保存
|
||||
2. 后转换为票务商品
|
||||
3. 保存时 ShopXO 原生 GoodsSpecificationsInsert 先生成原生规格
|
||||
4. AdminGoodsSaveHandle save_thing_end 执行清空规格表
|
||||
5. 但如果时序有问题,原生规格可能残留
|
||||
|
||||
---
|
||||
|
||||
## 五、根因总结
|
||||
|
||||
| 优先级 | 根因 | 位置 | 影响 |
|
||||
|--------|------|------|------|
|
||||
| **P1** | save_thing_end 从 DB 读取时,无效 config 块未被移除 | AdminGoodsSaveHandle.php:88-89 + 148-150 | 脏数据写回 DB,幽灵 spec 累积 |
|
||||
| P2 | GetGoodsViewData 只处理第一个配置块 | SeatSkuService.php:368 | 多模板时无效块不清理 |
|
||||
|
||||
---
|
||||
|
||||
## 六、修复方案
|
||||
|
||||
### P1 Fix(立即实施)
|
||||
|
||||
**文件**:`AdminGoodsSaveHandle.php`
|
||||
|
||||
**修改 1**:第 88-89 行
|
||||
```php
|
||||
if (empty($template)) {
|
||||
unset($configs[$i]); // 移除无效 config 块
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
**修改 2**:第 145 行后(unset($config) 之后)
|
||||
```php
|
||||
unset($config);
|
||||
$configs = array_values($configs); // 重排索引
|
||||
```
|
||||
|
||||
**修改 3**:第 148-150 行前加判空
|
||||
```php
|
||||
if (!empty($configs)) {
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
**修改 4**:BatchGenerate 循环中增加防御性校验(第 158-173 行)
|
||||
```php
|
||||
foreach ($configs as $config) {
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
if ($templateId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$template = Db::name('vr_seat_templates')->find($templateId);
|
||||
if (empty($template)) {
|
||||
continue; // 无效块跳过
|
||||
}
|
||||
$res = SeatSkuService::BatchGenerate(...);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### P2 Fix(高优先级)
|
||||
|
||||
**文件**:`SeatSkuService.php` 第 368-393 行
|
||||
|
||||
GetGoodsViewData 需要遍历所有配置块,清理无效块后再处理:
|
||||
```php
|
||||
// 过滤有效配置块
|
||||
$validConfigs = [];
|
||||
foreach ($vrGoodsConfig as $cfg) {
|
||||
$tid = intval($cfg['template_id'] ?? 0);
|
||||
if ($tid <= 0) continue;
|
||||
$tpl = Db::name(self::table('seat_templates'))->where('id', $tid)->find();
|
||||
if (!empty($tpl)) {
|
||||
$validConfigs[] = $cfg;
|
||||
}
|
||||
}
|
||||
if (empty($validConfigs)) {
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||
}
|
||||
$config = $validConfigs[0]; // 取第一个有效配置块用于前端展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实施计划
|
||||
|
||||
| 步骤 | 任务 | 文件 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| 1 | 修复 P1:无效 config 块移除 | AdminGoodsSaveHandle.php | P1 |
|
||||
| 2 | 修复 P2:GetGoodsViewData 多模板清理 | SeatSkuService.php | P1 |
|
||||
| 3 | 测试验证 | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 八、结论
|
||||
|
||||
1. **Council 的调研结果基本正确**,但遗漏了前端过滤本身是有效的这一点
|
||||
2. **真正的根因**在于 `save_thing_end` 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据
|
||||
3. **修复方案**是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
|
||||
4. **GetGoodsViewData** 也需要同步修复,支持多模板模式
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
# 安全审计报告:幽灵 Spec(Ghost Spec)安全问题评估
|
||||
|
||||
**审计人**: SecurityEngineer
|
||||
**日期**: 2026-04-20
|
||||
**审计对象**: 场馆硬删除后编辑商品的规格重复错误问题
|
||||
**项目路径**: `/Users/bigemon/WorkSpace/vr-shopxo-plugin/`
|
||||
|
||||
---
|
||||
|
||||
## 一、审计范围
|
||||
|
||||
本次审计覆盖以下文件:
|
||||
|
||||
| 文件 | 关键行号 | 审计重点 |
|
||||
|------|---------|---------|
|
||||
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | 全文 | 保存钩子是否拒绝脏数据 |
|
||||
| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | 全文 | BatchGenerate 安全校验、GetGoodsViewData fallback |
|
||||
| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | 858-912 | VenueDelete 硬删除逻辑 |
|
||||
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 182-449 | 前端 fallback 安全风险 |
|
||||
|
||||
---
|
||||
|
||||
## 二、S1 — AdminGoodsSaveHandle.php 审计
|
||||
|
||||
### S1-Q1: 当 template_id 指向不存在的场馆时,是否拒绝保存?
|
||||
|
||||
**结论:行为正确,但错误信息不友好**
|
||||
|
||||
关键代码路径:
|
||||
|
||||
1. **保存阶段 1**(第 22-41 行,`plugins_service_goods_save_handle`):
|
||||
- 前端发送 `vr_goods_config_base64`(含 `template_id`、`selected_rooms`、`selected_sections`、`sessions`、`template_snapshot`)
|
||||
- 直接 base64 解码写入 `$params['data']['vr_goods_config']`
|
||||
- **无任何校验** — 这是正确的,因为此时模板可能还未删除
|
||||
|
||||
2. **保存阶段 2**(第 55-182 行,`plugins_service_goods_save_thing_end`):
|
||||
- 第 77-90 行:遍历 configs,尝试重建 `template_snapshot`
|
||||
- **第 88-89 行**:模板不存在时执行 `continue`,**跳过 snapshot 重建但不阻断流程**
|
||||
- 第 158-172 行:对每个 `template_id > 0` 的 config 调用 `BatchGenerate`
|
||||
|
||||
3. **BatchGenerate 保护**(SeatSkuService.php 第 51-57 行):
|
||||
```php
|
||||
$template = Db::name(self::table('seat_templates'))
|
||||
->where('id', $seatTemplateId)->find();
|
||||
if (empty($template)) {
|
||||
return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
|
||||
}
|
||||
```
|
||||
|
||||
**结论**:如果 `template_id` 仍存在于 `vr_goods_config` 中但模板已被硬删除,`BatchGenerate` 返回 `code: -2`,该错误被第 169-171 行捕获并向上游返回,**整个保存事务被阻断**。用户看到的错误是 "座位模板 N 不存在"。
|
||||
|
||||
**评估**:安全 — 保存被正确阻断。但错误信息不友好(应告知用户重新选择场馆),属于 P2。
|
||||
|
||||
### S1-Q2: 幽灵 spec 是否可被恶意注入到 vr_goods_config?
|
||||
|
||||
**结论:不可注入,无漏洞**
|
||||
|
||||
分析:
|
||||
|
||||
- `vr_goods_config_base64` 中的字段:**由前端表单构造**,但不含 `spec_base_id_map`
|
||||
- `spec_base_id_map` **仅存储在 `vr_seat_templates` 表中**(Admin.php 第 177 行)
|
||||
- AdminGoodsSaveHandle 的保存流程中,**不读取也不回写 `spec_base_id_map`**
|
||||
- `template_snapshot` 在保存时由后端从 DB 重建(第 77-90 行),前端传来的值被覆盖
|
||||
|
||||
攻击路径分析:
|
||||
1. 攻击者能否伪造 `vr_goods_config_base64` 注入恶意 `spec_base_id_map`?→ **不能**,该字段不在表单构造范围内,且若注入则与 `template_id` 关联的 DB 记录不匹配,`BatchGenerate` 失败
|
||||
2. 攻击者能否通过 `template_snapshot` 注入 XSS?→ **理论上可能**,`template_snapshot.venue` 未做 HTML 转义,但该字段仅在后端处理,不渲染到前端(ticket_detail.html 中 venue 数据来自 `$vr_seat_template` 而非 snapshot)
|
||||
3. 攻击者能否利用 `template_id` 复用已删除场馆的规格?→ **不能**,`BatchGenerate` 会查 DB,找不到模板则返回错误
|
||||
|
||||
**结论:无安全漏洞(NO VULNERABILITY)**
|
||||
|
||||
### S1-Q3: spec_base_id 重复时是否有去重逻辑或安全阻断?
|
||||
|
||||
**结论:有兜底阻断(BatchGenerate 失败),但无专门去重逻辑**
|
||||
|
||||
- `BatchGenerate` 从 DB 读取当前模板的 `seat_map`,生成**新的**座位级 SKU
|
||||
- 保存时会先清空现有规格数据(第 152-155 行):
|
||||
```php
|
||||
Db::name('GoodsSpecType')->where('goods_id', $goodsId)->delete();
|
||||
Db::name('GoodsSpecBase')->where('goods_id', $goodsId)->delete();
|
||||
Db::name('GoodsSpecValue')->where('goods_id', $goodsId)->delete();
|
||||
```
|
||||
- **先删后建**模式自然覆盖了旧的重复规格,不依赖去重
|
||||
|
||||
**结论:无 spec_base_id 重复安全问题
|
||||
|
||||
---
|
||||
|
||||
## 三、S2 — SeatSkuService.php 审计
|
||||
|
||||
### S2-Q1: GetGoodsViewData 在模板不存在时如何 fallback?
|
||||
|
||||
**结论:fallback 行为安全,但会修改数据库**
|
||||
|
||||
关键代码(SeatSkuService.php 第 380-393 行):
|
||||
```php
|
||||
if (empty($seatTemplate)) {
|
||||
$config['template_id'] = null;
|
||||
$config['template_snapshot'] = null;
|
||||
\think\facade\Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode([$config], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
return [
|
||||
'vr_seat_template' => null,
|
||||
'goods_spec_data' => [],
|
||||
'goods_config' => $config,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**安全分析**:
|
||||
- `vr_seat_template: null` — 前端收到的座位模板为空
|
||||
- `goods_spec_data: []` — 场次列表为空
|
||||
- **该方法会主动修改 DB**(将 `template_id` 置 null),这是一个"自愈"行为
|
||||
- 自愈行为本身**不引入安全漏洞**,但有副作用:编辑商品时,用户原本的场馆关联被静默清空
|
||||
|
||||
**结论:fallback 逻辑本身安全,但会静默修改 DB 状态**
|
||||
|
||||
### S2-Q2: template_snapshot 是否可携带恶意 payload?
|
||||
|
||||
**结论:理论风险低,实际不可利用**
|
||||
|
||||
- `template_snapshot` 在保存时由后端重建(第 139-142 行),前端传入值被覆盖
|
||||
- `template_snapshot` 字段未在 ticket_detail.html 中直接渲染
|
||||
- `template_snapshot` 存储在 `vr_goods_config` JSON 中,无大小限制(vr_goods_config 字段需确认 DB schema)
|
||||
|
||||
**潜在风险**:
|
||||
- 如果 `vr_goods_config` 字段无大小限制,可存储超大 JSON(DoS 风险)— 需 DB 层加限
|
||||
- 如果未来代码变更直接渲染 `template_snapshot` 而不转义,可能 XSS — 当前代码无此路径
|
||||
|
||||
**结论:当前代码无实际可利用漏洞,建议在 DB 层对 `vr_goods_config` 加字段大小限制**
|
||||
|
||||
---
|
||||
|
||||
## 四、S3 — ShopXO 入口安全审计
|
||||
|
||||
### S3-Q1: ShopXO AdminGoodsSave.php 入口是否有参数校验?
|
||||
|
||||
**结论:入口层无专门校验,但 VR 插件有独立校验**
|
||||
|
||||
- `AdminGoodsSave.php`(文件不存在于项目根目录,可能位于 shopxo 源码中)作为 ShopXO 内核钩子入口
|
||||
- VR 插件的商品保存通过插件钩子 `AdminGoodsSaveHandle::handle()` 处理
|
||||
- 插件层面:校验逻辑在 `BatchGenerate` 中(模板存在性检查)
|
||||
- **未发现**未授权保存、越权修改其他商品、参数注入等安全漏洞
|
||||
|
||||
**结论:入口安全,VR 插件有独立校验**
|
||||
|
||||
---
|
||||
|
||||
## 五、VenueDelete 硬删除逻辑审计
|
||||
|
||||
### 硬删除安全检查(Admin.php 第 858-912 行)
|
||||
|
||||
关键代码:
|
||||
```php
|
||||
// 检查是否有关联商品(使用 is_delete_time 而不是 is_delete)
|
||||
$goods = \think\facade\Db::name('Goods')
|
||||
->where('vr_goods_config', 'like', '%"template_id":' . $id . '%')
|
||||
->where('is_delete_time', 0)
|
||||
->find();
|
||||
```
|
||||
|
||||
**安全分析**:
|
||||
- 硬删除**不检查商品是否有关联**,直接执行删除(第 888 行)
|
||||
- 关联商品仍然持有旧的 `template_id`,但如前所述,下次保存会被 `BatchGenerate` 阻断
|
||||
- SQL 注入风险:`$id` 为 `intval`,安全
|
||||
- 审计日志已记录(第 889-895 行)
|
||||
|
||||
**结论:硬删除安全,不引入额外漏洞**
|
||||
|
||||
---
|
||||
|
||||
## 六、漏洞严重性评级
|
||||
|
||||
| ID | 问题 | 类别 | 严重性 | 说明 |
|
||||
|----|------|------|--------|------|
|
||||
| V-1 | 场馆硬删除后保存失败,错误信息不友好("座位模板 N 不存在") | 功能/体验 | **P2** | 用户无法理解需要重新选择场馆 |
|
||||
| V-2 | GetGoodsViewData 会静默修改 DB(将 template_id 置 null) | 功能/行为 | **P2** | 编辑商品时场馆关联被静默清空 |
|
||||
| V-3 | loadSoldSeats() 为空实现,前端无法标记已售座位 | 业务逻辑 | **P2** | 用户可选中已售座位(超卖风险) |
|
||||
| V-4 | template_snapshot 字段无大小限制 | DoS 风险 | **P3** | 需 DB 层加字段限制 |
|
||||
| V-5 | spec_base_id_map 在保存流程中不可达 | 无漏洞 | — | 该字段仅用于 Plan A 订单流程 |
|
||||
|
||||
**P1 发现:0 个**
|
||||
**P2 发现:3 个**
|
||||
**P3 发现:1 个**
|
||||
|
||||
---
|
||||
|
||||
## 七、根因定性
|
||||
|
||||
**本次幽灵 spec 问题的根因是 P2(功能缺陷),不属于安全漏洞。**
|
||||
|
||||
具体机制:
|
||||
1. 场馆 A 被硬删除,`vr_seat_templates` 表中无记录
|
||||
2. 商品的 `vr_goods_config.template_id` 仍为 A 的 ID
|
||||
3. `GetGoodsViewData` 在读取时将 `template_id` 置 null 并写回 DB(自愈)
|
||||
4. 若用户在 `GetGoodsViewData` 执行前打开编辑页,前端收到 `template_id: null`,选单为空
|
||||
5. 若 `vr_goods_config` 中 `template_id` 未被及时清理,下次保存时 `BatchGenerate` 返回错误阻断
|
||||
|
||||
**关键保护机制**:`BatchGenerate` 是最后一道防线 — 只要 `template_id` 仍指向不存在的模板,保存就会被阻断,不会有脏数据写入规格表。
|
||||
|
||||
---
|
||||
|
||||
## 八、修复建议(按优先级)
|
||||
|
||||
### P2-1(高优先级):改善错误信息
|
||||
**文件**: `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php:55-57`
|
||||
**修改**: 将错误信息改为用户可理解的形式,并引导重新选择场馆
|
||||
|
||||
### P2-2(中优先级):防止静默 DB 修改
|
||||
**文件**: `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php:383-388`
|
||||
**修改**: GetGoodsViewData 不应主动修改 DB,而应返回 flag 让调用方决定是否清理
|
||||
|
||||
### P2-3(中优先级):实现 loadSoldSeats
|
||||
**文件**: `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html:375-383`
|
||||
**修改**: 实现从后端 API 加载已售座位数据
|
||||
|
||||
### P3-1(低优先级):DB 字段大小限制
|
||||
**修改**: 为 `goods.vr_goods_config` 字段加 TEXT/MEDIUMTEXT 限制,防止超大 JSON 存储
|
||||
|
||||
---
|
||||
|
||||
## 九、审计结论
|
||||
|
||||
本次审计**未发现任何 P1 安全漏洞**。幽灵 spec 问题是由场馆硬删除引发的**功能缺陷**(P2),核心保护机制(`BatchGenerate` 模板存在性检查)在场。关键安全属性:
|
||||
|
||||
- **无脏数据注入路径**:`spec_base_id_map` 不可控,不在表单提交范围内
|
||||
- **保存有保护**:模板不存在时保存被阻断
|
||||
- **无 XSS/SQL 注入**:所有输入均有适当处理
|
||||
- **权限控制依赖 ShopXO 内核**:VR 插件不处理权限
|
||||
|
||||
建议优先处理 P2-1(错误信息改善)和 P2-3(已售座位标记),以提升用户体验和防止超卖。
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
# SecurityEngineer — 幽灵 spec 安全审计汇总报告
|
||||
|
||||
**文件路径基于** `/Users/bigemon/WorkSpace/vr-shopxo-plugin/`
|
||||
**审计时间**:2026-04-20
|
||||
**参与者**:SecurityEngineer(安全审计)、BackendArchitect(根因分析)、FrontendDev(前端分析)
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
对「场馆删除后编辑商品出现规格重复错误」问题进行了三方安全审计。核心根因已定位,**P1 安全缺陷**已识别。
|
||||
|
||||
---
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | 商品保存钩子,vr_goods_config 处理 |
|
||||
| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | 批量 SKU 生成,模板不存在时的 fallback |
|
||||
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 顾客端座位选购页面 |
|
||||
| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | 场馆硬删除逻辑 |
|
||||
| `shopxo/app/admin/hook/AdminGoodsSave.php` | ShopXO 商品保存钩子入口 |
|
||||
|
||||
---
|
||||
|
||||
## 根因分析(SecurityEngineer)
|
||||
|
||||
### 根因 1(P1):无效 template_id 配置块未被过滤
|
||||
|
||||
**文件**:`AdminGoodsSaveHandle.php:148-173`
|
||||
|
||||
当 `vr_goods_config` 中存在 `template_id` 指向已删除场馆的配置块时:
|
||||
|
||||
1. `save_thing_end` 从 DB 加载 config(第 61-66 行)
|
||||
2. 遍历 configs 尝试重建 `template_snapshot`(第 77 行)
|
||||
3. 若模板不存在,`continue` 跳过 snapshot 重建(第 88-90 行)
|
||||
4. **整个 config 块(含旧的 `template_snapshot`)被写回 DB**(第 148-150 行)
|
||||
5. `BatchGenerate` 被调用时,若 `template_id` 仍为正整数但模板不存在,返回 `code: -2` 阻止保存
|
||||
|
||||
**关键缺陷**:若 config 块的 `template_id` 被前端置为 `0`(模板选单为空),则 `templateId > 0` 为 `false`,`BatchGenerate` 整个循环体被跳过,**无任何校验**直接写回。
|
||||
|
||||
### 根因 2(P1):幽灵 spec 持续污染 vr_goods_config
|
||||
|
||||
脏 config 块(含已删除模板的 `template_snapshot`)被写回 DB 后:
|
||||
- 下次编辑商品时,`vr_goods_config` 仍含无效配置
|
||||
- `GetGoodsViewData` 尝试加载模板(失败后将 `template_id` 置 null)
|
||||
- 但若 `save_thing_end` 在模板验证前先执行写回,无效配置再次被保存
|
||||
- 循环往复,**幽灵 spec 永远无法被清理**
|
||||
|
||||
### 根因 3(P2):前端无 `vr_goods_config_base64` 输入保护
|
||||
|
||||
`AdminGoodsSaveHandle.php:29-35` 接收前端传入的 base64 编码配置:
|
||||
- 无 schema 校验(不验证 `template_id` 是否为正整数)
|
||||
- 无类型校验(不验证是否为数组)
|
||||
- 管理员可直接 POST 恶意 JSON 注入 `vr_goods_config`
|
||||
|
||||
---
|
||||
|
||||
## 前端分析(参考 ticket_detail.html)
|
||||
|
||||
### 硬删除场景下的 fallback
|
||||
|
||||
`SeatSkuService::GetGoodsViewData` 在模板不存在时:
|
||||
- `vr_seat_template` 返回 `null`
|
||||
- `goods_config.template_id` 置 `null`
|
||||
- `goods_config.template_snapshot` 置 `null`
|
||||
- `goods_spec_data` 返回空数组
|
||||
|
||||
前端 `ticket_detail.html` 读取 `seatMap = []` 和 `specBaseIdMap = []`,座位图不渲染。**设计正确**。
|
||||
|
||||
### 安全风险
|
||||
|
||||
1. **`loadSoldSeats()` 未实现**(ticket_detail.html:375-383):TODO 注释状态,无法标记已售座位。顾客可购买已售座位(需支付验证拦截)。
|
||||
2. **`submit` 依赖 `specBaseIdMap`**(第 417 行):空时降级 `sessionSpecId`。理论上可操控座位数据选择任意座位。
|
||||
3. **无直接 XSS**:后端输出均有编码,`seatMap` 和 `specBaseIdMap` 来自 DB 合规数据。
|
||||
|
||||
---
|
||||
|
||||
## 严重性分级
|
||||
|
||||
| 等级 | 数量 | 描述 |
|
||||
|------|------|------|
|
||||
| **P1** | 2 | 无效 template_id 静默保存;幽灵 spec 无法清理 |
|
||||
| **P2** | 3 | Admin API 无 schema 校验;残留 snapshot 信息泄露;specBaseIdMap 端侧无验证 |
|
||||
| **低** | 0 | 无直接 XSS |
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### P1-1/P1-2:拒绝无效 template_id(必须)
|
||||
|
||||
**AdminGoodsSaveHandle.php:158-173** 需在调用 `BatchGenerate` 前验证:
|
||||
|
||||
```php
|
||||
foreach ($configs as $config) {
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
if ($templateId <= 0) {
|
||||
return ['code' => -401, 'msg' => '票务配置中的 template_id 无效或已被删除'];
|
||||
}
|
||||
$exists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
|
||||
if (empty($exists)) {
|
||||
return ['code' => -401, 'msg' => 'template_id [' . $templateId . '] 指向的场馆已不存在'];
|
||||
}
|
||||
$res = SeatSkuService::BatchGenerate(...);
|
||||
if ($res['code'] !== 0) {
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### P2-1:过滤无效 config 块(必须)
|
||||
|
||||
在写回 DB 之前过滤掉 `template_id <= 0` 的配置块:
|
||||
|
||||
```php
|
||||
$validConfigs = array_filter($configs, function($c) {
|
||||
return intval($c['template_id'] ?? 0) > 0;
|
||||
});
|
||||
if (empty($validConfigs)) {
|
||||
return ['code' => -401, 'msg' => '票务商品必须包含至少一个有效场馆配置'];
|
||||
}
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
**幽灵 spec 的根因是后端未拒绝脏数据**,而非前端注入。`save_thing_end` 在模板验证失败时静默保留了无效的 config 块,导致 `vr_goods_config` 中的幽灵 spec 永远无法被清理。修复方向明确:任何 `template_id` 为空或指向不存在场馆的配置块,都必须被过滤或拒绝保存,并返回 `code: -401` 告知用户重新选择场馆。
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
# SecurityEngineer — 幽灵 spec 安全审计报告
|
||||
|
||||
**文件路径基于** `/Users/bigemon/WorkSpace/vr-shopxo-plugin/`
|
||||
**审计时间**:2026-04-20
|
||||
**审计范围**:AdminGoodsSaveHandle.php、SeatSkuService.php、ticket_detail.html、Admin.php、AdminGoodsSave.php
|
||||
|
||||
---
|
||||
|
||||
## S1. AdminGoodsSaveHandle 脏数据拒绝逻辑
|
||||
|
||||
### S1-Q1:当 `template_id` 指向不存在的场馆时,是否拒绝保存(code -401)?
|
||||
|
||||
**结论:否 — 脏数据被静默保存,存在 P1 安全缺陷。**
|
||||
|
||||
**根因分析**:
|
||||
|
||||
1. **保存时 `save_thing_end` 流程**(AdminGoodsSaveHandle.php:158-173):
|
||||
```php
|
||||
foreach ($configs as $config) {
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
if ($templateId > 0) {
|
||||
$res = SeatSkuService::BatchGenerate(...);
|
||||
if ($res['code'] !== 0) {
|
||||
return $res; // ← 仅在此处返回错误
|
||||
}
|
||||
}
|
||||
// templateId == 0 时:整个循环体被跳过,什么都不做
|
||||
}
|
||||
```
|
||||
**关键**:当 `template_id` 硬编码为某个已删除模板的 ID(整数,如 `5`)时,`intval($config['template_id'] ?? 0)` 返回 `5`,`templateId > 0` 为 `true`,代码进入 `BatchGenerate` 调用。
|
||||
|
||||
2. **BatchGenerate 内部有模板存在性校验**(SeatSkuService.php:52-57):
|
||||
```php
|
||||
$template = Db::name(self::table('seat_templates'))->where('id', $seatTemplateId)->find();
|
||||
if (empty($template)) {
|
||||
return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
|
||||
}
|
||||
```
|
||||
返回 `code: -2`,**但 `AdminGoodsSaveHandle.php:169` 只检查 `!== 0`**:
|
||||
```php
|
||||
if ($res['code'] !== 0) {
|
||||
return $res; // -2 !== 0 → 确实返回错误
|
||||
}
|
||||
```
|
||||
|
||||
所以 `BatchGenerate` 返回 `-2` 时,**错误确实被向上传播**,保存被拒绝。
|
||||
|
||||
3. **但 `save_thing_end` 在返回错误之前,已将修改后的 config 写回 DB**(AdminGoodsSaveHandle.php:148-150):
|
||||
```php
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
```
|
||||
此写回发生在 `foreach ($configs as $config)` 循环(第 77 行)**之后**,即 `template_snapshot` 已被处理。问题是:对于无效模板的 config 块,`template_snapshot` 不会被重建(跳过第 88-90 行的 `continue`),但**旧的 `template_snapshot` 仍然保留在内存的 `$config` 中**,随后被写回 DB。
|
||||
|
||||
**P1 缺陷**:当模板不存在时,`template_snapshot` 不被清理。即使 `BatchGenerate` 返回错误阻止了保存,`vr_goods_config` 中**已含有一个指向不存在模板的配置块**,且其 `template_snapshot` 仍保留旧的座位图数据。
|
||||
|
||||
4. **另一个路径**(SeatSkuService.php:55-57):如果模板记录被物理删除,`find()` 返回 `null`,`BatchGenerate` 返回 `-2` 并**阻止保存**。但如果 config 中的 `template_id` 为 `0`(`intval(null)` 或前端传空),则 `templateId > 0` 为 `false`,循环体完全跳过,`vr_goods_config` 被写回时**没有任何校验**。
|
||||
|
||||
### S1-Q2:幽灵 spec 的产生环节
|
||||
|
||||
**幽灵 spec 产生于 `vr_goods_config` 的 `spec_base_id_map` 字段**。分析如下:
|
||||
|
||||
- `spec_base_id_map` 存储在 `vr_seat_templates.spec_base_id_map` 表字段中(Admin.php:177)
|
||||
- 当前端编辑含 `vr_goods_config` 的商品时,`save_thing_end` 加载 config 后,遍历每个 config 块:
|
||||
- 如果 `template_id` 有效 → `BatchGenerate` 重新生成所有 SKU
|
||||
- 如果 `template_id` 无效(0 或已删除)→ 跳过 `BatchGenerate`,config 块**原样写回 DB**
|
||||
|
||||
**幽灵 spec 不会被过滤**,因为保存逻辑中没有针对无效 `template_id` 配置块的过滤/清理逻辑。
|
||||
|
||||
---
|
||||
|
||||
## S2. 脏数据注入路径分析
|
||||
|
||||
### S2-Q1:幽灵 spec 是否可被恶意利用来注入/覆盖商品规格?
|
||||
|
||||
**结论:理论风险存在(中等),但需管理员权限利用。**
|
||||
|
||||
**攻击路径**:
|
||||
|
||||
1. **通过 `vr_goods_config_base64` 参数注入**(AdminGoodsSaveHandle.php:29-35):
|
||||
```php
|
||||
$base64Config = $postParams['vr_goods_config_base64'] ?? '';
|
||||
if (!empty($base64Config)) {
|
||||
$jsonStr = base64_decode($base64Config);
|
||||
if ($jsonStr !== false) {
|
||||
$params['data']['vr_goods_config'] = $jsonStr;
|
||||
}
|
||||
}
|
||||
```
|
||||
前端表单**不暴露** `vr_goods_config_base64` 输入框,所以普通用户在标准编辑流程中无法注入。
|
||||
|
||||
2. **ShopXO API 直接提交**:任何已登录的管理员可以直接 POST 到商品保存 API,携带恶意 `vr_goods_config_base64`,注入任意 JSON 到 `vr_goods_config` 字段。
|
||||
|
||||
3. **注入的内容**:
|
||||
- 多个 config 块引用同一个 `template_id`(重复模板)
|
||||
- 引用已删除模板的 `template_id`
|
||||
- `template_snapshot` 中注入任意字符串(虽然后端会重建,但若模板不存在则保留)
|
||||
|
||||
4. **`save_thing_end` 对 `vr_goods_config` 的处理**(AdminGoodsSaveHandle.php:61-66):从 DB 读取 `vr_goods_config`,**不使用前端传入的 `$data['vr_goods_config']`**(除非 DB 为空)。这意味着即使用户在 `save_handle` 时注入了恶意 config,`save_thing_end` 仍然基于数据库中的已有配置执行,不会直接使用注入值。
|
||||
|
||||
**但是**:若 DB 中已存在含幽灵 spec 的 `vr_goods_config`(由于之前的保存或注入),`save_thing_end` 会加载并处理它。
|
||||
|
||||
### S2-Q2:前端 fallback 安全风险
|
||||
|
||||
**结论:存在低-中风险(信息泄露 + CSS 注入),无直接 XSS。**
|
||||
|
||||
1. **`ticket_detail.html` 是顾客端页面**(非管理后台),查看源码:
|
||||
- `seatMap` = `<?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>`(第 186 行)
|
||||
- `specBaseIdMap` = `<?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>`(第 187 行)
|
||||
|
||||
2. **硬删除场景下的数据流**(SeatSkuService.php:380-393):
|
||||
```php
|
||||
if (empty($seatTemplate)) {
|
||||
$config['template_id'] = null;
|
||||
$config['template_snapshot'] = null;
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode([$config], ...),
|
||||
]);
|
||||
return [
|
||||
'vr_seat_template' => null, // ← 模板数据为空
|
||||
'goods_spec_data' => [],
|
||||
'goods_config' => $config,
|
||||
];
|
||||
}
|
||||
```
|
||||
当模板被硬删除后,`vr_seat_template` 返回 `null`,`seatMap` 和 `specBaseIdMap` 在前端均为空数组 `[]`。座位图不会渲染。**前端 fallback 设计正确**。
|
||||
|
||||
3. **但 `AdminGoodsSaveHandle.php:148-150` 写回脏数据时**,`template_snapshot` 未被清理,若前端访问到一个旧的 snapshot(来自数据库中残留的配置),`seatMap` 包含旧座位数据,此时:
|
||||
- `renderSeatMap()` 第 270 行:`style="background:'+color+'"` — color 值来自后端 DB,若 DB 被攻陷(通过 VenueSave 注入),可注入 CSS 表达式如 `url(javascript:...)`(现代浏览器已防护)
|
||||
- `renderSeatMap()` 第 275 行:`data-label` 属性 — 值来自 `seatInfo.label`,经过 `htmlspecialchars`(ShopXO 输出编码),**基本安全**
|
||||
- `renderSeatMap()` 第 275 行:`data-label="'rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"` — 硬编码的纯字母数字,无注入风险
|
||||
|
||||
4. **硬编码拼接中的潜在属性注入**(ticket_detail.html:275):
|
||||
```html
|
||||
data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"
|
||||
```
|
||||
此处 `colIndex+1` 是 JS 计算值,**无注入风险**。`rowLabel` 来自 `map.row_labels` 或 `chr(65+index)`,也是纯字母,**无注入风险**。
|
||||
|
||||
5. **`submit` 函数的 spec_base_id**(ticket_detail.html:417):
|
||||
```javascript
|
||||
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||
```
|
||||
若 `specBaseIdMap` 为空,降级到 `sessionSpecId`。理论上可操控座位图数据来选择任意座位,但购买还需付款环节验证。**风险有限**。
|
||||
|
||||
---
|
||||
|
||||
## S3. ShopXO 商品保存入口
|
||||
|
||||
**AdminGoodsSave.php** — 入口文件,只注册钩子,无额外校验逻辑。
|
||||
|
||||
---
|
||||
|
||||
## S4. 严重性分级
|
||||
|
||||
| # | 风险描述 | 严重性 | 根因位置 |
|
||||
|---|---------|--------|---------|
|
||||
| P1-1 | 模板不存在时,`template_snapshot` 未被清理就直接写回 DB,脏配置持续存在 | **P1** | AdminGoodsSaveHandle.php:148-150(硬删除后未清理 config 块) |
|
||||
| P1-2 | `template_id=0` 时整个 config 块无校验直接写回,任何人都能保存空规格商品 | **P1** | AdminGoodsSaveHandle.php:158-173(`templateId == 0` 时跳过所有处理) |
|
||||
| P2-1 | 管理员可通过 API 直接注入 `vr_goods_config_base64` 写入任意配置 | **P2** | AdminGoodsSaveHandle.php:29-35(无 schema 校验) |
|
||||
| P2-2 | 硬删除模板后,前端 fallback 依赖 DB 中残留的 `template_snapshot`(信息泄露) | **P2** | AdminGoodsSaveHandle.php:148-150(写回时未过滤无效 config) |
|
||||
| P2-3 | `submit` 依赖 `specBaseIdMap`(空时降级 sessionSpecId),无端侧验证 | **P2** | ticket_detail.html:417(需配合支付侧校验) |
|
||||
|
||||
---
|
||||
|
||||
## S5. 修复建议
|
||||
|
||||
### P1-1/P1-2 修复(必须)
|
||||
|
||||
**AdminGoodsSaveHandle.php:158-173**,修改后:
|
||||
|
||||
```php
|
||||
foreach ($configs as $config) {
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
if ($templateId <= 0) {
|
||||
// 无效 template_id:拒绝保存,返回错误
|
||||
return ['code' => -401, 'msg' => '票务配置中的 template_id 无效或已被删除,请重新选择场馆'];
|
||||
}
|
||||
// 验证模板在 DB 中存在
|
||||
$exists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
|
||||
if (empty($exists)) {
|
||||
return ['code' => -401, 'msg' => '票务配置中的 template_id [' . $templateId . '] 指向的场馆已不存在,请重新选择'];
|
||||
}
|
||||
$res = SeatSkuService::BatchGenerate(...);
|
||||
if ($res['code'] !== 0) {
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同时在循环之前(写回 DB 之前),过滤掉 `template_id <= 0` 的 config 块:
|
||||
|
||||
```php
|
||||
// 过滤无效 config 块(template_id 为空或 0)
|
||||
$validConfigs = array_filter($configs, function($c) {
|
||||
return intval($c['template_id'] ?? 0) > 0;
|
||||
});
|
||||
if (empty($validConfigs)) {
|
||||
return ['code' => -401, 'msg' => '票务商品必须包含至少一个有效场馆配置'];
|
||||
}
|
||||
```
|
||||
|
||||
### P2-1 修复(建议)
|
||||
|
||||
在 `save_handle` 时(AdminGoodsSaveHandle.php:29-35),对 `vr_goods_config_base64` 做 schema 校验:
|
||||
- 解码后必须是 JSON 数组
|
||||
- 每个 config 块的 `template_id` 必须是正整数
|
||||
- 禁止传入 `template_snapshot`(应始终由后端从 DB 重建)
|
||||
|
||||
### P2-2 修复(建议)
|
||||
|
||||
在 `save_thing_end` 写回 DB 之前,清理无效模板的 config 块:
|
||||
|
||||
```php
|
||||
// 写回之前:清理无效 config
|
||||
$validConfigs = [];
|
||||
foreach ($configs as $config) {
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
if ($templateId > 0) {
|
||||
$templateExists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
|
||||
if (!empty($templateExists)) {
|
||||
$validConfigs[] = $config;
|
||||
}
|
||||
}
|
||||
}
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
```
|
||||
|
||||
### P2-3 修复(建议)
|
||||
|
||||
`ticket_detail.html` 的 `submit` 函数中,对 `spec_base_id` 增加服务器端校验(非本文档范围,需在支付 API 入口添加)。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 风险等级 | 数量 | 说明 |
|
||||
|---------|------|------|
|
||||
| **P1** | 2 | 脏数据未拒绝,直接影响数据完整性和商品保存正确性 |
|
||||
| **P2** | 3 | 注入风险低(需管理员权限)、信息泄露、缺少校验 |
|
||||
| **低** | 0 | 无直接 XSS(后端输出有编码保护) |
|
||||
|
||||
**核心 P1 缺陷**:当 `template_id` 指向不存在的场馆时,系统**不拒绝保存**,而是静默保留旧的 `template_snapshot`,导致幽灵 spec 持续存在于数据库中。这是用户遇到「规格不允许重复」错误的根本原因(配置块未清理,残留的 `spec_base_id_map` 数据与新生成的 SKU 产生冲突)。
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
# FrontendDev 调研报告:幽灵 spec 问题
|
||||
|
||||
> 日期:2026-04-20 | Agent:council/FrontendDev
|
||||
|
||||
---
|
||||
|
||||
## 1. ticket_detail.html 的前端规格项构建
|
||||
|
||||
### 1.1 页面性质确认
|
||||
|
||||
`ticket_detail.html` 是**客户前端购票页面**(用于 C 端用户选座下单),**不是**后台商品编辑页面。后台编辑商品时出现的「规格不允许重复」错误发生在 ShopXO 标准后台,其触发点在 `GoodsService.php:1859/1889/1925`。
|
||||
|
||||
前端购票页面的数据来源:
|
||||
|
||||
| PHP 变量 | 来源(SeatSkuService) | 用途 |
|
||||
|----------|----------------------|------|
|
||||
| `$vr_seat_template` | `GetGoodsViewData()` | `seat_map`、`spec_base_id_map` |
|
||||
| `$goods_spec_data` | `GetGoodsViewData()` | 场次(session)列表 |
|
||||
|
||||
前端 JS 接收这些数据:
|
||||
|
||||
```
|
||||
ticket_detail.html:186-187
|
||||
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
|
||||
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
|
||||
```
|
||||
|
||||
前端规格项(场次)构建逻辑(`renderSessions()`, ticket_detail.html:202-213):
|
||||
|
||||
```javascript
|
||||
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
|
||||
// specData 格式: [{spec_id: 2001, spec_name: "08:00-23:59", price: 100}]
|
||||
// 渲染为可点击的场次卡片
|
||||
```
|
||||
|
||||
**结论**:`ticket_detail.html` 本身不构建 ShopXO 规格(spec)表格,其规格项仅为场次选择器。真正触发「规格不允许重复」的是 ShopXO 后台商品编辑页的 `GoodsService.php`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 模板不存在时前端对 template_snapshot 和 spec_base_id_map 的处理
|
||||
|
||||
### 2.1 后端 fallback 行为(SeatSkuService.php)
|
||||
|
||||
关键函数:`GetGoodsViewData()` (`SeatSkuService.php:358-464`)
|
||||
|
||||
**模板不存在时的 fallback(硬删除场景)**:
|
||||
|
||||
```php
|
||||
// SeatSkuService.php:383-393
|
||||
if (empty($seatTemplate)) {
|
||||
$config['template_id'] = null;
|
||||
$config['template_snapshot'] = null;
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode([$config], ...),
|
||||
]);
|
||||
return [
|
||||
'vr_seat_template' => null,
|
||||
'goods_spec_data' => [],
|
||||
'goods_config' => $config,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**执行效果**:
|
||||
1. `template_id` 被置为 `null`(写入 DB)
|
||||
2. `template_snapshot` 被置为 `null`(写入 DB)
|
||||
3. 返回给前端:`vr_seat_template = null`、`goods_spec_data = []`
|
||||
|
||||
**前端接收到的数据**:
|
||||
```javascript
|
||||
seatMap: {} // 空对象
|
||||
specBaseIdMap: {} // 空对象
|
||||
goods_spec_data: [] // 空数组
|
||||
```
|
||||
|
||||
**前端渲染结果**:
|
||||
- `renderSessions()`:`sessionGrid` 内为 `goods_spec_data.length === 0`,显示提示「该商品暂无场次信息」(ticket_detail.html:133)
|
||||
- `renderSeatMap()`:`seatMap.map` 为空,座位图区域显示「座位图加载失败」
|
||||
- 整个座位选择区域 UI 为空/失败状态
|
||||
|
||||
### 2.2 根因分析
|
||||
|
||||
**模板不存在时,前端的 fallback 行为是正确的**——前端展示空白购票页,用户无法选座。这符合"场馆已删除,无法购票"的业务预期。
|
||||
|
||||
真正的问题不在 `ticket_detail.html`(前端),而在:
|
||||
1. 后台商品编辑页(ShopXO admin)——保存时 `AdminGoodsSaveHandle` 如何处理 `template_id=null` 的情况
|
||||
2. `vr_goods_config` 的持久化清理——硬删除后 `vr_goods_config` 中的 config 块是否被正确清理
|
||||
|
||||
---
|
||||
|
||||
## 3. loadSoldSeats() 函数实现情况
|
||||
|
||||
**状态:未实现(仅有 TODO 注释)**
|
||||
|
||||
```
|
||||
ticket_detail.html:375-383
|
||||
loadSoldSeats: function() {
|
||||
// TODO: 从后端加载已售座位
|
||||
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||
// goods_id: this.goodsId,
|
||||
// spec_base_id: this.sessionSpecId
|
||||
// }, function(res) {
|
||||
// // 标记已售座位
|
||||
// });
|
||||
},
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- `soldSeats: {}` 永远为空对象(ticket_detail.html:189)
|
||||
- `renderSeatMap()` 渲染座位时,无法从 `soldSeats` 读取已售标记
|
||||
- 已售座位只能通过 `.sold` class(由 PHP 渲染)或 `soldSeats` 字典来标记,但两者都未生效
|
||||
- 结果:前端无法区分已售/可选座位——用户可能选中一个已售座位,提交后才发现无法购买
|
||||
|
||||
**严重程度**:P2(功能缺陷),不影响「规格不允许重复」错误。
|
||||
|
||||
---
|
||||
|
||||
## 4. 编辑模式下前端对已删除场馆旧规格的处理
|
||||
|
||||
### 4.1 当前行为
|
||||
|
||||
当商品的 `vr_goods_config` 中 `template_id` 指向的场馆已被硬删除:
|
||||
|
||||
1. `GetGoodsViewData()` 检测到模板不存在 → `template_id=null`、`template_snapshot=null` → 写入 DB
|
||||
2. 前端收到 `vr_seat_template=null`、`goods_spec_data=[]`
|
||||
3. `ticket_detail.html` 渲染空白购票页(无场次、无座位图)
|
||||
4. **前端没有特殊逻辑处理幽灵 spec**——因为后端已经清理了 `template_id` 和 `template_snapshot`
|
||||
|
||||
### 4.2 问题点
|
||||
|
||||
**`ticket_detail.html` 是前端购票页,不是编辑页**。商品编辑(后台)由 ShopXO 标准后台处理,VR 插件通过钩子介入。
|
||||
|
||||
幽灵 spec 的真正风险在于 `AdminGoodsSaveHandle` 的保存逻辑:
|
||||
|
||||
- `AdminGoodsSaveHandle.php:383-394`(硬删除 fallback):当模板不存在时,`continue` 跳过 snapshot 重建,**但 config 块本身未被移除**
|
||||
- 如果 `vr_goods_config` 包含多个 config 块(如多场馆商品),硬删除场馆后该 config 块残存
|
||||
- 下次编辑时,该 config 块仍被读取,若前端重新选择了场馆,可能导致 spec 重复
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端根因分析
|
||||
|
||||
### 5.1 「规格不允许重复」错误的真正触发点
|
||||
|
||||
该错误**不在 `ticket_detail.html`**,而在 ShopXO 后台商品编辑流程的 `GoodsService.php:1859/1889/1925`。
|
||||
|
||||
触发条件:
|
||||
1. 用户在 ShopXO 后台编辑商品时,手动填写/复制了重复的规格值
|
||||
2. 表单提交到 `GoodsService::GoodsSave()` → spec 验证逻辑检查 `specifications_value_*` 参数
|
||||
3. 发现有重复值 → 返回「规格不允许重复」错误
|
||||
|
||||
### 5.2 与 VR 插件的关联
|
||||
|
||||
当 `AdminGoodsSaveHandle` 运行时(`plugins_service_goods_save_thing_end`),它会:
|
||||
1. 清空 `GoodsSpecType`、`GoodsSpecBase`、`GoodsSpecValue`(AdminGoodsSaveHandle.php:152-155)
|
||||
2. 对 `template_id > 0` 的 config 块执行 `BatchGenerate`
|
||||
|
||||
如果 `template_id` 为 `null`(硬删除后),`BatchGenerate` 跳过,但 `vr_goods_config` 中的 config 块仍然残存。**此时商品 spec 表为空**,不会出现「规格不允许重复」错误。
|
||||
|
||||
但如果用户在前端(ShopXO 后台编辑页)操作时,ShopXO 的原生规格表单被填充了旧的 VR 规格数据,这些数据可能在保存时被 ShopXO 的原生规格逻辑验证并触发重复错误。
|
||||
|
||||
---
|
||||
|
||||
## 6. 修复方案
|
||||
|
||||
### 6.1 前端修复(ticket_detail.html)
|
||||
|
||||
**loadSoldSeats() 建议实现**:
|
||||
|
||||
```javascript
|
||||
loadSoldSeats: function() {
|
||||
if (!this.goodsId || !this.sessionSpecId) return;
|
||||
var self = this;
|
||||
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||
goods_id: this.goodsId,
|
||||
spec_base_id: this.sessionSpecId
|
||||
}, function(res) {
|
||||
if (res.code === 0 && res.data) {
|
||||
self.soldSeats = res.data; // {row_col: true, ...}
|
||||
self.markSoldSeats();
|
||||
}
|
||||
});
|
||||
},
|
||||
markSoldSeats: function() {
|
||||
var self = this;
|
||||
document.querySelectorAll('.vr-seat').forEach(function(el) {
|
||||
var key = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||
if (self.soldSeats[key]) {
|
||||
el.classList.add('sold');
|
||||
}
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 后端修复(建议 BackendArchitect 评估)
|
||||
|
||||
当模板被硬删除后,`AdminGoodsSaveHandle` 应清理整个 config 块:
|
||||
|
||||
```php
|
||||
// AdminGoodsSaveHandle.php:77-90 改进
|
||||
if (empty($template)) {
|
||||
// 模板不存在时,移除整个 config 块(避免残存)
|
||||
unset($configs[$i]);
|
||||
continue;
|
||||
}
|
||||
$configs = array_values($configs); // 重排索引
|
||||
```
|
||||
|
||||
或在 `SeatSkuService::GetGoodsViewData()` 中持久化清理:
|
||||
|
||||
```php
|
||||
// SeatSkuService.php:383-393 改进
|
||||
if (empty($seatTemplate)) {
|
||||
// 模板不存在时,清除整个 config 块,而非仅置 null
|
||||
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
|
||||
unset($vrGoodsConfig[0]);
|
||||
$newConfig = array_values($vrGoodsConfig);
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => empty($newConfig) ? '' : json_encode($newConfig, ...),
|
||||
]);
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 总结
|
||||
|
||||
| 问题 | 位置 | 严重度 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| loadSoldSeats() 未实现 | ticket_detail.html:375 | P2 | 已售座位无法标记 |
|
||||
| 模板不存在时 fallback 正确 | SeatSkuService.php:383 | — | 后端已正确清理 template_id |
|
||||
| 「规格不允许重复」不在前端触发 | GoodsService.php:1859 | — | 触发点在 ShopXO 后台服务层 |
|
||||
| config 块残留 | AdminGoodsSaveHandle.php | P2 | 硬删除后 config 块未移除 |
|
||||
| spec_base_id_map 不影响前端 | ticket_detail.html:417 | P3 | 前端通过 seatKey 查找,未使用 map |
|
||||
|
||||
---
|
||||
|
||||
## 8. 文件路径索引
|
||||
|
||||
| 文件 | 行号 | 关键内容 |
|
||||
|------|------|---------|
|
||||
| `SeatSkuService.php` | 358-464 | `GetGoodsViewData()`,模板不存在 fallback |
|
||||
| `SeatSkuService.php` | 383-394 | 模板不存在时置 null 并更新 DB |
|
||||
| `AdminGoodsSaveHandle.php` | 77-145 | config 块遍历和 snapshot 重建逻辑 |
|
||||
| `AdminGoodsSaveHandle.php` | 152-155 | 清空原生 spec 表 |
|
||||
| `AdminGoodsSaveHandle.php` | 158-173 | BatchGenerate 循环(跳过 template_id=0)|
|
||||
| `ticket_detail.html` | 186-189 | 前端 JS 接收 seatMap/specBaseIdMap |
|
||||
| `ticket_detail.html` | 202-213 | `renderSessions()` 场次渲染 |
|
||||
| `ticket_detail.html` | 375-383 | `loadSoldSeats()` TODO(未实现)|
|
||||
| `ticket_detail.html` | 417 | specBaseIdMap 查找(仅 Plan A 提交用)|
|
||||
| `GoodsService.php` | 1859 | 规格值列重复检测 |
|
||||
| `GoodsService.php` | 1889 | 规格值重复检测 |
|
||||
| `GoodsService.php` | 1925 | 规格名称重复检测 |
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# 幽灵 Spec 问题 — Council 调研汇总报告
|
||||
|
||||
> 日期:2026-04-20 | Agent:FrontendDev + BackendArchitect + SecurityEngineer
|
||||
> 版本:v2.1 | 基于 main 分支 `11fdf0309`
|
||||
> 基于 main 分支 `f84f95b56`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -68,25 +68,40 @@ loadSoldSeats: function() {
|
|||
|
||||
---
|
||||
|
||||
### 2.2 BackendArchitect — 后端调研(`reviews/council-ghost-spec-BackendArchitect.md`)
|
||||
### 2.2 BackendArchitect — 后端调研(`reviews/BackendArchitect-on-Issue-13-debug.md`)
|
||||
|
||||
#### 关键发现(逐行验证)
|
||||
#### 关键发现
|
||||
|
||||
**根因 1(Critical):无效 config 块未被移除,脏数据写回 DB**
|
||||
**Primary Bug — 99% 命中**
|
||||
|
||||
`AdminGoodsSaveHandle.php:83-90` — `continue` 跳过 snapshot 重建但不删除 config 块,第 148-150 行将脏 config 无条件写回 goods 表。
|
||||
| 文件 | 行号 | 问题代码 |
|
||||
|------|------|----------|
|
||||
| `AdminGoodsSaveHandle.php` | **77** | `return in_array($r['id'], $config['selected_rooms'] ?? []);` |
|
||||
|
||||
**根因 2(High):GetGoodsViewData 仅处理单模板模式,多模板时无效块不清理**
|
||||
当 `$r`(rooms 数组元素)缺少 `'id'` key 时,访问 `$r['id']` 直接抛出 `Undefined array key "id"`。
|
||||
|
||||
`SeatSkuService.php:368` — 只取 `$vrGoodsConfig[0]`,多模板场景下其余配置块被完全忽略;第 386-388 行写回 DB 时只写 `[$config]` 单元素。
|
||||
**对比:SeatSkuService::BatchGenerate:100 已有正确防护**
|
||||
```php
|
||||
// ✅ 安全写法
|
||||
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
|
||||
```
|
||||
而 `AdminGoodsSaveHandle:77` 没有这个防护。
|
||||
|
||||
**根因 3(Medium):BatchGenerate 对无效 template_id 返回 code=-2,阻断保存**
|
||||
**Secondary Bug — 模板不存在时 null 访问**
|
||||
|
||||
`AdminGoodsSaveHandle.php:164-170` — 无效 config 块的 `templateId` 仍为原值,BatchGenerate 内部检测到模板不存在后返回错误码,阻断整个保存流程。
|
||||
| 文件 | 行号 | 问题代码 |
|
||||
|------|------|----------|
|
||||
| `AdminGoodsSaveHandle.php` | **71** | `$seatMap = json_decode($template['seat_map'] ?? '{}', true);` |
|
||||
|
||||
**根因 4(Medium):前端过滤无法防御 DB 层污染**
|
||||
当 `find()` 返回 null 后,`$template['seat_map']` 在 PHP 8.0+ 抛出 `TypeError`。
|
||||
|
||||
`AdminGoodsSave.php:196-229` — 前端 JS 通过 `validTemplateIds.has(c.template_id)` 过滤无效块,但无法保证 DB 层 config 块被正确清理。
|
||||
**Tertiary Bug — 类型不匹配静默失败**
|
||||
|
||||
| 文件 | 行号 | 问题代码 |
|
||||
|------|------|----------|
|
||||
| `AdminGoodsSaveHandle.php` | **77** | `in_array($r['id'], ...)` 类型不一致 |
|
||||
|
||||
`selected_rooms[]` 从前端传来是字符串(如 `"room_0"`),而 `$r['id']` 可能是整数。类型不匹配时 `in_array()` 永远返回 `false`,静默导致 `selectedRoomIds` 为空数组。
|
||||
|
||||
#### 后端根因
|
||||
|
||||
|
|
@ -94,10 +109,24 @@ loadSoldSeats: function() {
|
|||
|
||||
#### 后端修复建议(已合并)
|
||||
|
||||
1. `AdminGoodsSaveHandle.php:88` — `continue` 改为 `unset($configs[$i])`,第 145 行后加 `$configs = array_values($configs);`
|
||||
2. `AdminGoodsSaveHandle.php:148-150` — 写回前加 `if (!empty($configs))`
|
||||
3. `SeatSkuService.php:368` — 遍历所有配置块而非只处理第一个
|
||||
4. `SeatSkuService.php:386-388` — 写回 validConfigs 而非 `[$config]`
|
||||
```php
|
||||
// AdminGoodsSaveHandle.php:83-90(已修复)
|
||||
if ($templateId > 0) {
|
||||
$template = Db::name('vr_seat_templates')->find($templateId);
|
||||
if (empty($template)) {
|
||||
continue; // ✅ 硬删除场景跳过
|
||||
}
|
||||
$seatMap = json_decode($template['seat_map'] ?? '{}', true);
|
||||
// ...
|
||||
}
|
||||
|
||||
// AdminGoodsSaveHandle.php:116-137(已修复)
|
||||
array_filter($allRooms, function ($r) use ($selectedRooms) {
|
||||
$rid = $r['id'] ?? ''; // ✅ P0 修复:空安全
|
||||
// 尝试直接匹配 + 前缀匹配 + 索引回退
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -106,7 +135,7 @@ loadSoldSeats: function() {
|
|||
#### 审计报告来源
|
||||
|
||||
- `reviews/SecurityEngineer-AUDIT.md` — `AdminGoodsSaveHandle.php` 根因分析 + 修复建议
|
||||
- `reviews/council-ghost-spec-BackendArchitect.md` — "幽灵 spec" 全链路根因分析(4 个根因)
|
||||
- `reviews/BackendArchitect-on-Issue-13-debug.md` — "Undefined array key 'id'" 根因分析
|
||||
|
||||
#### 审计结论(来源:SecurityEngineer-AUDIT.md)
|
||||
|
||||
|
|
@ -186,7 +215,7 @@ f1173e3c8 docs: 补充硬删除修复记录 + Issue #13 关闭说明
|
|||
| 报告 | 路径 |
|
||||
|------|------|
|
||||
| FrontendDev 前端调研 | `reviews/council-ghost-spec-FrontendDev.md` |
|
||||
| BackendArchitect 后端调研 | `reviews/council-ghost-spec-BackendArchitect.md` |
|
||||
| BackendArchitect 后端调研 | `reviews/BackendArchitect-on-Issue-13-debug.md` |
|
||||
| SecurityEngineer 安全审计 | `reviews/SecurityEngineer-AUDIT.md` |
|
||||
| BackendArchitect 幽灵 spec 调研 | `reviews/council-ghost-spec-BackendArchitect.md` |
|
||||
| BackendArchitect Round 5 Review | `reviews/BackendArchitect-on-FrontendDev-P1.md` |
|
||||
| 本汇总报告 | `reviews/council-ghost-spec-summary.md` |
|
||||
|
|
@ -13,12 +13,6 @@
|
|||
return array (
|
||||
'listen' =>
|
||||
array (
|
||||
'plugins_css' =>
|
||||
array (
|
||||
),
|
||||
'plugins_js' =>
|
||||
array (
|
||||
),
|
||||
'plugins_service_admin_menu_data' =>
|
||||
array (
|
||||
0 => 'app\\plugins\\vr_ticket\\Hook',
|
||||
|
|
@ -43,6 +37,10 @@ return array (
|
|||
array (
|
||||
0 => 'app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle',
|
||||
),
|
||||
'plugins_css_data' =>
|
||||
array (
|
||||
0 => 'app\\plugins\\vr_ticket\\hook\\ViewGoodsCss',
|
||||
),
|
||||
),
|
||||
);
|
||||
?>
|
||||
|
|
@ -142,6 +142,8 @@ class Goods extends Common
|
|||
MyViewAssign([
|
||||
'vr_seat_template' => $viewData['vr_seat_template'] ?? null,
|
||||
'goods_spec_data' => $viewData['goods_spec_data'] ?? [],
|
||||
'seatSpecMap' => $viewData['seatSpecMap'] ?? [],
|
||||
'specTypeList' => $viewData['specTypeList'] ?? [],
|
||||
]);
|
||||
// 使用绝对路径 + fetch() 方法,让 Think 驱动正确解析 include 路径
|
||||
$tplFile = ROOT . 'app' . DS . 'plugins' . DS . 'vr_ticket' . DS . 'view' . DS . 'goods' . DS . 'ticket_detail.html';
|
||||
|
|
|
|||
|
|
@ -1043,6 +1043,33 @@ class Admin extends Common
|
|||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场次已售座位列表(JSON API)
|
||||
* URL: /plugins/vr_ticket/admin/soldSeats
|
||||
* GET 参数: goods_id, spec_base_id
|
||||
* 返回: {code:0, data:{sold_seats:['A_1','A_2','B_5']}}
|
||||
*/
|
||||
public function SoldSeats()
|
||||
{
|
||||
// 鉴权(移动端登录)
|
||||
if (empty($_SESSION['user']['id'])) {
|
||||
return json_encode(['code' => 401, 'msg' => '请先登录']);
|
||||
}
|
||||
|
||||
// 获取参数
|
||||
$goodsId = input('goods_id', 0, 'intval');
|
||||
$specBaseId = input('spec_base_id', 0, 'intval');
|
||||
|
||||
if (empty($goodsId) || empty($specBaseId)) {
|
||||
return json_encode(['code' => 400, 'msg' => '参数错误']);
|
||||
}
|
||||
|
||||
// TODO: 从已支付订单的 vr_tickets 表查询真实已售座位
|
||||
// 第一版返回空数组,后续迭代接入真实数据
|
||||
$soldSeats = [];
|
||||
return json_encode(['code' => 0, 'data' => ['sold_seats' => $soldSeats]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计座位数(支持 v3 多房间格式 或 v2 格式)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@
|
|||
],
|
||||
"plugins_service_goods_save_thing_end": [
|
||||
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"
|
||||
],
|
||||
"plugins_css_data": [
|
||||
"app\\plugins\\vr_ticket\\hook\\ViewGoodsCss"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
namespace app\plugins\vr_ticket\hook;
|
||||
|
||||
/**
|
||||
* 票务商品详情页 CSS 注入
|
||||
* 注册到 plugins_css_data 钩子
|
||||
*/
|
||||
class ViewGoodsCss
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
return 'plugins/vr_ticket/css/ticket.css';
|
||||
}
|
||||
}
|
||||
|
|
@ -182,11 +182,12 @@ class BaseService
|
|||
'upd_time' => $now,
|
||||
]);
|
||||
|
||||
// 3. 定义 $vr- 规格类型(name => JSON value)
|
||||
// 3. 定义 $vr- 规格类型(5维:场次、场馆、演播室、分区、座位号)
|
||||
$specTypes = [
|
||||
'$vr-场次' => '[{"name":"待选场次","images":""}]',
|
||||
'$vr-场馆' => '[{"name":"' . ($goods['venue_data'] ?? '国家体育馆') . '","images":""}]',
|
||||
'$vr-演播室' => '[{"name":"主厅","images":""}]',
|
||||
'$vr-分区' => '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]',
|
||||
'$vr-时段' => '[{"name":"待选场次","images":""}]',
|
||||
'$vr-座位号' => '[{"name":"待选座位","images":""}]',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ class SeatSkuService extends BaseService
|
|||
const BATCH_SIZE = 200;
|
||||
|
||||
/**
|
||||
* VR 规格维度名(顺序固定)
|
||||
* VR 规格维度名(顺序固定,5维)
|
||||
* 注意:按选购流程顺序排列:场次 → 场馆 → 演播室 → 分区 → 座位号
|
||||
*/
|
||||
const SPEC_DIMS = ['$vr-场馆', '$vr-分区', '$vr-座位号', '$vr-场次'];
|
||||
const SPEC_DIMS = ['$vr-场次', '$vr-场馆', '$vr-演播室', '$vr-分区', '$vr-座位号'];
|
||||
|
||||
/**
|
||||
* 批量生成座位级 SKU
|
||||
|
|
@ -87,10 +88,11 @@ class SeatSkuService extends BaseService
|
|||
|
||||
// 按维度收集唯一值(用 有序列表 + 去重)
|
||||
$dimUniqueValues = [
|
||||
'$vr-场次' => [],
|
||||
'$vr-场馆' => [],
|
||||
'$vr-演播室' => [],
|
||||
'$vr-分区' => [],
|
||||
'$vr-座位号' => [],
|
||||
'$vr-场次' => [],
|
||||
];
|
||||
|
||||
// 5. 遍历地图,收集所有座位信息
|
||||
|
|
@ -152,31 +154,38 @@ class SeatSkuService extends BaseService
|
|||
$val_seat = $venueName . '-' . $roomName . '-' . $char . '-' . $rowLabel . $col;
|
||||
|
||||
foreach ($sessionStrings as $sessionStr) {
|
||||
$seatId = $roomId . '_' . $rowLabel . '_' . $col . '_' . md5($sessionStr);
|
||||
$seatKey = $roomId . '_' . $rowLabel . '_' . $col;
|
||||
$seatId = $seatKey . '_' . md5($sessionStr);
|
||||
|
||||
$seatsToInsert[$seatId] = [
|
||||
'price' => $seatPrice,
|
||||
'seat_key' => $seatKey, // ← 用于前端映射
|
||||
'extends' => json_encode(['seat_key' => $seatKey], JSON_UNESCAPED_UNICODE),
|
||||
'spec_values' => [
|
||||
$val_venue,
|
||||
$val_section,
|
||||
$val_seat,
|
||||
$sessionStr,
|
||||
$sessionStr, // $vr-场次(第1维)
|
||||
$val_venue, // $vr-场馆(第2维)
|
||||
$roomName, // $vr-演播室(第3维)
|
||||
$val_section, // $vr-分区(第4维)
|
||||
$val_seat, // $vr-座位号(第5维)
|
||||
],
|
||||
];
|
||||
|
||||
// 收集唯一维度值(保持首次出现顺序)
|
||||
// 收集唯一维度值(保持首次出现顺序,与 SPEC_DIMS 对应)
|
||||
if (!in_array($sessionStr, $dimUniqueValues['$vr-场次'])) {
|
||||
$dimUniqueValues['$vr-场次'][] = $sessionStr;
|
||||
}
|
||||
if (!in_array($val_venue, $dimUniqueValues['$vr-场馆'])) {
|
||||
$dimUniqueValues['$vr-场馆'][] = $val_venue;
|
||||
}
|
||||
if (!in_array($roomName, $dimUniqueValues['$vr-演播室'])) {
|
||||
$dimUniqueValues['$vr-演播室'][] = $roomName;
|
||||
}
|
||||
if (!in_array($val_section, $dimUniqueValues['$vr-分区'])) {
|
||||
$dimUniqueValues['$vr-分区'][] = $val_section;
|
||||
}
|
||||
if (!in_array($val_seat, $dimUniqueValues['$vr-座位号'])) {
|
||||
$dimUniqueValues['$vr-座位号'][] = $val_seat;
|
||||
}
|
||||
if (!in_array($sessionStr, $dimUniqueValues['$vr-场次'])) {
|
||||
$dimUniqueValues['$vr-场次'][] = $sessionStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -208,13 +217,15 @@ class SeatSkuService extends BaseService
|
|||
'coding' => '',
|
||||
'barcode' => '',
|
||||
'add_time' => $now,
|
||||
'extends' => $s['extends'] ?? null,
|
||||
]);
|
||||
|
||||
if (!$baseId) {
|
||||
throw new \Exception("GoodsSpecBase 写入失败 (seat: {$seatId})");
|
||||
}
|
||||
|
||||
// 4 条 GoodsSpecValue,每条对应一个维度
|
||||
// 5 条 GoodsSpecValue,每条对应一个维度(按 SPEC_DIMS 顺序)
|
||||
// 注意:GoodsSpecValue 表没有 name 字段,只能通过 value 匹配关联维度
|
||||
foreach ($s['spec_values'] as $specVal) {
|
||||
$valueBatch[] = [
|
||||
'goods_id' => $goodsId,
|
||||
|
|
@ -353,7 +364,7 @@ class SeatSkuService extends BaseService
|
|||
* 获取商品前端展示数据(供 ticket_detail.html 模板使用)
|
||||
*
|
||||
* @param int $goodsId
|
||||
* @return array ['vr_seat_template' => [...], 'goods_spec_data' => [...]]
|
||||
* @return array ['vr_seat_template' => [...], 'goods_spec_data' => [], 'seatSpecMap' => [...]]
|
||||
*/
|
||||
public static function GetGoodsViewData(int $goodsId): array
|
||||
{
|
||||
|
|
@ -362,7 +373,7 @@ class SeatSkuService extends BaseService
|
|||
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
|
||||
|
||||
if (empty($vrGoodsConfig) || !is_array($vrGoodsConfig)) {
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||
}
|
||||
|
||||
// 过滤有效配置块(多模板模式)
|
||||
|
|
@ -376,14 +387,14 @@ class SeatSkuService extends BaseService
|
|||
}
|
||||
}
|
||||
if (empty($validConfigs)) {
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||
}
|
||||
|
||||
// 取第一个有效配置块用于前端展示
|
||||
$config = $validConfigs[0];
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
if ($templateId <= 0) {
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||
}
|
||||
|
||||
// 读取座位模板(包含 seat_map 和 spec_base_id_map)
|
||||
|
|
@ -407,7 +418,7 @@ class SeatSkuService extends BaseService
|
|||
} else {
|
||||
\think\facade\Db::name('Goods')->where('id', $goodsId)->update(['vr_goods_config' => '']);
|
||||
}
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||
}
|
||||
|
||||
// 解码 seat_map JSON(存储时是 JSON 字符串)
|
||||
|
|
@ -418,15 +429,36 @@ class SeatSkuService extends BaseService
|
|||
}
|
||||
}
|
||||
|
||||
// 解码 spec_base_id_map JSON
|
||||
if (!empty($seatTemplate['spec_base_id_map'])) {
|
||||
$decoded = json_decode($seatTemplate['spec_base_id_map'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$seatTemplate['spec_base_id_map'] = $decoded;
|
||||
// ========== 构建规格类型列表(4维:场馆、分区、座位号、场次)==========
|
||||
// 从 GoodsSpecType 读取所有维度定义
|
||||
$specTypeList = [];
|
||||
$specTypes = \think\facade\Db::name('GoodsSpecType')
|
||||
->where('goods_id', $goodsId)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($specTypes as $type) {
|
||||
$dimName = $type['name'] ?? '';
|
||||
$values = json_decode($type['value'] ?? '[]', true);
|
||||
$options = [];
|
||||
foreach ($values as $v) {
|
||||
if (isset($v['name'])) {
|
||||
$options[] = $v['name'];
|
||||
}
|
||||
}
|
||||
if (!empty($dimName) && !empty($options)) {
|
||||
$specTypeList[$dimName] = [
|
||||
'name' => $dimName,
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 构建场次列表(goods_spec_data)
|
||||
// ========== 构建 seatSpecMap ==========
|
||||
$seatSpecMap = self::buildSeatSpecMap($goodsId, $seatTemplate);
|
||||
|
||||
// ========== 构建场次列表(goods_spec_data)==========
|
||||
$sessions = $config['sessions'] ?? [];
|
||||
$goodsSpecData = [];
|
||||
|
||||
|
|
@ -435,48 +467,244 @@ class SeatSkuService extends BaseService
|
|||
$end = $session['end'] ?? '';
|
||||
$timeRange = $start && $end ? "{$start}-{$end}" : ($start ?: $end);
|
||||
|
||||
// 查找该场次对应的 spec_base_id
|
||||
$specValue = \think\facade\Db::name('goods_spec_value')
|
||||
->alias('sv')
|
||||
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
|
||||
->where('sv.goods_id', $goodsId)
|
||||
->where('sv.value', $timeRange)
|
||||
->where('sb.price', '>', 0)
|
||||
->find();
|
||||
// 查找该场次对应的最低价格(从 seatSpecMap 中获取)
|
||||
$sessionPrice = null;
|
||||
foreach ($seatSpecMap as $seatKey => $info) {
|
||||
foreach ($info['spec'] as $specItem) {
|
||||
$specType = $specItem['type'] ?? '';
|
||||
$specValue = $specItem['value'] ?? '';
|
||||
if ($specType === '$vr-场次' && $specValue === $timeRange) {
|
||||
if ($sessionPrice === null || $info['price'] < $sessionPrice) {
|
||||
$sessionPrice = $info['price'];
|
||||
}
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$goodsSpecData[] = [
|
||||
'spec_id' => $specValue['goods_spec_base_id'] ?? 0,
|
||||
'spec_id' => 0,
|
||||
'spec_name' => $timeRange,
|
||||
'price' => $specValue['price'] ?? floatval($goods['price'] ?? 0),
|
||||
'price' => $sessionPrice ?? floatval($goods['price'] ?? 0),
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
// 如果没有从配置读取到场次,尝试从数据库直接读取场次类规格值
|
||||
// 如果没有从配置读取到场次,尝试从 seatSpecMap 提取唯一场次
|
||||
if (empty($goodsSpecData)) {
|
||||
$sessionValues = \think\facade\Db::name('goods_spec_value')
|
||||
->alias('sv')
|
||||
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
|
||||
->field('sv.goods_spec_base_id as spec_id, sv.value as spec_name, sb.price')
|
||||
->where('sv.goods_id', $goodsId)
|
||||
->where('sb.price', '>', 0)
|
||||
->order('sb.id asc')
|
||||
->select()->toArray();
|
||||
|
||||
foreach ($sessionValues as $sv) {
|
||||
if (preg_match('/^\d{2}:\d{2}-\d{2}:\d{2}$/', $sv['spec_name'])) {
|
||||
$goodsSpecData[] = [
|
||||
'spec_id' => $sv['spec_id'],
|
||||
'spec_name' => $sv['spec_name'],
|
||||
'price' => floatval($sv['price']),
|
||||
];
|
||||
$sessionMap = [];
|
||||
foreach ($seatSpecMap as $info) {
|
||||
foreach ($info['spec'] as $specItem) {
|
||||
$specType = $specItem['type'] ?? '';
|
||||
if ($specType === '$vr-场次') {
|
||||
$sessionMap[$specItem['value'] ?? ''] = $specItem['value'] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($sessionMap as $timeRange) {
|
||||
$goodsSpecData[] = [
|
||||
'spec_id' => 0,
|
||||
'spec_name' => $timeRange,
|
||||
'price' => floatval($goods['price'] ?? 0),
|
||||
'start' => '',
|
||||
'end' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'vr_seat_template' => $seatTemplate ?: null,
|
||||
'goods_spec_data' => $goodsSpecData,
|
||||
'seatSpecMap' => $seatSpecMap,
|
||||
'specTypeList' => $specTypeList, // 4维规格类型列表
|
||||
'goods_config' => $config,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建座位规格映射表(seatSpecMap)
|
||||
*
|
||||
* @param int $goodsId
|
||||
* @param array $seatTemplate
|
||||
* @return array seatSpecMap
|
||||
*/
|
||||
private static function buildSeatSpecMap(int $goodsId, array $seatTemplate): array
|
||||
{
|
||||
$seatSpecMap = [];
|
||||
|
||||
// 1. 查询当前商品所有 GoodsSpecBase(含 extends.seat_key)
|
||||
$specs = \think\facade\Db::name('GoodsSpecBase')
|
||||
->where('goods_id', $goodsId)
|
||||
->where('inventory', '>', 0)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($specs)) {
|
||||
return $seatSpecMap;
|
||||
}
|
||||
|
||||
// 2. 查询 GoodsSpecType 获取维度映射(name => index)
|
||||
$specTypes = \think\facade\Db::name('GoodsSpecType')
|
||||
->where('goods_id', $goodsId)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 构建 name => index 映射
|
||||
$dimIndexByName = [];
|
||||
$dimValuesByName = []; // name => [value1, value2, ...]
|
||||
foreach ($specTypes as $idx => $type) {
|
||||
$dimName = $type['name'] ?? '';
|
||||
if (!empty($dimName)) {
|
||||
$dimIndexByName[$dimName] = $idx;
|
||||
// 解析 value JSON 数组
|
||||
$values = json_decode($type['value'] ?? '[]', true);
|
||||
$dimValuesByName[$dimName] = [];
|
||||
foreach ($values as $v) {
|
||||
if (isset($v['name'])) {
|
||||
$dimValuesByName[$dimName][] = $v['name'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查询每个 spec_base_id 对应的 GoodsSpecValue
|
||||
$specBaseIds = array_column($specs, 'id');
|
||||
$specValues = \think\facade\Db::name('GoodsSpecValue')
|
||||
->whereIn('goods_spec_base_id', $specBaseIds)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 4. 按 spec_base_id 分组,直接使用 GoodsSpecValue.name 字段确定维度名(更可靠)
|
||||
$specByBaseId = [];
|
||||
foreach ($specValues as $sv) {
|
||||
$baseId = $sv['goods_spec_base_id'];
|
||||
$value = $sv['value'] ?? '';
|
||||
|
||||
// 通过值匹配找到对应的维度名(依赖 GoodsSpecType.value JSON 中的 name)
|
||||
$dimName = '';
|
||||
foreach ($dimValuesByName as $name => $values) {
|
||||
if (in_array($value, $values)) {
|
||||
$dimName = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($specByBaseId[$baseId])) {
|
||||
$specByBaseId[$baseId] = [];
|
||||
}
|
||||
$specByBaseId[$baseId][] = [
|
||||
'type' => $dimName,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
// 4. 解析座位模板中的 room 信息(用于提取 rowLabel, colNum 等)
|
||||
$rooms = $seatTemplate['seat_map']['rooms'] ?? [];
|
||||
$roomSeatInfo = []; // roomId => [rowLabel_colNum => ['rowLabel' => 'A', 'colNum' => 3, 'section' => [...]]]
|
||||
foreach ($rooms as $rIdx => $room) {
|
||||
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
|
||||
$sections = $room['sections'] ?? [];
|
||||
|
||||
$map = $room['map'] ?? [];
|
||||
$seatsData = $room['seats'] ?? [];
|
||||
|
||||
foreach ($map as $rowIndex => $rowStr) {
|
||||
$rowLabel = chr(65 + $rowIndex);
|
||||
$chars = mb_str_split($rowStr);
|
||||
|
||||
foreach ($chars as $colIndex => $char) {
|
||||
if ($char === '_' || $char === '-' || !isset($seatsData[$char])) {
|
||||
continue;
|
||||
}
|
||||
$colNum = $colIndex + 1;
|
||||
|
||||
// 查找分区信息
|
||||
$sectionInfo = null;
|
||||
foreach ($sections as $sec) {
|
||||
if (($sec['char'] ?? '') === $char) {
|
||||
$sectionInfo = $sec;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] = [
|
||||
'rowLabel' => $rowLabel,
|
||||
'colNum' => $colNum,
|
||||
'section' => $sectionInfo,
|
||||
'char' => $char,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 构建 seatSpecMap:seat_key → 完整规格
|
||||
foreach ($specs as $spec) {
|
||||
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||
$seatKey = $extends['seat_key'] ?? '';
|
||||
if (empty($seatKey)) continue;
|
||||
|
||||
// 解析 seatKey 格式:roomId_rowLabel_colNum
|
||||
$parts = explode('_', $seatKey);
|
||||
if (count($parts) < 3) continue;
|
||||
$roomId = $parts[0];
|
||||
$rowLabel = $parts[1];
|
||||
$colNum = intval($parts[2]);
|
||||
|
||||
// 提取各维度值
|
||||
$venueName = '';
|
||||
$sectionName = '';
|
||||
$seatName = '';
|
||||
$sessionName = '';
|
||||
$roomName = ''; // ← 演播室(第3维)
|
||||
foreach ($specByBaseId[$spec['id']] ?? [] as $specItem) {
|
||||
$specType = $specItem['type'] ?? '';
|
||||
$specVal = $specItem['value'] ?? '';
|
||||
switch ($specType) {
|
||||
case '$vr-场次':
|
||||
$sessionName = $specVal;
|
||||
break;
|
||||
case '$vr-场馆':
|
||||
$venueName = $specVal;
|
||||
break;
|
||||
case '$vr-演播室':
|
||||
$roomName = $specVal;
|
||||
break;
|
||||
case '$vr-分区':
|
||||
$sectionName = $specVal;
|
||||
break;
|
||||
case '$vr-座位号':
|
||||
$seatName = $specVal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取座位元信息
|
||||
$seatMeta = $roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] ?? [
|
||||
'rowLabel' => $rowLabel,
|
||||
'colNum' => $colNum,
|
||||
'section' => null,
|
||||
'char' => '',
|
||||
];
|
||||
|
||||
$seatSpecMap[$seatKey] = [
|
||||
'spec_base_id' => intval($spec['id']),
|
||||
'price' => floatval($spec['price']),
|
||||
'inventory' => intval($spec['inventory']),
|
||||
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||
'rowLabel' => $seatMeta['rowLabel'],
|
||||
'colNum' => $seatMeta['colNum'],
|
||||
'roomId' => $roomId,
|
||||
'roomName' => $roomName, // ← 演播室名(第3维)
|
||||
'section' => $seatMeta['section'],
|
||||
'venueName' => $venueName,
|
||||
'sectionName' => $sectionName,
|
||||
'seatName' => $seatName,
|
||||
'sessionName' => $sessionName,
|
||||
];
|
||||
}
|
||||
|
||||
return $seatSpecMap;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
/* VR票务 - 票务商品详情页样式 */
|
||||
/* 从 ticket_detail.html 内联样式抽取,2026-04-21 */
|
||||
|
||||
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.vr-ticket-header { margin-bottom: 20px; }
|
||||
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
||||
.vr-event-subtitle { color: #666; font-size: 14px; }
|
||||
|
||||
.vr-seat-section { margin-bottom: 30px; }
|
||||
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||
|
||||
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
||||
.vr-stage {
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
||||
padding: 15px 40px;
|
||||
margin: 0 auto 25px;
|
||||
max-width: 600px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
||||
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
||||
|
||||
.vr-seat {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
color: #fff;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
||||
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
||||
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
||||
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
||||
.vr-seat.space { background: transparent !important; cursor: default; }
|
||||
|
||||
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
||||
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
||||
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
||||
|
||||
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
||||
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
||||
.vr-selected-item {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #e8f4ff; border: 1px solid #b8d4f0;
|
||||
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
||||
}
|
||||
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
||||
|
||||
.vr-sessions { margin-bottom: 20px; }
|
||||
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||
.vr-session-item {
|
||||
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
||||
cursor: pointer; text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vr-session-item:hover { border-color: #409eff; }
|
||||
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
||||
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
||||
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
||||
|
||||
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
||||
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
||||
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||
|
||||
.vr-purchase-bar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: #fff; border-top: 1px solid #e8e8e8;
|
||||
padding: 12px 20px; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
.vr-purchase-info { font-size: 14px; color: #666; }
|
||||
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
||||
.vr-purchase-btn {
|
||||
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
||||
color: #fff; border: none; border-radius: 20px;
|
||||
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
||||
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||
|
||||
/* 规格选择器样式 */
|
||||
.vr-spec-selector { margin-bottom: 15px; }
|
||||
.vr-spec-label { font-size: 14px; font-weight: bold; color: #333; margin-bottom: 10px; }
|
||||
.vr-spec-options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.vr-spec-option {
|
||||
border: 1px solid #ddd; border-radius: 6px; padding: 6px 12px;
|
||||
cursor: pointer; font-size: 12px; color: #333;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.vr-spec-option:hover { border-color: #409eff; }
|
||||
.vr-spec-option.selected { border-color: #409eff; background: #ecf5ff; color: #409eff; }
|
||||
.vr-spec-option:hover { background: #f5f7fa; }
|
||||
|
||||
|
|
@ -12,32 +12,40 @@ $security_desc = $shopxo_config['security_desc'] ?? '';
|
|||
</div><!-- end .vr-ticket-page -->
|
||||
|
||||
<style>
|
||||
.vr-footer {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
margin-top: 80px; /* 避开固定底部购买栏 */
|
||||
}
|
||||
.vr-footer-links {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.vr-footer-links a {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin: 0 12px;
|
||||
}
|
||||
.vr-footer-links a:hover { color: #409eff; }
|
||||
.vr-footer-copy {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vr-footer-icp {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
}
|
||||
.vr-footer {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
margin-top: 80px;
|
||||
/* 避开固定底部购买栏 */
|
||||
}
|
||||
|
||||
.vr-footer-links {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vr-footer-links a {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.vr-footer-links a:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.vr-footer-copy {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vr-footer-icp {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="vr-footer">
|
||||
|
|
@ -45,13 +53,25 @@ $security_desc = $shopxo_config['security_desc'] ?? '';
|
|||
<a href="<?php echo Config('shopxo.host_url'); ?>">返回首页</a>
|
||||
</div>
|
||||
<div class="vr-footer-copy">
|
||||
© <?php echo date('Y'); ?> <?php echo htmlspecialchars($shop_name, ENT_QUOTES, 'UTF-8'); ?> All Rights Reserved.
|
||||
©
|
||||
<?php echo date('Y'); ?>
|
||||
<?php echo htmlspecialchars($shop_name, ENT_QUOTES, 'UTF-8'); ?> All Rights Reserved.
|
||||
</div>
|
||||
<?php if (!empty($icp)): ?>
|
||||
<div class="vr-footer-icp">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" style="color:#bbb;text-decoration:none"><?php echo htmlspecialchars($icp, ENT_QUOTES, 'UTF-8'); ?></a>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" style="color:#bbb;text-decoration:none">
|
||||
<?php echo htmlspecialchars($icp, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php echo Config('shopxo.is_close_website_footer_js') != 1 ? '<script src="' . Config('shopxo.host_url') . 'static/common/js/footer.js?v=' . $shopxo_config['version'] . '"></script>' : ''; ?>
|
||||
<?php echo Config('shopxo.is_close_website_footer_js') != 1 ? '<script src="' . Config('shopxo.host_url') . 'static/common/js/footer.js?v=' . ($shopxo_config['version'] ?? '1.0.0') . '"></script>' : ''; ?>
|
||||
<script type='text/javascript'
|
||||
src="<?php echo Config('shopxo.host_url'); ?>static/common/lib/jquery/jquery-2.2.4.min.js"></script>
|
||||
<!-- ⚠️ CryptoJS 定义文件,必须先于 common.js 引入 -->
|
||||
<script type='text/javascript'
|
||||
src="<?php echo Config('shopxo.host_url'); ?>static/common/lib/base64/base64csvtojson.js"></script>
|
||||
<!-- ⚠️ 引入 common.js -->
|
||||
<script type='text/javascript'
|
||||
src="<?php echo Config('shopxo.host_url'); ?>static/common/js/common.js"></script>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,122 @@
|
|||
/* VR票务 - 票务商品详情页样式 */
|
||||
/* 从 ticket_detail.html 内联样式抽取,2026-04-21 */
|
||||
|
||||
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.vr-ticket-header { margin-bottom: 20px; }
|
||||
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
||||
.vr-event-subtitle { color: #666; font-size: 14px; }
|
||||
|
||||
.vr-seat-section { margin-bottom: 30px; }
|
||||
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||
|
||||
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
||||
.vr-stage {
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
||||
padding: 15px 40px;
|
||||
margin: 0 auto 25px;
|
||||
max-width: 600px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
||||
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
||||
|
||||
.vr-seat {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
color: #fff;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
||||
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
||||
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
||||
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
||||
.vr-seat.space { background: transparent !important; cursor: default; }
|
||||
|
||||
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
||||
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
||||
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
||||
|
||||
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
||||
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
||||
.vr-selected-item {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #e8f4ff; border: 1px solid #b8d4f0;
|
||||
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
||||
}
|
||||
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
||||
|
||||
.vr-sessions { margin-bottom: 20px; }
|
||||
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||
.vr-session-item {
|
||||
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
||||
cursor: pointer; text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vr-session-item:hover { border-color: #409eff; }
|
||||
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
||||
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
||||
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
||||
|
||||
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
||||
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
||||
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||
|
||||
.vr-purchase-bar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: #fff; border-top: 1px solid #e8e8e8;
|
||||
padding: 12px 20px; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
.vr-purchase-info { font-size: 14px; color: #666; }
|
||||
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
||||
.vr-purchase-btn {
|
||||
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
||||
color: #fff; border: none; border-radius: 20px;
|
||||
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
||||
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||
|
||||
/* 规格选择器样式 */
|
||||
.vr-spec-selector { margin-bottom: 15px; }
|
||||
.vr-spec-label { font-size: 14px; font-weight: bold; color: #333; margin-bottom: 10px; }
|
||||
.vr-spec-options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.vr-spec-option {
|
||||
border: 1px solid #ddd; border-radius: 6px; padding: 6px 12px;
|
||||
cursor: pointer; font-size: 12px; color: #333;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.vr-spec-option:hover { border-color: #409eff; }
|
||||
.vr-spec-option.selected { border-color: #409eff; background: #ecf5ff; color: #409eff; }
|
||||
.vr-spec-option:hover { background: #f5f7fa; }
|
||||
|
||||
Loading…
Reference in New Issue