vr-shopxo-plugin/docs/16_VR_TICKET_FIELD_VALIDATI...

249 lines
9.4 KiB
Markdown
Raw Normal View History

# VR Ticket 票务商品字段校验方案
> **状态**: ✅ 已实施
> **版本**: 1.0.0
> **实施日期**: 2026-05-18
> **负责人**: 西莉雅、大头
---
## 目录
1. [需求背景](#一需求背景)
2. [字段约束规则](#二字段约束规则)
3. [业务影响分析](#三业务影响分析)
4. [实施记录](#四实施记录)
5. [前端适配](#五前端适配)
6. [FAQ](#六faq)
## 一、需求背景
当前票务商品存在以下隐患:
1. 用户未填 batch_number_expire演出日期导致 session_meta 为空,前端无法展示倒计时和场次禁用逻辑
2. 用户未填 coding商品编号导致 peer_goods 无法关联
3. 未对 (coding, batch_number_expire) 组合做唯一性约束,同一演出的不同日期商品可能冲突
---
## 二、字段约束规则
### 2.1 字段说明
| 字段 | 数据库列 | 用途 | 约束 |
|------|----------|------|------|
| 商品编号 | goods.coding | 同演出多日期关联peer_goods | 非强制,但有编号才能关联同演出商品 |
| 演出日期 | goods.batch_number_expire | 演出当天日期Unix 时间戳) | 强制,票务商品必须填写 |
### 2.2 唯一性约束
**(coding, batch_number_expire) 组合唯一,由应用层保存钩子校验,原生数据库无此关联索引。**
含义:
- 同一 coding同一演出不同 batch_number_expire = 不同日期场次(正常)
- 同一 coding 下,不能有两个相同的 batch_number_expire禁止
- coding 为空时batch_number_expire 也不能与其他空 coding 的记录重复(禁止,因为会破坏日期切分逻辑)
**注意**:该唯一性由 AdminGoodsSaveHandle 钩子在保存时校验,数据库层面不创建联合唯一索引。
---
## 三、业务影响分析
### 3.1 未填写 batch_number_expire 的后果
- tree API 返回的 peer_goods.date = "",前端无法展示日期切换控件
- tree API 返回的 session_meta 为空或 date="",前端的场次选择卡无倒计时、无过期判断
- 后端 BuyCheck 钩子无法验证停售时间
### 3.2 未填写 coding 的后果
- peer_goods 返回空,前端不展示多日期切换控件
- 不影响下单
### 3.3 两者均未填的后果
- 多条票务商品会共享 (coding='', batch_number_expire=0),违反唯一性规则
- 保存时被 AdminGoodsSaveHandle 钩子拦截,报错提示"该日期已存在其他票务商品"
---
## 四、实施记录
> **实施日期**: 2026-05-18
> **所有阶段均已完成。** 以下为各阶段的实现细节与代码位置。
### Phase 1 ✅:后台配置界面动态必填控制
**文件**: `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php` (L283-340)
**实现方式**: Vue `watch` + DOM 操作,无侵入式动态注入
**核心函数**: `applyTicketRequired(required)`
**功能清单**:
- 监听 `isTicket` 复选框:勾选时为 `batch_number_expire`、`coding` 注入 `required` 属性
- 视觉反馈:标签后追加红色 `*` 标记CSS class: `vr-ticket-field-star`
- 占位提示input placeholder 替换为「票务商品「演出日期」必填」等友好提示
- 取消勾选时彻底清理(仅移除插件添加的属性,保护后台模板原生 required
- `data-vr-required-src` 标记区分插件注入 vs 模板预置
**关键代码片段**:
```javascript
fields.forEach(({ name, label }) => {
const input = document.querySelector('input[name="' + name + '"]');
if (!input) return;
if (required) {
if (!input.hasAttribute('required')) {
input.setAttribute('required', '');
input.setAttribute('data-vr-required-src', '1');
}
// 添加红 * 视觉提示
// 修改 placeholder 为票务提示
} else {
// 仅移除插件添加的 required不影响后台模板预置的 required
if (input.getAttribute('data-vr-required-src') === '1') {
input.removeAttribute('required');
}
// 恢复原生 placeholder
}
});
```
### Phase 2 ✅:后端表单校验(保存钩子)
**文件**: `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` (L60-95)
**触发时机**: `plugins_service_goods_save_thing_end` 钩子事务内goods 表已落库)
**校验项**:
| # | 校验 | 条件 | 错误消息 |
|---|------|------|----------|
| 1 | 演出日期必填 | `batch_number_expire <= 0` | 「票务商品必须设置演出日期(批号有效期),请填写后重新保存」 |
| 2 | 组合唯一性 | `(coding, batch_number_expire)` 重复 | 「该商品编号「{coding}」在此演出日期已存在商品「{title}」」 |
| 2b | 组合唯一性coding 为空) | 空 coding + 相同日期重复 | 「该演出日期已存在其他未设置编号的票务商品「{title}」」 |
**唯一性校验 SQL 逻辑**:
```sql
SELECT id, title, coding FROM `goods`
WHERE batch_number_expire = {batch_number_expire}
AND (coding = {coding} OR (coding = '' AND {coding} = ''))
AND is_delete_time = 0
AND id != {current_goods_id}
AND vr_goods_config != ''
LIMIT 1
```
**设计决策**: 唯一性约束在应用层实施,不在数据库建联合索引,以保持与 ShopXO 原生结构的兼容性。
### Phase 3 ✅BuyCheck 下单校验
**文件**: `shopxo/app/plugins/vr_ticket/Hook.php` (L264-368)
**触发时机**: `plugins_service_buy_order_insert_begin` 钩子(订单创建前)
**两层校验**:
| 校验层 | 条件 | 错误消息 | 行为 |
|--------|------|----------|------|
| 数据完整性 | `batch_number_expire <= 0` | 「「{title}」未设置演出日期,暂时无法购买」 | 拒绝下单 |
| 停售时效 | `now >= batch_expire_ts` | 「该场次({start} {end}距开场已不足5分钟已停止售票」 | 拒绝下单 |
**关键实现细节**:
- 批量查询优化:一次性查询所有 `goods_id``vr_goods_config` + `batch_number_expire`
- `batch_expire_ts` 从 SKU 的 `extends` JSON 字段读取(由 `SeatSkuService::BatchGenerate` 写入)
- 非票务商品:完全跳过,零性能影响
- extends 缺失或为空:跳过停售校验(保守放过策略)
**逻辑隔离**: 所有票务专属逻辑由 `vr_goods_config` 非空判断保护,普通商品完全不受影响。
## 五、前端适配
**仅票务商品**适用以下规则。普通商品不受影响。
当用户未填写相关字段时,前端需要感知并降级:
| 字段 | 填写情况 | 前端表现 |
|------|----------|----------|
| batch_number_expire | 空 | 场次选择卡无倒计时无停售禁用逻辑tree API 的 session_meta 为空 |
| coding | 空 | 不显示多日期切换控件tree API 的 peer_goods 为空数组 |
| 两者均空 | 冲突 | 保存时被 AdminGoodsSaveHandle 拦截BuyCheck 拒绝下单 |
### 5.1 后台管理界面(已实施)
后台商品编辑页中当「VR Ticket」复选框勾选时`batch_number_expire`(演出日期)和 `coding`(商品编号)将动态变为必填,标签旁显示红色星号,输入框 placeholder 提示「票务商品「XX」必填」。
### 5.2 前端UniApp
前端可通过 tree API 的 `session_meta``peer_goods` 的返回情况来判断数据完整性:
```javascript
// 检测数据完整性
function checkDataIntegrity(apiData) {
const warnings = [];
// 检查是否有场次元数据
if (!apiData.session_meta || apiData.session_meta.length === 0) {
warnings.push('该商品未设置有效的演出日期,无法展示场次信息');
}
// 检查是否有同场次关联商品
if (!apiData.peer_goods || apiData.peer_goods.length === 0) {
// coding 未填,无关联商品,但这不影响基本选座流程
}
return warnings;
}
```
---
## 六、FAQ
### Q: 普通商品(非票务)会受影响吗?
**不会。** 票务相关逻辑的判断入口统一为:
```php
$vrConfig = Db::name('Goods')->where('id', $goodsId)->value('vr_goods_config');
if (empty($vrConfig)) {
// 普通商品,跳过所有票务校验和逻辑
return;
}
```
- Phase 1后台表单仅票务商品可见演出日期/商品编号配置区块
- Phase 2保存钩子仅票务商品执行必填和唯一性校验
- Phase 3BuyCheck仅票务商品执行停售时间校验
普通商品的 `batch_number_expire``coding` 字段不受任何影响,可以正常使用或留空。
### Q: 为什么不在数据库层面建唯一索引?
保持与 ShopXO 原生数据库结构的兼容性。`(coding, batch_number_expire)` 的唯一性仅在票务商品(`vr_goods_config != ''`)范围内生效,数据库唯一索引无法表达这种条件约束。
### Q: BuyCheck 的停售校验为什么是"不足5分钟"
`batch_expire_ts = 演出开始时间戳 - 300秒5分钟`,由 `SeatSkuService::BatchGenerate()` 在 SKU 生成时计算。这个5分钟缓冲给用户足够的支付和确认时间。
### Q: 如果 coding 为空peer_goods 会怎样?
tree API 的 `peer_goods` 返回空数组 `[]`。前端应据此隐藏多日期切换控件,这不影响基本选座和下单流程。
---
## 附录:相关代码文件
| 文件 | 作用 |
|------|------|
| `hook/AdminGoodsSave.php` | Phase 1 — 前端动态必填控件Vue + DOM |
| `hook/AdminGoodsSaveHandle.php` | Phase 2 — 后端唯一性/必填校验 |
| `Hook.php` | Phase 3 — BuyCheck 下单拦截 |
| `service/SeatSkuService.php` | SKU 生成时写入 batch_expire_ts |
| `goods/Goods.php` (tree action) | tree API 返回 session_meta / peer_goods |
---
*文档由 Antigravity 生成 · 2026-05-18*