Compare commits

..

41 Commits

Author SHA1 Message Date
sileya-ai 57cc10f8c5 Phase 2 完成:spec渲染+多座位选择+submit修复 → 合并入主线 (#19) 2026-04-22 09:59:39 +00:00
Council de7c25c6b9 docs: Phase 3 P0 - 5维Spec重构文档(演播室层补全) 2026-04-22 01:36:39 +08:00
Council 8c38484c58 Merge origin/main into fix/venue-hard-delete-p0 (resolve plan.md + reviews conflict with origin/main) 2026-04-22 01:06:11 +08:00
Council 6688a10d95 fix: submit 改为 AJAX POST 到 buy/add,base64 编码 goods_data,修复非法访问错误
- ticket_detail.html: form.submit() → jQuery AJAX POST
  - 改为 POST 到 ?s=buy/add(直接走 OrderInsert)
  - goods_data 用 CryptoJS.base64 编码(ShopXO BuyGoods 期望格式)
  - 显式传 buy_type=goods, address_id=0, site_model=2
  - 成功跳转 jump_url,code=-400 走登录页,失败 alert 提示
- footer.html: 追加 base64csvtojson.js + common.js(修正 JS 加载顺序)

⚠️ CHECKPOINT 已解决:submit() 报"非法访问"问题已修复
⚠️ 注意:base64csvtojson.js 由 ShopXO Docker 容器生成,需同步到 public/static/
⚠️ Phase 3 待完成:订单确认页/支付/观演人信息存储
2026-04-22 01:02:57 +08:00
Council a97e5fd0d3 docs: 添加 AntiGravity 会话报告 SESSION_REPORT_20260421_PHASE2_FIX.md
记录 AntiGravity 在 2026-04-21 的完整工作:
- 移除 GoodsSpecValue.type 字段,改为通过值匹配确定维度
- buildSeatSpecMap() 按值匹配重建 seatSpecMap
- GetGoodsViewData() 返回 specTypeList(4维规格类型列表)
- 前端新增场馆/分区选择器 + filterSeats() 联动过滤
- CSS 同步到 public/plugins/ 目录

⚠️ CHECKPOINT - 存在已知问题:
- submit() POST 到 Buy::Index 报'非法访问'(疑似登录/CSRF 拦截)
- 扩展字段(观演人信息)存储方案待确认
- Phase 3 前需修复并合并回 main
2026-04-21 14:25:31 +08:00
Council f6f02a0c79 fix: CSS 文件路径 - 同步到 public/plugins/ 目录 2026-04-21 13:12:27 +08:00
Council fdd89fbb70 fix: 优化规格选择器样式 - 处理长名称显示和添加 tooltip 2026-04-21 13:09:48 +08:00
Council dce3c45b23 fix: 添加缺失的 buildSeatSpecMap() 调用 2026-04-21 13:04:54 +08:00
Council de9134773f feat: 添加场馆和分区选择器 + specTypeList 支持
- SeatSkuService: 返回 specTypeList 包含所有4维规格
- Goods.php: 注入 specTypeList
- ticket_detail.html:
  - 添加 venueSelector 和 sectionSelector HTML 容器
  - 添加 renderAllSelectors() 渲染场次/场馆/分区
  - 添加 selectVenue/selectSection/filterSeats 函数
- CSS: 添加规格选择器样式
2026-04-21 13:02:38 +08:00
Council fc07c2ece6 chore: 删除临时脚本 2026-04-21 12:54:42 +08:00
Council c4a35ca258 chore: 删除不再需要的 SQL 修复文件 2026-04-21 12:54:37 +08:00
Council 8ea0c1a229 fix: GetGoodsViewData 使用 GoodsSpecType.name 通过值匹配确定维度 2026-04-21 12:46:59 +08:00
Council 4683862688 fix: GetGoodsViewData 使用 SPEC_DIMS 顺序推断维度,不再依赖 type 字段 2026-04-21 12:45:50 +08:00
Council 416fe0a067 fix: 移除 type 字段插入(数据库已回滚) 2026-04-21 12:44:37 +08:00
Council c134351c82 fix: 修复 spec 选择区 + GoodsSpecType encoding
- 清理重复的 GoodsSpecType 记录
- 重新生成正确的 GoodsSpecType 数据(场次信息)
2026-04-21 12:32:16 +08:00
Council 461dd6b101 fix: 修复 seat map 数据结构 + selected seats UI + encoding + submit button
1. renderSeatMap: 修复 map.seat_map 数据结构访问
2. updateSelectedUI: 渲染 selectedList + 启用 submit button
3. 修复 GoodsSpecValue 中文编码问题
4. 添加 barCount/barPrice 更新
2026-04-21 12:30:09 +08:00
Council 82a5b2129d fix: 修复 seatMap 数据结构错误 - vr_seat_template 已经是解码后的 seat_map 2026-04-21 12:08:48 +08:00
Council fb300e00fc feat(Phase2): 修复 seatSpecMap 生成 + room ID 硬编码问题
关键修复:
1. BatchGenerate(): 新增 extends.seat_key 字段写入 GoodsSpecBase
2. BatchGenerate(): 新增 type 字段写入 GoodsSpecValue(4维spec类型)
3. ticket_detail.html: renderSeatMap() 不再用 room_001_ 硬编码,改用模板实际 roomId
4. Goods.php: seatSpecMap 注入(已在上次提交)

数据库修复:
- 为 vrt_goods_spec_value 新增 type 字段
- 重新生成商品 118 的规格数据(含 seat_key 和 type)
2026-04-21 12:03:56 +08:00
Council c581395a9c feat(Phase2): Issue 1 修复购买提交流程
- Goods.php: 注入 seatSpecMap 到票务模板
- ticket_detail.html: submit() 改 POST + 4维spec数组

关键修复:
- submit() 使用隐藏表单 POST 到 Buy 链路(不再用 location.href)
- spec 从 seatSpecMap[seatKey].spec 读取完整4维数组
- extension_data 嵌套在 order_base 内
- 直接 JSON.stringify,不需要 base64
2026-04-21 11:41:59 +08:00
Council 919c5cfd4e council(draft): FirstPrinciples - create plan.md for ShopXO frontend research (Q1-Q4) 2026-04-20 23:10:51 +08:00
Council 671b0359ad council(finalize): BackendArchitect - merge report + resolve plan.md conflict, all tasks done 2026-04-20 19:21:04 +08:00
Council ccf0fbb309 council(review): BackendArchitect - ghost spec root cause analysis report 2026-04-20 19:18:08 +08:00
Council 11fdf0309f Merge branch 'council/FrontendDev' into main
council(review): FrontendDev - ghost spec research report with verified findings
- All 7 FrontendDev tasks completed and verified against actual code
- Summary updated with correct file references and commit hashes
- Conflicting plan.md resolved: keep FrontendDev version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:16:18 +08:00
Council cba9c64eb9 council(draft): BackendArchitect - merge fix branch, resolve conflict, all tasks complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:16:11 +08:00
Council c9105f7eb3 council(review): FrontendDev - fix summary file references and verified research
- Remove non-existent SecurityEngineer report file references
- Fix commit hashes to match actual fix/venue-hard-delete-p0 history
- Add BackendArchitect-on-FrontendDev-P1.md to index
- Verify all findings against actual code (AdminGoodsSaveHandle.php,
  SeatSkuService.php, ticket_detail.html)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:15:47 +08:00
Council 1803262bbd council(finalize): SecurityEngineer - mark all tasks complete in plan.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:15:41 +08:00
Council d52bf31b55 council(finalize): SecurityEngineer - resolve plan.md merge conflict, finalize ghost spec summary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:14:57 +08:00
Council ff30e79d0b council(review): SecurityEngineer - ghost spec security audit + summary
Security audit findings:
- 0 P1 vulnerabilities found
- 3 P2 issues: error messages, DB auto-modification, sold seats detection
- 1 P3 issue: field size limit

Reports:
- reviews/SecurityEngineer-GHOST_SPEC_SECURITY.md
- reviews/council-ghost-spec-summary.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:06:29 +08:00
Council dbacd36230 council(review): FrontendDev - ghost spec research report
- ticket_detail.html is customer-facing (not admin edit page)
- "spec不允许重复" triggers in GoodsService.php, not in the frontend
- GetGoodsViewData() correctly clears template_id/snapshot on hard delete
- loadSoldSeats() is unimplemented (TODO only)
- BackendArchitect should evaluate removing stale config blocks on hard delete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:05:32 +08:00
Council f84f95b569 Merge branch 'council/FrontendDev' 2026-04-20 18:49:57 +08:00
Council a96a3c00ba council(draft): FrontendDev - update plan.md for ghost spec research
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:49:54 +08:00
Council f441deaa61 Merge branch 'council/FrontendDev'
# Conflicts:
#	plan.md
2026-04-20 18:49:13 +08:00
Council f27a32dc3d council(draft): FrontendDev - plan.md: ghost spec research Round 1 2026-04-20 18:48:09 +08:00
Council 795126cd55 council(draft): SecurityEngineer - resolve plan.md merge conflict, ghost spec audit 2026-04-20 18:47:55 +08:00
Council aa6651e963 council(draft): BackendArchitect - create plan for ghost spec investigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:47:38 +08:00
Council 98dfbbd943 council(draft): SecurityEngineer - Round 1 plan for ghost spec security audit 2026-04-20 18:47:31 +08:00
Council 63c1608442 Merge branch 'main' into council/SecurityEngineer
# Conflicts:
#	plan.md
2026-04-20 09:59:21 +08:00
Council 2590f361f7 council(review): SecurityEngineer - Round 2 plan update: all tasks marked done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 09:57:09 +08:00
Council 325eb4116a council(draft): SecurityEngineer - Round 1 plan: AdminGoodsSaveHandle security audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 09:46:37 +08:00
Council bdb4eb55e7 council(draft): SecurityEngineer - add Round 1 plan for AdminGoodsSaveHandle security audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 09:45:33 +08:00
Council c18e298a69 council(draft): SecurityEngineer - add Round 6 docs review plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 05:25:52 +08:00
28 changed files with 6137 additions and 523 deletions

161
docs/AGENT_PROMPT.md Normal file
View File

@ -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:10000Web/ localhost:10001MySQL/ localhost:9000PHP-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 到本地,汇报给大头后由他处理上游合并。

View File

@ -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 1P0购买提交流程失效
### 根因(三层叠加)
**第一层(致命)**`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); // 直接 JSONBuyService 自动处理
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 2P1缩放时舞台不跟随
### 根因
`.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 3P1spec 加载问题(已回滚)
### 根因
- `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 4P2商品详情/图片加载
- `$goods['content']`:✅ 正常渲染
- `$goods['images']`:⚠️ 数据存在但未使用
- `.goods-detail-content` CSS 缺失
如需展示商品图片,在模板中添加图片渲染逻辑。如票务详情页不需要 ShopXO 商品内容,可降级为「确认不需要」。
---
## Issue 5P2 潜在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` 能通过 seatKeyspec value 字符串)查询 spec_base_idmap 可以去掉。但保留是合理的优化。
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 修正版*

