feat(vr_ticket): 票务商品 site_type 锁定为虚拟

票务商品的结算/拆单/加购流程强依赖虚拟商品类型(site_type=3)。
当商品设为票务(item_type=ticket)时,三层防御锁定 site_type:

1. UI 层(新增 AdminGoodsSaveForbid 钩子)
   监听 plugins_service_goods_base_forbid_operate_data,
   把 site_type 加入禁用列表,触发 saveinfo.html 第 228 行 disabled 渲染。

2. 服务端层(AdminGoodsSaveHandle)
   plugins_service_goods_save_handle 票务分支新增
   $params['data']['site_type'] = 3,强制覆盖。

3. 前端 JS 层(AdminGoodsSave)
   新增 syncSiteTypeLock(),watch(isTicket) 中调用,
   isTicket=true 强制 value=3 + disabled;
   isTicket=false 解除(仅移除插件添加的标记)。

仅在票务关闭(item_type=normal)后才允许修改 site_type。

风险等级:LOW(gitnexus detect_changes: 0 affected processes)
main
Council 2026-06-27 23:28:59 +08:00
parent 6fc5587145
commit 73e21da656
4 changed files with 121 additions and 0 deletions

View File

@ -47,6 +47,9 @@
"plugins_service_goods_save_thing_end": [
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"
],
"plugins_service_goods_base_forbid_operate_data": [
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveForbid"
],
"plugins_css_data": [
"app\\plugins\\vr_ticket\\hook\\ViewGoodsCss"
],

View File

@ -360,6 +360,50 @@ class AdminGoodsSave
}
};
// ── 商品类型 site_type 锁定控制(票务商品专用) ──
// 票务商品的结算/拆单/加购流程强依赖"虚拟"商品类型value=3
// · isTicket=true → 强制 site_type=3虚拟并 disabled 该 select
// · isTicket=false → 恢复可选,但不修改当前值(保留用户上次选择)
// 注:解除 disabled 时仅移除本插件添加的标记,避免破坏其他钩子的禁用控制
const SITE_TYPE_VIRTUAL_VALUE = 3; // 商品类型 = 虚拟
const syncSiteTypeLock = (locked) => {
const select = document.querySelector('select[name="site_type"]');
if (!select) return;
if (locked) {
// 强制选中"虚拟"
select.value = String(SITE_TYPE_VIRTUAL_VALUE);
// 标记并禁用(仅在尚未禁用时操作,避免覆盖其他钩子的禁用)
if (!select.hasAttribute('data-vr-ticket-locked')) {
select.setAttribute('data-vr-ticket-locked', '1');
// 记录原始 disabled 状态以便恢复
if (select.hasAttribute('disabled')) {
select.setAttribute('data-vr-ticket-prev-disabled', '1');
} else {
select.removeAttribute('data-vr-ticket-prev-disabled');
}
select.setAttribute('disabled', 'disabled');
}
// 触发 chosen 更新以反映新选中项
if (window.$ && $.fn.chosen && $(select).data('chosen')) {
$(select).trigger('chosen:updated');
}
} else {
// 解除禁用(仅解除本插件添加的)
if (select.getAttribute('data-vr-ticket-locked') === '1') {
select.removeAttribute('data-vr-ticket-locked');
if (!select.hasAttribute('data-vr-ticket-prev-disabled')) {
select.removeAttribute('disabled');
} else {
select.removeAttribute('data-vr-ticket-prev-disabled');
}
}
if (window.$ && $.fn.chosen && $(select).data('chosen')) {
$(select).trigger('chosen:updated');
}
}
};
// ── 原生字段强制必填控制(票务商品专用) ──
// 当 isTicket 勾选时:强制 batch_number_expire、coding、produce_region 为必填
// 当 isTicket 取消时:恢复原始状态(仅移除插件添加的 required
@ -483,6 +527,8 @@ class AdminGoodsSave
watch(isTicket, (val) => {
applyTicketRequired(val);
// 票务商品时锁定 site_type 为"虚拟";取消时解锁
syncSiteTypeLock(val);
if (val) {
// 票务商品模式:替换城市下拉选项
replaceCityOptions();

View File

@ -0,0 +1,66 @@
<?php
namespace app\plugins\vr_ticket\hook;
use think\facade\Db;
/**
* 后台商品保存页 字段禁用控制钩子
*
* 监听 ShopXO `plugins_service_goods_base_forbid_operate_data` 钩子。
* 当商品为票务商品item_type='ticket')时,把 site_type商品类型字段
* 加入禁用列表 `$data`,触发 saveinfo.html 228 行的 `disabled` 渲染。
*
* 为什么这样设计?
* - 票务商品的结算流程强依赖"虚拟"商品类型site_type=3
* 否则订单拆单、加入购物车逻辑会失效。
* - ShopXO 已提供原生扩展点,无需修改公共模板。
*
* 配合 AdminGoodsSaveHandle.php 中的服务端强制锁定,构成"双层防御"
* - UI 层(这里):禁用 select 控件
* - 服务端层AdminGoodsSaveHandle.php即使绕过前端也会强制 site_type=3
*/
class AdminGoodsSaveForbid
{
/**
* ShopXO 钩子入口
*
* @param array $params 钩子参数,包含:
* - hook_name : 钩子名称(应等于本钩子)
* - goods_id : 商品 ID0 = 新建)
* - goods : 商品数据(含 item_type 字段)
* - params : 输入参数
* - data : 引用传递,禁用字段列表
* @return void
*/
public function handle($params = [])
{
// 仅响应禁用字段控制钩子
if (($params['hook_name'] ?? '') !== 'plugins_service_goods_base_forbid_operate_data') {
return;
}
// 数据引用(禁用字段列表)
if (!isset($params['data']) || !is_array($params['data'])) {
return;
}
$data = &$params['data'];
// 判定是否为票务商品:优先用 $goods 数组,否则从 DB 查询
$isTicket = false;
$goodsId = intval($params['goods_id'] ?? 0);
$goods = $params['goods'] ?? [];
if (!empty($goods) && isset($goods['item_type'])) {
$isTicket = ($goods['item_type'] === 'ticket');
} elseif ($goodsId > 0) {
// 编辑场景下若 goods 未传完整,回查数据库(防御性兜底)
$itemType = Db::name('Goods')->where('id', $goodsId)->value('item_type');
$isTicket = ($itemType === 'ticket');
}
// 票务商品 → 禁用 site_type商品类型强制锁定为"虚拟"
if ($isTicket && !in_array('site_type', $data, true)) {
$data[] = 'site_type';
}
}
}

View File

@ -26,6 +26,12 @@ class AdminGoodsSaveHandle
$params['data']['item_type'] = 'ticket';
$params['data']['is_exist_many_spec'] = 1;
// ── 服务端强制锁定 site_type ──
// 票务商品的结算/拆单/加购流程强依赖"虚拟"商品类型value=3
// 即使前端绕过 disabled 控件或被恶意构造请求提交其他值,
// 这里也强制覆盖为 3确保数据完整性。
$params['data']['site_type'] = 3;
$base64Config = $postParams['vr_goods_config_base64'] ?? '';
if (!empty($base64Config)) {
$jsonStr = base64_decode($base64Config);