更新
This commit is contained in:
@@ -23,16 +23,11 @@ function conversionPrivateMethod(string $name): ReflectionMethod
|
||||
|
||||
$buildFanCountRows = conversionPrivateMethod('buildFanCountRows');
|
||||
$fanRows = $buildFanCountRows->invoke(null, [
|
||||
['user_id' => 'alice', 'external_userid' => 'live'],
|
||||
['user_id' => 'alice', 'external_userid' => 'deleted'],
|
||||
['user_id' => 'alice', 'external_userid' => 'deleted'], // distinct pair only
|
||||
['user_id' => 'bob', 'external_userid' => 'deleted-only'],
|
||||
['user_id' => 'carol', 'external_userid' => 'delete-readded'],
|
||||
], [
|
||||
['user_id' => 'alice', 'external_userid' => 'live'],
|
||||
// A candidate pair that survived a delete/re-add cycle remains effective and is not deleted.
|
||||
['user_id' => 'carol', 'external_userid' => 'delete-readded'],
|
||||
['user_id' => 'nobody', 'external_userid' => 'not-a-candidate'],
|
||||
['add_event_id' => 1, 'user_id' => 'alice', 'external_userid' => 'shared-customer', 'is_deleted' => false],
|
||||
['add_event_id' => 2, 'user_id' => 'alice', 'external_userid' => 'shared-customer', 'is_deleted' => false], // same pair only once
|
||||
['add_event_id' => 3, 'user_id' => 'alice', 'external_userid' => 'deleted-only', 'is_deleted' => true],
|
||||
['add_event_id' => 4, 'user_id' => 'bob', 'external_userid' => 'shared-customer', 'is_deleted' => false],
|
||||
['add_event_id' => 5, 'user_id' => 'carol', 'external_userid' => 'live', 'is_deleted' => false],
|
||||
]);
|
||||
$fanRowsByUser = [];
|
||||
foreach ($fanRows as $row) {
|
||||
@@ -42,21 +37,17 @@ foreach ($fanRows as $row) {
|
||||
deletedFansExpect(
|
||||
($fanRowsByUser['alice']['add_fans_count'] ?? null) === 2
|
||||
&& ($fanRowsByUser['alice']['deleted_fans_count'] ?? null) === 1,
|
||||
'All distinct candidate pairs must count as add fans, with deleted fans retained as a subset'
|
||||
'The same employee/customer pair must count once, while another customer remains a separate pair'
|
||||
);
|
||||
deletedFansExpect(
|
||||
($fanRowsByUser['bob']['add_fans_count'] ?? null) === 1
|
||||
&& ($fanRowsByUser['bob']['deleted_fans_count'] ?? null) === 1,
|
||||
'A deleted-only candidate must count once in add fans and once in its deleted subset'
|
||||
&& ($fanRowsByUser['bob']['deleted_fans_count'] ?? null) === 0,
|
||||
'The same customer under a different employee must count as a new employee/customer pair'
|
||||
);
|
||||
deletedFansExpect(
|
||||
($fanRowsByUser['carol']['add_fans_count'] ?? null) === 1
|
||||
&& ($fanRowsByUser['carol']['deleted_fans_count'] ?? null) === 0,
|
||||
'A delete/re-add pair that remains effective at period end must not count as deleted'
|
||||
);
|
||||
deletedFansExpect(
|
||||
!isset($fanRowsByUser['nobody']),
|
||||
'Effective rows outside the source-filtered candidate set must not be counted'
|
||||
'A live add event must not count as deleted'
|
||||
);
|
||||
|
||||
$newEntityRow = conversionPrivateMethod('newEntityRow');
|
||||
@@ -146,10 +137,12 @@ deletedFansExpect(
|
||||
'Dual-role merging and virtual department buckets must propagate deleted_fans_count'
|
||||
);
|
||||
deletedFansExpect(
|
||||
str_contains($logicSource, 'applyHistoricalExternalUserChannelFilter')
|
||||
str_contains($logicSource, 'applyExternalUserEventChannelFilter')
|
||||
&& str_contains($logicSource, "'e.id'")
|
||||
&& str_contains($logicSource, "'e.user_id'")
|
||||
&& str_contains($logicSource, 'surviving_e.event_time >= ?')
|
||||
&& str_contains($logicSource, 'surviving_del.event_time >= surviving_e.event_time'),
|
||||
'Deleted pairs must use historical channel attribution and exclude pairs with a surviving re-add'
|
||||
&& str_contains($logicSource, 'surviving_del.event_time > surviving_e.event_time'),
|
||||
'Deleted pairs must use event/employee channel snapshots and recognize a surviving re-add'
|
||||
);
|
||||
|
||||
$pageSource = file_get_contents(
|
||||
|
||||
@@ -33,17 +33,32 @@ conversionFanRuleExpect(
|
||||
'会话存档同意是独立能力,不能再次成为加粉统计的硬性条件'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "'del_external_contact'")
|
||||
&& str_contains($methodSource, 'surviving_del.event_time >= surviving_e.event_time'),
|
||||
'加粉统计必须继续识别区间内已删除客户'
|
||||
str_contains($methodSource, 'surviving_del.change_type = ?')
|
||||
&& str_contains($methodSource, 'surviving_del.id > surviving_e.id'),
|
||||
'加粉统计必须继续识别员工+客户组合的期末删除状态'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, 'e.id AS add_event_id')
|
||||
&& str_contains($methodSource, 'e.event_time AS add_time'),
|
||||
'加粉统计必须保留组合最早新增事件用于渠道和时间归属'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, 'prev_e.user_id = e.user_id')
|
||||
&& str_contains($methodSource, 'prev_e.external_userid = e.external_userid')
|
||||
&& str_contains($methodSource, 'earlier_e.user_id = e.user_id')
|
||||
&& str_contains($methodSource, 'earlier_e.external_userid = e.external_userid'),
|
||||
'加粉统计必须以员工+客户为组合键,不能只按客户跨员工去重'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "['add_external_contact', \$startTimestamp]"),
|
||||
'加粉统计必须继续排除区间开始前已添加的重加客户'
|
||||
'同一员工+客户在区间开始前已经存在时不能再次算新增组合'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "->group('e.user_id, e.external_userid')"),
|
||||
'加粉统计必须继续按员工和客户去重'
|
||||
substr_count($methodSource, 'MediaChannelService::applyExternalUserEventChannelFilter(') === 2
|
||||
&& str_contains($methodSource, "'e.id'")
|
||||
&& str_contains($methodSource, "'e.external_userid'")
|
||||
&& str_contains($methodSource, "'e.user_id'"),
|
||||
'有效与已删加粉都必须按事件ID及同一员工读取渠道快照'
|
||||
);
|
||||
|
||||
$excludeMethod = new ReflectionMethod(ConversionLogic::class, 'excludeUncountedFanPairs');
|
||||
@@ -75,8 +90,9 @@ $pageSource = file_get_contents(
|
||||
conversionFanRuleExpect(
|
||||
is_string($pageSource)
|
||||
&& !str_contains($pageSource, '须会话同意')
|
||||
&& str_contains($pageSource, '区间新增加粉(含已删除)'),
|
||||
'页面口径须说明加粉包含已删除客户,且不能继续宣称依赖会话存档同意'
|
||||
&& str_contains($pageSource, '同一员工的同一客户只计一次')
|
||||
&& str_contains($pageSource, '同一客户进入不同员工分别计数'),
|
||||
'页面口径须说明按员工+客户去重且不同员工分别计数'
|
||||
);
|
||||
|
||||
echo "Conversion fan event rule: OK\n";
|
||||
|
||||
@@ -21,26 +21,25 @@ conversionFanDetailExpect(is_string($conversionSource), 'Unable to read Conversi
|
||||
$buildDetailRows = (new ReflectionClass(ConversionLogic::class))->getMethod('buildFanDetailRows');
|
||||
$buildDetailRows->setAccessible(true);
|
||||
$detailRows = $buildDetailRows->invoke(null, [
|
||||
['user_id' => 'staff-a', 'external_userid' => 'readded', 'add_time' => 100],
|
||||
['user_id' => 'staff-a', 'external_userid' => 'readded', 'add_time' => 200],
|
||||
['user_id' => 'staff-b', 'external_userid' => 'deleted', 'add_time' => 300],
|
||||
], [
|
||||
['user_id' => 'staff-a', 'external_userid' => 'readded', 'add_time' => 200],
|
||||
['add_event_id' => 10, 'user_id' => 'staff-a', 'external_userid' => 'shared', 'add_time' => 100, 'is_deleted' => false],
|
||||
['add_event_id' => 11, 'user_id' => 'staff-a', 'external_userid' => 'shared', 'add_time' => 200, 'is_deleted' => false],
|
||||
['add_event_id' => 12, 'user_id' => 'staff-b', 'external_userid' => 'shared', 'add_time' => 300, 'is_deleted' => true],
|
||||
]);
|
||||
conversionFanDetailExpect(
|
||||
count($detailRows) === 2
|
||||
&& (int) ($detailRows[0]['add_time'] ?? 0) === 100
|
||||
&& empty($detailRows[0]['is_deleted'])
|
||||
&& (string) ($detailRows[1]['user_id'] ?? '') === 'staff-b'
|
||||
&& !empty($detailRows[1]['is_deleted']),
|
||||
'Delete/re-add details must retain the first counted add time and final deletion state'
|
||||
'Details must deduplicate the same employee/customer while retaining the same customer under another employee'
|
||||
);
|
||||
conversionFanDetailExpect(
|
||||
str_contains($conversionSource, 'self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds)')
|
||||
&& str_contains($conversionSource, 'MIN(e.event_time) AS add_time')
|
||||
&& str_contains($conversionSource, 'e.id AS add_event_id')
|
||||
&& str_contains($conversionSource, "->where('e.event_time', 'between', [\$startTimestamp, \$endTimestamp])")
|
||||
&& str_contains($conversionSource, 'EXISTS (SELECT 1 FROM `')
|
||||
&& str_contains($conversionSource, 'MAX(event_time) AS delete_time'),
|
||||
'Aggregate and detail results must share the same distinct fan-pair loader and expose event times'
|
||||
'Aggregate and detail results must share the same employee/customer loader and expose event times'
|
||||
);
|
||||
conversionFanDetailExpect(
|
||||
str_contains($conversionSource, "'external_userid' => \$externalUserId")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\MediaChannelService;
|
||||
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
|
||||
use think\facade\Db;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
@@ -10,6 +11,13 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
if (!str_contains(
|
||||
(string) file_get_contents(dirname(__DIR__) . '/app/common/service/qywx/MediaChannelService.php'),
|
||||
'$tagIds === [] || !QywxExternalContactEventTagSnapshotService::installed()'
|
||||
)) {
|
||||
throw new RuntimeException('事件渠道快照缺表时未降级,代码先于迁移发布会导致接口 500');
|
||||
}
|
||||
|
||||
$tagQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$tagQuery,
|
||||
@@ -17,7 +25,8 @@ MediaChannelService::applyExternalUserChannelFilter(
|
||||
[
|
||||
'source_tag_id' => 'tag-regression-id',
|
||||
'source_tag_name' => '回归渠道',
|
||||
]
|
||||
],
|
||||
'e.user_id'
|
||||
);
|
||||
$tagSql = (string)$tagQuery->fetchSql()->select();
|
||||
if (!str_contains($tagSql, 'qywx_external_contact_tag')) {
|
||||
@@ -29,6 +38,9 @@ if (!str_contains($tagSql, 'EXISTS (SELECT 1 FROM')) {
|
||||
if (!str_contains($tagSql, 'channel_tag.external_userid = e.external_userid')) {
|
||||
throw new RuntimeException('tag 渠道未按事实表 external_userid 相关查询');
|
||||
}
|
||||
if (!str_contains($tagSql, 'channel_tag.follow_user_id = e.user_id')) {
|
||||
throw new RuntimeException('tag 渠道未限定为产生事件的同一企微员工');
|
||||
}
|
||||
if (!str_contains($tagSql, 'tag_id = ')) {
|
||||
throw new RuntimeException('单标签渠道应使用 tag_id = 走组合索引');
|
||||
}
|
||||
@@ -39,6 +51,21 @@ if (str_contains($tagSql, 'follow_users') || str_contains($tagSql, 'LIKE')) {
|
||||
throw new RuntimeException('tag 渠道仍在扫描 follow_users JSON');
|
||||
}
|
||||
|
||||
$customerLevelTagQuery = Db::name('order')->alias('o');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$customerLevelTagQuery,
|
||||
'o.payer_external_userid',
|
||||
[
|
||||
'source_tag_id' => 'tag-order-id',
|
||||
'source_tag_name' => '订单渠道',
|
||||
]
|
||||
);
|
||||
$customerLevelTagSql = (string)$customerLevelTagQuery->fetchSql()->select();
|
||||
if (!str_contains($customerLevelTagSql, 'channel_tag.external_userid = o.payer_external_userid')
|
||||
|| str_contains($customerLevelTagSql, 'channel_tag.follow_user_id')) {
|
||||
throw new RuntimeException('没有员工维度的订单事实不应被强行关联不存在的企微员工字段');
|
||||
}
|
||||
|
||||
$legacyQuery = Db::name('order')->alias('o');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$legacyQuery,
|
||||
@@ -65,7 +92,8 @@ MediaChannelService::applyExternalUserChannelFilter(
|
||||
'source_tag_ids' => ['tag-group-a', 'tag-group-b'],
|
||||
'channel_name' => '自媒体4',
|
||||
'is_group' => true,
|
||||
]
|
||||
],
|
||||
'e.user_id'
|
||||
);
|
||||
$groupSql = (string)$groupQuery->fetchSql()->select();
|
||||
if (!str_contains($groupSql, 'EXISTS (SELECT 1 FROM')) {
|
||||
@@ -74,6 +102,9 @@ if (!str_contains($groupSql, 'EXISTS (SELECT 1 FROM')) {
|
||||
if (!str_contains($groupSql, 'tag_id IN (')) {
|
||||
throw new RuntimeException('分组渠道未按多个 tag_id 过滤');
|
||||
}
|
||||
if (!str_contains($groupSql, 'channel_tag.follow_user_id = e.user_id')) {
|
||||
throw new RuntimeException('分组渠道未限定为产生事件的同一企微员工');
|
||||
}
|
||||
if (str_contains($groupSql, 'follow_users') || str_contains($groupSql, 'LIKE')) {
|
||||
throw new RuntimeException('分组渠道仍在扫描 follow_users JSON');
|
||||
}
|
||||
@@ -85,17 +116,86 @@ MediaChannelService::applyHistoricalExternalUserChannelFilter(
|
||||
[
|
||||
'source_tag_id' => 'deleted-fan-channel-id',
|
||||
'source_tag_name' => '已删粉丝渠道',
|
||||
]
|
||||
],
|
||||
'e.user_id'
|
||||
);
|
||||
$historicalSql = (string)$historicalQuery->fetchSql()->select();
|
||||
if (!str_contains($historicalSql, 'historical_channel_contact.external_userid = e.external_userid')) {
|
||||
throw new RuntimeException('已删粉丝渠道未按 external_userid 关联历史客户快照');
|
||||
}
|
||||
if (!str_contains($historicalSql, 'historical_channel_contact.follow_users LIKE')) {
|
||||
throw new RuntimeException('已删粉丝渠道未使用保留的 follow_users 快照');
|
||||
if (!str_contains($historicalSql, 'JSON_SEARCH')
|
||||
|| !str_contains($historicalSql, 'e.user_id')
|
||||
|| !str_contains($historicalSql, 'JSON_CONTAINS')) {
|
||||
throw new RuntimeException('已删粉丝渠道未在保留的 follow_users 中精确匹配事件员工标签');
|
||||
}
|
||||
if (str_contains($historicalSql, 'historical_channel_contact.delete_time IS NULL')) {
|
||||
throw new RuntimeException('已删粉丝渠道错误排除了软删客户');
|
||||
}
|
||||
|
||||
$installedProperty = (new ReflectionClass(QywxExternalContactEventTagSnapshotService::class))
|
||||
->getProperty('installed');
|
||||
$installedProperty->setAccessible(true);
|
||||
$installedProperty->setValue(null, true);
|
||||
|
||||
$eventSnapshotQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$eventSnapshotQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
[
|
||||
'source_tag_id' => '',
|
||||
'source_tag_ids' => ['tag-event-a', 'tag-event-b'],
|
||||
'is_group' => true,
|
||||
]
|
||||
);
|
||||
$eventSnapshotSql = (string)$eventSnapshotQuery->fetchSql()->select();
|
||||
foreach ([
|
||||
'qywx_external_contact_event_tag',
|
||||
'event_channel_tag.event_id = e.id',
|
||||
'event_channel_tag.follow_user_id = e.user_id',
|
||||
'captured_event_channel.event_id = e.id',
|
||||
"captured_event_channel.tag_id = ''",
|
||||
'channel_tag.follow_user_id = e.user_id',
|
||||
] as $needle) {
|
||||
if (!str_contains($eventSnapshotSql, $needle)) {
|
||||
throw new RuntimeException('新增事件渠道快照查询缺少条件:' . $needle);
|
||||
}
|
||||
}
|
||||
if (!str_contains($eventSnapshotSql, 'event_channel_tag.tag_id IN (')) {
|
||||
throw new RuntimeException('新增事件分组渠道未按多个快照 tag_id 过滤');
|
||||
}
|
||||
|
||||
$deletedEventSnapshotQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$deletedEventSnapshotQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
['source_tag_id' => 'tag-deleted-event'],
|
||||
true
|
||||
);
|
||||
$deletedEventSnapshotSql = (string)$deletedEventSnapshotQuery->fetchSql()->select();
|
||||
if (!str_contains($deletedEventSnapshotSql, 'event_channel_tag.event_id = e.id')
|
||||
|| !str_contains($deletedEventSnapshotSql, 'JSON_SEARCH')
|
||||
|| !str_contains($deletedEventSnapshotSql, 'JSON_CONTAINS')) {
|
||||
throw new RuntimeException('已删新增事件未优先用事件快照,并按同一员工执行老数据回退');
|
||||
}
|
||||
|
||||
$installedProperty->setValue(null, false);
|
||||
$missingSnapshotQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$missingSnapshotQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
['source_tag_id' => 'tag-before-migration']
|
||||
);
|
||||
$missingSnapshotSql = (string)$missingSnapshotQuery->fetchSql()->select();
|
||||
if (str_contains($missingSnapshotSql, 'qywx_external_contact_event_tag')
|
||||
|| !str_contains($missingSnapshotSql, 'channel_tag.follow_user_id = e.user_id')) {
|
||||
throw new RuntimeException('快照表未安装时没有安全降级到同员工当前标签口径');
|
||||
}
|
||||
$installedProperty->setValue(null, null);
|
||||
|
||||
echo "MEDIA_CHANNEL_EXTERNAL_USER_FILTER_OK\n";
|
||||
|
||||
@@ -45,10 +45,18 @@ foreach ([
|
||||
"array_key_exists('status', \$changes)",
|
||||
"array_key_exists('skip_verify', \$changes)",
|
||||
"array_key_exists('automation_config', \$changes)",
|
||||
"array_key_exists('member_status', \$changes)",
|
||||
'array_replace($currentAutomation, $automationPatch)',
|
||||
'self::savePool($saveParams, $adminId, $adminInfo, false)',
|
||||
'self::updatePoolMemberStatuses(',
|
||||
'self::previewPoolMemberStatus(',
|
||||
'self::assertMemberDispatchReady(',
|
||||
'if ($dispatch !== null && $status === 0)',
|
||||
'至少需要保留一名当前可用的上线员工',
|
||||
'员工上下线需要单独批量保存',
|
||||
"'sync_error_count' => \$syncErrorCount",
|
||||
"'sync_queued_count' => \$syncQueuedCount",
|
||||
"'member_updated' => \$memberUpdated",
|
||||
"'results' => \$results",
|
||||
] as $needle) {
|
||||
if (!str_contains($method, $needle)) {
|
||||
@@ -63,14 +71,37 @@ if ($permissionValidation === false || $writeLoop === false || $permissionValida
|
||||
if (!str_contains($method, "false\n );")) {
|
||||
throw new RuntimeException('批量修改不得允许共享操作人绕过原管理范围批量编辑');
|
||||
}
|
||||
if (!str_contains($method, "Db::name('qywx_promotion_range_sync')->where('pool_id', \$poolId)->lock(true)->find()")
|
||||
|| !str_contains($sources['logic'], "'_status_only' => true")
|
||||
|| !str_contains($sources['logic'], '$eligibleUserIds = self::eligibleSelectedUserIds(')
|
||||
|| !str_contains($sources['logic'], '$createdRangeChanged = $createdRemote')
|
||||
|| !str_contains($sources['logic'], '$needsQueuedSync = !$createdRemote || $createdRangeChanged')
|
||||
|| !str_contains($sources['logic'], '$rangeShrank = array_diff($currentRange[\'userids\'], $nextRange[\'userids\']) !== []')
|
||||
|| !str_contains($sources['logic'], 'if ($isGoingOffline || $rangeShrank)')) {
|
||||
throw new RuntimeException('单个与批量员工状态更新未使用一致的事务锁和字段保留策略');
|
||||
}
|
||||
|
||||
$page = $sources['page'];
|
||||
foreach ([
|
||||
'批量修改方案',
|
||||
'全选可管理的分流方案',
|
||||
'allManageablePoolsSelected',
|
||||
'someManageablePoolsSelected',
|
||||
'toggleAllPoolSelection',
|
||||
'仅勾选的项目会覆盖到所选方案',
|
||||
'batchConfigApply.skip_verify',
|
||||
'batchConfigApply.fallback_url',
|
||||
'batchConfigApply.status',
|
||||
'batchConfigApply.member_status',
|
||||
'batchConfigForm.member_admin_ids',
|
||||
'batchMemberStatusById',
|
||||
'batchMemberDepartmentTree',
|
||||
'batchMemberTreeDefaultExpandedKeys',
|
||||
'selectableDepartments: true',
|
||||
'勾选部门可全选其下员工',
|
||||
'batchMemberOfflineBlockedPools',
|
||||
'批量修改员工上线状态',
|
||||
'员工上下线需单独批量保存',
|
||||
'batchConfigApply.reception',
|
||||
'batchConfigApply.customer',
|
||||
'batchConfigApply.welcome',
|
||||
@@ -82,6 +113,10 @@ foreach ([
|
||||
throw new RuntimeException("前端批量修改交互缺少 {$needle}");
|
||||
}
|
||||
}
|
||||
if (!str_contains($sources['api'], 'member_status?: {')
|
||||
|| !str_contains($sources['api'], 'member_updated: number')) {
|
||||
throw new RuntimeException('前端批量修改 API 缺少员工上下线契约');
|
||||
}
|
||||
if (!str_contains($sources['automationForm'], 'disabledSections?: AutomationSection[]')
|
||||
|| !str_contains($sources['automationForm'], 'backupExcludedMemberIds?: number[]')) {
|
||||
throw new RuntimeException('自动化表单未支持批量场景的分组禁用或主成员冲突约束');
|
||||
|
||||
Reference in New Issue
Block a user