9.4 KiB
VR Ticket 票务商品字段校验方案
状态: ✅ 已实施 版本: 1.0.0 实施日期: 2026-05-18 负责人: 西莉雅、大头
目录
一、需求背景
当前票务商品存在以下隐患:
- 用户未填 batch_number_expire(演出日期),导致 session_meta 为空,前端无法展示倒计时和场次禁用逻辑
- 用户未填 coding(商品编号),导致 peer_goods 无法关联
- 未对 (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 模板预置
关键代码片段:
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 逻辑:
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 的extendsJSON 字段读取(由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 的返回情况来判断数据完整性:
// 检测数据完整性
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: 普通商品(非票务)会受影响吗?
不会。 票务相关逻辑的判断入口统一为:
$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