682
docs/FULL_PLAN.md Normal file
View File

@ -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:10000Web/ localhost:10001MySQL/ localhost:9000PHP-FPM
> 📋 **AntiGravity 已进行会话进度**: `SESSION_REPORT_20260421_PHASE2_FIX.md` - 记录AntiGravity 推进的所有工作,包含经验教训与改动。
---
## 一、项目概览
### 1.1 目标产品
VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。
### 1.2 技术栈
- **前端**:原生 HTML + CSS + JS无框架商品详情页使用 `ticket_detail.html`
- **后端**ShopXOThinkPHP 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 每个 GoodsSpecBaseSKU由 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`GETShopXO `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. 查询对应的 GoodsSpecValue4个维度的 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 界面结构
```
┌─────────────────────────────────────────────────────┐
│ 顶部 Bannervenue.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`GETShopXO `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); // 直接 JSONBuyService 自动处理
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 fieldJSON 字符串,非 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 可独立阅读并推进事务。*

358
docs/PLAN_5DIM_REFACTOR.md Normal file
View File

@ -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 JSONgoods_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` 作为 fallbackAdmin.php:646 和 ticket_detail.html:262 已有兼容逻辑)
- 未来可扩展多个 room多厅模式
### 2.3 当前 GoodsSpecTypegoods_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 GoodsSpecValuegoods_id=112
**当前0 条**。
BatchGenerate() 虽然写了 GoodsSpecBaseSKU 存在,有价格/库存),但没有写 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重建 GoodsSpecType5维
```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. 遍历所有 GoodsSpecBasegoods_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` — 座位模板管理

