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>council/FrontendDev
parent
f84f95b569
commit
dbacd36230
14
plan.md
14
plan.md
|
|
@ -17,13 +17,13 @@
|
|||
|
||||
## FrontendDev 任务清单
|
||||
|
||||
- [ ] [Claimed: council/FrontendDev] **Task 1**: 读取 `ticket_detail.html`,分析前端构建规格项的过程
|
||||
- [ ] [Claimed: council/FrontendDev] **Task 2**: 当模板不存在时,前端如何处理 `template_snapshot` 和 `spec_base_id_map`?
|
||||
- [ ] [Claimed: council/FrontendDev] **Task 3**: `loadSoldSeats()` 函数实际实现了吗?soldSeats 数据如何填充?
|
||||
- [ ] [Claimed: council/FrontendDev] **Task 4**: 编辑模式下(已有 vr_goods_config),前端是否正确处理已删除场馆的旧规格?
|
||||
- [ ] [Claimed: council/FrontendDev] **Task 5**: 给出前端根因分析(含具体文件路径和行号)
|
||||
- [ ] [Claimed: council/FrontendDev] **Task 6**: 给出修复方案
|
||||
- [ ] [Claimed: council/FrontendDev] **Task 7**: 将调研报告写入 `reviews/council-ghost-spec-FrontendDev.md`
|
||||
- [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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
# FrontendDev 调研报告:幽灵 spec 问题
|
||||
|
||||
> 日期:2026-04-20 | Agent:council/FrontendDev
|
||||
|
||||
---
|
||||
|
||||
## 1. ticket_detail.html 的前端规格项构建
|
||||
|
||||
### 1.1 页面性质确认
|
||||
|
||||
`ticket_detail.html` 是**客户前端购票页面**(用于 C 端用户选座下单),**不是**后台商品编辑页面。后台编辑商品时出现的「规格不允许重复」错误发生在 ShopXO 标准后台,其触发点在 `GoodsService.php:1859/1889/1925`。
|
||||
|
||||
前端购票页面的数据来源:
|
||||
|
||||
| PHP 变量 | 来源(SeatSkuService) | 用途 |
|
||||
|----------|----------------------|------|
|
||||
| `$vr_seat_template` | `GetGoodsViewData()` | `seat_map`、`spec_base_id_map` |
|
||||
| `$goods_spec_data` | `GetGoodsViewData()` | 场次(session)列表 |
|
||||
|
||||
前端 JS 接收这些数据:
|
||||
|
||||
```
|
||||
ticket_detail.html:186-187
|
||||
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
|
||||
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
|
||||
```
|
||||
|
||||
前端规格项(场次)构建逻辑(`renderSessions()`, ticket_detail.html:202-213):
|
||||
|
||||
```javascript
|
||||
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
|
||||
// specData 格式: [{spec_id: 2001, spec_name: "08:00-23:59", price: 100}]
|
||||
// 渲染为可点击的场次卡片
|
||||
```
|
||||
|
||||
**结论**:`ticket_detail.html` 本身不构建 ShopXO 规格(spec)表格,其规格项仅为场次选择器。真正触发「规格不允许重复」的是 ShopXO 后台商品编辑页的 `GoodsService.php`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 模板不存在时前端对 template_snapshot 和 spec_base_id_map 的处理
|
||||
|
||||
### 2.1 后端 fallback 行为(SeatSkuService.php)
|
||||
|
||||
关键函数:`GetGoodsViewData()` (`SeatSkuService.php:358-464`)
|
||||
|
||||
**模板不存在时的 fallback(硬删除场景)**:
|
||||
|
||||
```php
|
||||
// SeatSkuService.php:383-393
|
||||
if (empty($seatTemplate)) {
|
||||
$config['template_id'] = null;
|
||||
$config['template_snapshot'] = null;
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => json_encode([$config], ...),
|
||||
]);
|
||||
return [
|
||||
'vr_seat_template' => null,
|
||||
'goods_spec_data' => [],
|
||||
'goods_config' => $config,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**执行效果**:
|
||||
1. `template_id` 被置为 `null`(写入 DB)
|
||||
2. `template_snapshot` 被置为 `null`(写入 DB)
|
||||
3. 返回给前端:`vr_seat_template = null`、`goods_spec_data = []`
|
||||
|
||||
**前端接收到的数据**:
|
||||
```javascript
|
||||
seatMap: {} // 空对象
|
||||
specBaseIdMap: {} // 空对象
|
||||
goods_spec_data: [] // 空数组
|
||||
```
|
||||
|
||||
**前端渲染结果**:
|
||||
- `renderSessions()`:`sessionGrid` 内为 `goods_spec_data.length === 0`,显示提示「该商品暂无场次信息」(ticket_detail.html:133)
|
||||
- `renderSeatMap()`:`seatMap.map` 为空,座位图区域显示「座位图加载失败」
|
||||
- 整个座位选择区域 UI 为空/失败状态
|
||||
|
||||
### 2.2 根因分析
|
||||
|
||||
**模板不存在时,前端的 fallback 行为是正确的**——前端展示空白购票页,用户无法选座。这符合"场馆已删除,无法购票"的业务预期。
|
||||
|
||||
真正的问题不在 `ticket_detail.html`(前端),而在:
|
||||
1. 后台商品编辑页(ShopXO admin)——保存时 `AdminGoodsSaveHandle` 如何处理 `template_id=null` 的情况
|
||||
2. `vr_goods_config` 的持久化清理——硬删除后 `vr_goods_config` 中的 config 块是否被正确清理
|
||||
|
||||
---
|
||||
|
||||
## 3. loadSoldSeats() 函数实现情况
|
||||
|
||||
**状态:未实现(仅有 TODO 注释)**
|
||||
|
||||
```
|
||||
ticket_detail.html:375-383
|
||||
loadSoldSeats: function() {
|
||||
// TODO: 从后端加载已售座位
|
||||
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||
// goods_id: this.goodsId,
|
||||
// spec_base_id: this.sessionSpecId
|
||||
// }, function(res) {
|
||||
// // 标记已售座位
|
||||
// });
|
||||
},
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- `soldSeats: {}` 永远为空对象(ticket_detail.html:189)
|
||||
- `renderSeatMap()` 渲染座位时,无法从 `soldSeats` 读取已售标记
|
||||
- 已售座位只能通过 `.sold` class(由 PHP 渲染)或 `soldSeats` 字典来标记,但两者都未生效
|
||||
- 结果:前端无法区分已售/可选座位——用户可能选中一个已售座位,提交后才发现无法购买
|
||||
|
||||
**严重程度**:P2(功能缺陷),不影响「规格不允许重复」错误。
|
||||
|
||||
---
|
||||
|
||||
## 4. 编辑模式下前端对已删除场馆旧规格的处理
|
||||
|
||||
### 4.1 当前行为
|
||||
|
||||
当商品的 `vr_goods_config` 中 `template_id` 指向的场馆已被硬删除:
|
||||
|
||||
1. `GetGoodsViewData()` 检测到模板不存在 → `template_id=null`、`template_snapshot=null` → 写入 DB
|
||||
2. 前端收到 `vr_seat_template=null`、`goods_spec_data=[]`
|
||||
3. `ticket_detail.html` 渲染空白购票页(无场次、无座位图)
|
||||
4. **前端没有特殊逻辑处理幽灵 spec**——因为后端已经清理了 `template_id` 和 `template_snapshot`
|
||||
|
||||
### 4.2 问题点
|
||||
|
||||
**`ticket_detail.html` 是前端购票页,不是编辑页**。商品编辑(后台)由 ShopXO 标准后台处理,VR 插件通过钩子介入。
|
||||
|
||||
幽灵 spec 的真正风险在于 `AdminGoodsSaveHandle` 的保存逻辑:
|
||||
|
||||
- `AdminGoodsSaveHandle.php:383-394`(硬删除 fallback):当模板不存在时,`continue` 跳过 snapshot 重建,**但 config 块本身未被移除**
|
||||
- 如果 `vr_goods_config` 包含多个 config 块(如多场馆商品),硬删除场馆后该 config 块残存
|
||||
- 下次编辑时,该 config 块仍被读取,若前端重新选择了场馆,可能导致 spec 重复
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端根因分析
|
||||
|
||||
### 5.1 「规格不允许重复」错误的真正触发点
|
||||
|
||||
该错误**不在 `ticket_detail.html`**,而在 ShopXO 后台商品编辑流程的 `GoodsService.php:1859/1889/1925`。
|
||||
|
||||
触发条件:
|
||||
1. 用户在 ShopXO 后台编辑商品时,手动填写/复制了重复的规格值
|
||||
2. 表单提交到 `GoodsService::GoodsSave()` → spec 验证逻辑检查 `specifications_value_*` 参数
|
||||
3. 发现有重复值 → 返回「规格不允许重复」错误
|
||||
|
||||
### 5.2 与 VR 插件的关联
|
||||
|
||||
当 `AdminGoodsSaveHandle` 运行时(`plugins_service_goods_save_thing_end`),它会:
|
||||
1. 清空 `GoodsSpecType`、`GoodsSpecBase`、`GoodsSpecValue`(AdminGoodsSaveHandle.php:152-155)
|
||||
2. 对 `template_id > 0` 的 config 块执行 `BatchGenerate`
|
||||
|
||||
如果 `template_id` 为 `null`(硬删除后),`BatchGenerate` 跳过,但 `vr_goods_config` 中的 config 块仍然残存。**此时商品 spec 表为空**,不会出现「规格不允许重复」错误。
|
||||
|
||||
但如果用户在前端(ShopXO 后台编辑页)操作时,ShopXO 的原生规格表单被填充了旧的 VR 规格数据,这些数据可能在保存时被 ShopXO 的原生规格逻辑验证并触发重复错误。
|
||||
|
||||
---
|
||||
|
||||
## 6. 修复方案
|
||||
|
||||
### 6.1 前端修复(ticket_detail.html)
|
||||
|
||||
**loadSoldSeats() 建议实现**:
|
||||
|
||||
```javascript
|
||||
loadSoldSeats: function() {
|
||||
if (!this.goodsId || !this.sessionSpecId) return;
|
||||
var self = this;
|
||||
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||
goods_id: this.goodsId,
|
||||
spec_base_id: this.sessionSpecId
|
||||
}, function(res) {
|
||||
if (res.code === 0 && res.data) {
|
||||
self.soldSeats = res.data; // {row_col: true, ...}
|
||||
self.markSoldSeats();
|
||||
}
|
||||
});
|
||||
},
|
||||
markSoldSeats: function() {
|
||||
var self = this;
|
||||
document.querySelectorAll('.vr-seat').forEach(function(el) {
|
||||
var key = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||
if (self.soldSeats[key]) {
|
||||
el.classList.add('sold');
|
||||
}
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 后端修复(建议 BackendArchitect 评估)
|
||||
|
||||
当模板被硬删除后,`AdminGoodsSaveHandle` 应清理整个 config 块:
|
||||
|
||||
```php
|
||||
// AdminGoodsSaveHandle.php:77-90 改进
|
||||
if (empty($template)) {
|
||||
// 模板不存在时,移除整个 config 块(避免残存)
|
||||
unset($configs[$i]);
|
||||
continue;
|
||||
}
|
||||
$configs = array_values($configs); // 重排索引
|
||||
```
|
||||
|
||||
或在 `SeatSkuService::GetGoodsViewData()` 中持久化清理:
|
||||
|
||||
```php
|
||||
// SeatSkuService.php:383-393 改进
|
||||
if (empty($seatTemplate)) {
|
||||
// 模板不存在时,清除整个 config 块,而非仅置 null
|
||||
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
|
||||
unset($vrGoodsConfig[0]);
|
||||
$newConfig = array_values($vrGoodsConfig);
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'vr_goods_config' => empty($newConfig) ? '' : json_encode($newConfig, ...),
|
||||
]);
|
||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 总结
|
||||
|
||||
| 问题 | 位置 | 严重度 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| loadSoldSeats() 未实现 | ticket_detail.html:375 | P2 | 已售座位无法标记 |
|
||||
| 模板不存在时 fallback 正确 | SeatSkuService.php:383 | — | 后端已正确清理 template_id |
|
||||
| 「规格不允许重复」不在前端触发 | GoodsService.php:1859 | — | 触发点在 ShopXO 后台服务层 |
|
||||
| config 块残留 | AdminGoodsSaveHandle.php | P2 | 硬删除后 config 块未移除 |
|
||||
| spec_base_id_map 不影响前端 | ticket_detail.html:417 | P3 | 前端通过 seatKey 查找,未使用 map |
|
||||
|
||||
---
|
||||
|
||||
## 8. 文件路径索引
|
||||
|
||||
| 文件 | 行号 | 关键内容 |
|
||||
|------|------|---------|
|
||||
| `SeatSkuService.php` | 358-464 | `GetGoodsViewData()`,模板不存在 fallback |
|
||||
| `SeatSkuService.php` | 383-394 | 模板不存在时置 null 并更新 DB |
|
||||
| `AdminGoodsSaveHandle.php` | 77-145 | config 块遍历和 snapshot 重建逻辑 |
|
||||
| `AdminGoodsSaveHandle.php` | 152-155 | 清空原生 spec 表 |
|
||||
| `AdminGoodsSaveHandle.php` | 158-173 | BatchGenerate 循环(跳过 template_id=0)|
|
||||
| `ticket_detail.html` | 186-189 | 前端 JS 接收 seatMap/specBaseIdMap |
|
||||
| `ticket_detail.html` | 202-213 | `renderSessions()` 场次渲染 |
|
||||
| `ticket_detail.html` | 375-383 | `loadSoldSeats()` TODO(未实现)|
|
||||
| `ticket_detail.html` | 417 | specBaseIdMap 查找(仅 Plan A 提交用)|
|
||||
| `GoodsService.php` | 1859 | 规格值列重复检测 |
|
||||
| `GoodsService.php` | 1889 | 规格值重复检测 |
|
||||
| `GoodsService.php` | 1925 | 规格名称重复检测 |
|
||||
Loading…
Reference in New Issue