# 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 3(BuyCheck):仅票务商品执行停售时间校验 普通商品的 `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*