View File

@ -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 4CSS 文件分离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 第一版返回空数组,后续迭代接入 |
---
## 六、验收测试总表
### P0Step 1 + Step 2
| # | 测试场景 | 预期结果 |
|---|---------|---------|
| 1 | 选择 3 个座位 → 提交 | 购物车页显示 3 条商品 |
| 2 | 座位 2 库存不足 | 弹窗提示,已选座位清零 |
| 3 | 选择场次 A → 选 2 座 → 切换场次 B | 已选座位清零,购买栏归零 |
| 4 | 切换回场次 A | 座位图重新渲染,无旧数据残留 |
### P1Step 3 + Step 4
| # | 测试场景 | 预期结果 |
|---|---------|---------|
| 5 | `SoldSeats()` 返回 `["A_1","A_2"]` | A_1、A_2 标记灰色已售 |
| 6 | 访问 `ticket_detail.html` | DevTools Network 可见 `ticket.css` 请求 |
| 7 | 页面各区块布局 | 与内联样式版本一致 |
### P2Step 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 作为可选优化项

View File

@ -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 + 原生 JSsession/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
├── 内联 JSvrTicketApp 对象)
└── 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 官方更新同步成本 | 🟢 低 | 票务页面与商城页面目录隔离 |

View File

@ -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*

View File

@ -0,0 +1,482 @@
# 商品详情扩展字段数据字典与前端使用说明
> 日期2026-04-21
> 用途:前端 agentantigravity / cursor拿到商品详情页时扩展字段里有哪些可用数据如何用
---
## 一、核心数据结构全貌
商品详情页加载时PHP 后端向模板注入以下变量:
| 模板变量名 | 来源 | 说明 |
|-----------|------|------|
| `$goods` | ShopXO GoodsService | ShopXO 原生商品数据id/title/price/content/images 等) |
| `$vr_seat_template` | `SeatSkuService::GetGoodsViewData()` | 票务插件扩展数据 |
| `$goods_spec_data` | `SeatSkuService::GetGoodsViewData()` | 场次列表 |
---
## 二、vr_goods_configgoods 表扩展字段)
存储位置:`goods.vr_goods_config`JSON 字段)
这是商品发布时由管理员配置的数据快照,**前端只能读,不能写**。
### 完整 JSON 示例(商品 118VR 演唱会)
```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=房间IDvalue=该房间选中的分区字符数组 |
| `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. 查询对应的 GoodsSpecValue4个维度的值
$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
});
```

View File

@ -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'], // 场次列表
],
// 【修复】重构后的 seatSpecMaproom_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. 构建 seatSpecMapseat_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 字段 | 前端 |

View File

@ -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_basegoods_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 对接 ShopXOCSS 在 H5/小程序完全一致WebView 同源) |
| `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` | ShopXO view/goods/ 模板使用原生 PHP + 原生 JSsession/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、外部 CDNShopXO 必须离线可用)
【第三层:具体需求】
[座位图 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 和小程序都基于 WebViewCSS 渲染一致。关键:用 `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多座位选座和 Q1H5 模板基础)**
- 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
View File

@ -1,80 +1,109 @@
# Plan — ShopXO 酷炫前端模板实现方案调研
# Plan — 调研「场馆删除后编辑商品出现规格重复错误」问题
> 版本v1.0 | 日期2026-04-20 | Agentcouncil/ProductManager + council/FrontendDev + council/BackendArchitect + council/FirstPrinciples
> 版本v1.3 | 日期2026-04-20 | Agentcouncil/FrontendDev + council/SecurityEngineer + council/BackendArchitect
---
## 任务概述
## BackendArchitectTask B1-B6
vr-shopxo-plugin 项目推进 Phase 3 前端模板调研,聚焦 4 个方向:
- Q1ShopXO 自定义模板最佳实践
- Q2单订单多 SKU 支持(多座位选择前提)
- Q3第三方无代码构建服务提示词策略
- Q4uni-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-7FrontendDev+ 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` | GetGoodsViewDataP2 根因,多模板处理缺陷 |
| `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 为空时提示用户重新选择场馆

View File

@ -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. **商品创建时**:用户配置了场馆 Atemplate_id=5和场馆 Btemplate_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 | 修复 P2GetGoodsViewData 多模板清理 | SeatSkuService.php | P1 |
| 3 | 测试验证 | — | — |
---
## 八、结论
1. **Council 的调研结果基本正确**,但遗漏了前端过滤本身是有效的这一点
2. **真正的根因**在于 `save_thing_end` 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据
3. **修复方案**是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
4. **GetGoodsViewData** 也需要同步修复,支持多模板模式

View File

@ -0,0 +1,232 @@
# 安全审计报告:幽灵 SpecGhost 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` 字段无大小限制,可存储超大 JSONDoS 风险)— 需 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已售座位标记以提升用户体验和防止超卖。

View File

@ -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
### 根因 1P1无效 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` 整个循环体被跳过,**无任何校验**直接写回。
### 根因 2P1幽灵 spec 持续污染 vr_goods_config
脏 config 块(含已删除模板的 `template_snapshot`)被写回 DB 后:
- 下次编辑商品时,`vr_goods_config` 仍含无效配置
- `GetGoodsViewData` 尝试加载模板(失败后将 `template_id` 置 null
- 但若 `save_thing_end` 在模板验证前先执行写回,无效配置再次被保存
- 循环往复,**幽灵 spec 永远无法被清理**
### 根因 3P2前端无 `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-383TODO 注释状态,无法标记已售座位。顾客可购买已售座位(需支付验证拦截)。
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` 告知用户重新选择场馆。

View File

@ -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 产生冲突)。

View File

@ -0,0 +1,254 @@
# FrontendDev 调研报告:幽灵 spec 问题
> 日期2026-04-20 | Agentcouncil/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 | 规格名称重复检测 |

View File

@ -1,7 +1,7 @@
# 幽灵 Spec 问题 — Council 调研汇总报告
> 日期2026-04-20 | AgentFrontendDev + 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`
#### 关键发现(逐行验证)
#### 关键发现
**根因 1Critical无效 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'] ?? []);` |
**根因 2HighGetGoodsViewData 仅处理单模板模式,多模板时无效块不清理**
`$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` 没有这个防护。
**根因 3MediumBatchGenerate 对无效 template_id 返回 code=-2阻断保存**
**Secondary Bug — 模板不存在时 null 访问**
`AdminGoodsSaveHandle.php:164-170` — 无效 config 块的 `templateId` 仍为原值BatchGenerate 内部检测到模板不存在后返回错误码,阻断整个保存流程。
| 文件 | 行号 | 问题代码 |
|------|------|----------|
| `AdminGoodsSaveHandle.php` | **71** | `$seatMap = json_decode($template['seat_map'] ?? '{}', true);` |
**根因 4Medium前端过滤无法防御 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` |

View File

@ -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',
),
),
);
?>

View File

@ -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';

View File

@ -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 格式)
*/

View File

@ -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"
]
}
}

View File

@ -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';
}
}

View File

@ -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":""}]',
];

View File

@ -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. 构建 seatSpecMapseat_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;
}
}

View File

@ -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; }

View File

@ -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">
&copy; <?php echo date('Y'); ?> <?php echo htmlspecialchars($shop_name, ENT_QUOTES, 'UTF-8'); ?> All Rights Reserved.
&copy;
<?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

View File

@ -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; }