Files
zyt/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php
T
2026-09-09 12:18:17 +08:00

2269 lines
101 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\dept\DeptLogic;
use app\common\cache\AdminAuthCache;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
use app\common\service\qywx\QywxPromotionMemberRange;
use app\common\service\qywx\QywxPromotionConfig;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionOperatorAccess;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionWidgetService;
use RuntimeException;
use think\facade\Db;
use think\facade\Log;
/** 一诊 / 企业微信获客助手管理逻辑。 */
class WecomPromotionLogic
{
public static function overview(int $adminId, array $adminInfo, string $domain): array
{
self::assertMemberDispatchSchema();
self::assertPoolOperatorSchema();
$hasBasePagePermission = QywxPromotionOperatorAccess::hasBasePagePermission($adminId, $adminInfo);
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
$operatorPoolIds = self::operatorPoolIds($adminId);
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
->leftJoin('admin u', 'u.id = p.owner_admin_id')
->leftJoin('dept d', 'd.id = p.dept_id')
->whereNull('p.delete_time');
self::applyPoolAccessScope($poolsQuery, 'p', $visibleIds, $operatorPoolIds);
$pools = $poolsQuery
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.widget_config_json,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
->order('p.id', 'desc')
->select()->toArray();
$poolIds = array_values(array_filter(array_map('intval', array_column($pools, 'id'))));
$links = [];
if ($poolIds !== []) {
$links = Db::name('qywx_promotion_link')->alias('l')
->whereNull('l.delete_time')
->whereIn('l.pool_id', $poolIds)
->field('l.id,l.pool_id,l.name,l.group_name,l.wecom_url,l.remote_link_id,l.remote_status,l.remote_create_time,l.range_user_json,l.range_department_json,l.skip_verify,l.priority_option_json,l.last_sync_time,l.sync_error,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time')
->order('l.status', 'desc')
->order('l.weight', 'desc')
->order('l.id', 'desc')
->select()->toArray();
}
$domain = self::publicDomain($domain);
foreach ($pools as &$pool) {
$pool['automation_config'] = QywxPromotionConfig::forPool((int) $pool['id']);
$pool['widget_config'] = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
unset($pool['widget_config_json']);
$key = (string) $pool['public_key'];
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
$compatGoUrl = $domain . '/api/qywx-promotion/go/' . $key;
$pool['script_url'] = $scriptUrl;
$pool['compat_go_url'] = $compatGoUrl;
$pool['install_code'] = '<script src="'
. htmlspecialchars($scriptUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '" defer></script>';
}
unset($pool);
$today = date('Y-m-d');
$todayClicks = 0;
$onlineLinks = 0;
foreach ($links as &$link) {
$link['range_userids'] = self::decodeStringList($link['range_user_json'] ?? null);
$link['range_department_ids'] = self::decodeStringList($link['range_department_json'] ?? null);
$link['priority_option'] = self::decodeObject($link['priority_option_json'] ?? null);
$link['is_official'] = trim((string) ($link['remote_link_id'] ?? '')) !== '';
$link['valid_customer_acquisition_link'] = QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''));
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
$onlineLinks++;
}
}
unset($link);
$poolMemberAdminIds = self::poolMemberAdminIds($poolIds);
$memberOptions = self::memberOptions($adminId, $adminInfo, $poolMemberAdminIds);
$operatorOptions = self::operatorOptions($adminId, $adminInfo);
$operatorsByPool = self::poolOperators($poolIds);
$adminIdByUserId = [];
$memberOptionByAdminId = [];
foreach ($memberOptions as $member) {
$adminIdByUserId[(string) $member['userid']] = (int) $member['id'];
$memberOptionByAdminId[(int) $member['id']] = $member;
}
$linksByPool = [];
foreach ($links as $link) {
$linksByPool[(int) $link['pool_id']][] = $link;
}
self::backfillPoolMembers($pools, $linksByPool, $adminIdByUserId);
$memberRulesByPool = [];
$syncByPool = [];
if ($poolIds !== []) {
$memberRows = Db::name('qywx_promotion_pool_member')
->whereIn('pool_id', $poolIds)
->whereNull('delete_time')
->order('id', 'asc')
->select()->toArray();
foreach ($memberRows as $memberRow) {
$memberAdminId = (int) ($memberRow['admin_id'] ?? 0);
$option = $memberOptionByAdminId[$memberAdminId] ?? [];
$memberRow['name'] = (string) ($option['name'] ?? $memberRow['userid'] ?? '未知成员');
$memberRow['dept_names'] = array_values((array) ($option['dept_names'] ?? []));
$memberRow['status'] = (int) ($memberRow['enabled'] ?? 0);
$memberRow['today_count'] = (string) ($memberRow['today_date'] ?? '') === $today
? (int) ($memberRow['today_count'] ?? 0)
: 0;
$memberRulesByPool[(int) $memberRow['pool_id']][] = $memberRow;
$todayClicks += (int) $memberRow['today_count'];
}
foreach (Db::name('qywx_promotion_range_sync')->whereIn('pool_id', $poolIds)->select()->toArray() as $syncRow) {
$syncByPool[(int) $syncRow['pool_id']] = $syncRow;
}
}
foreach ($pools as &$pool) {
$poolLinks = $linksByPool[(int) $pool['id']] ?? [];
$officialLinks = array_values(array_filter(
$poolLinks,
static fn (array $link): bool => !empty($link['is_official'])
&& (int) ($link['remote_status'] ?? 0) !== 2
));
$legacyCount = count(array_filter(
$poolLinks,
static fn (array $link): bool => empty($link['is_official'])
));
// links 已按 id 倒序返回;旧多链接方案暂以最新官方链接作为主链接,
// 保存方案时会把其余链接仅在本地下线,保留远端链接与历史客户归因。
$officialLink = $officialLinks[0] ?? null;
$memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
$sync = $syncByPool[(int) $pool['id']] ?? [];
$remoteUserIds = array_fill_keys((array) ($officialLink['range_userids'] ?? []), true);
$reception = QywxPromotionMemberRange::evaluate($memberRules, $today, time(), $pool['automation_config']);
$availableUserIds = array_fill_keys($reception['userids'], true);
foreach ($memberRules as &$memberRule) {
$memberRule['is_backup'] = in_array((int) ($memberRule['admin_id'] ?? 0), $pool['automation_config']['backup_member_admin_ids'], true);
$memberRule['reception_available'] = isset($availableUserIds[(string) ($memberRule['userid'] ?? '')]);
$memberRule['is_current'] = false;
$memberRule['is_applied'] = false;
$memberRule['is_in_remote_range'] = isset($remoteUserIds[(string) ($memberRule['userid'] ?? '')]);
$memberRule['sync_status'] = (int) ($sync['status'] ?? 0);
$memberRule['sync_error'] = (string) ($sync['last_error'] ?? '');
}
unset($memberRule);
$memberAdminIds = [];
foreach ($memberRules as $memberRule) {
if ((int) ($memberRule['admin_id'] ?? 0) > 0) {
$memberAdminIds[] = (int) $memberRule['admin_id'];
}
}
$mainUrl = $officialLink === null
? ''
: QywxCustomerAcquisitionLinkService::withCustomerChannel(
(string) ($officialLink['wecom_url'] ?? ''),
'zyt_pool:' . (int) $pool['id']
);
$pool['official_link'] = $officialLink;
$pool['official_link_count'] = count($officialLinks);
$pool['legacy_link_count'] = $legacyCount;
$pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
$pool['member_admin_ids'] = array_values(array_diff($pool['member_admin_ids'], $pool['automation_config']['backup_member_admin_ids']));
$pool['member_rules'] = $memberRules;
$pool['operators'] = $operatorsByPool[(int) $pool['id']] ?? [];
$pool['operator_admin_ids'] = array_values(array_map(
static fn (array $operator): int => (int) $operator['id'],
$pool['operators']
));
$pool['is_shared_with_me'] = in_array($adminId, $pool['operator_admin_ids'], true);
$pool['can_operate'] = true;
$pool['can_manage_access'] = $hasBasePagePermission
&& self::ownerInScope((int) ($pool['owner_admin_id'] ?? 0), $visibleIds);
$pool['can_delete'] = $pool['can_manage_access'];
$pool['dispatch_sync'] = $sync;
$pool['using_backup'] = $reception['using_backup'];
$pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
$pool['migration_state'] = count($officialLinks) > 1
? 'needs_resolution'
: ($officialLink === null ? ($legacyCount > 0 ? 'legacy_only' : 'missing') : 'ready');
$pool['main_url'] = $mainUrl;
// go_url 现在代表可直接分享的企业微信官方链接;compat_go_url 保留旧 302 入口。
$pool['go_url'] = $mainUrl;
$pool['trigger_code'] = $mainUrl === '' ? '' : '<a href="'
. htmlspecialchars($mainUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '" data-wecom-promotion="' . (string) $pool['public_key'] . '">添加企业微信</a>';
}
unset($pool);
$config = self::internalApplicationStatus($domain);
return [
'meta' => [
'admin_id' => $adminId,
'scope_label' => $hasBasePagePermission
? DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo))
: '仅共享方案',
'generated_at' => date('Y-m-d H:i:s'),
],
'config' => $config,
'summary' => [
'configured_apps' => $config['ready'] ? 1 : 0,
'pool_count' => count($pools),
'online_links' => $onlineLinks,
'today_clicks' => $todayClicks,
],
'pools' => $pools,
'links' => $links,
'member_options' => $memberOptions,
'operator_options' => $operatorOptions,
'department_options' => $hasBasePagePermission
? DeptLogic::getAllDataScoped($adminId, $adminInfo)
: [],
'automation_installed' => QywxPromotionConfig::installed(),
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
public static function savePool(
array $params,
int $adminId,
array $adminInfo,
bool $syncImmediately = true
): array
{
self::assertMemberDispatchSchema();
$id = max(0, (int) ($params['id'] ?? 0));
if ($id <= 0 && !QywxPromotionOperatorAccess::hasBasePagePermission($adminId, $adminInfo)) {
throw new RuntimeException('共享操作人只能编辑已授权方案,不能新建分流方案');
}
$existingPool = $id > 0
? self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo)
: null;
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 60) {
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
}
$fallback = trim((string) ($params['fallback_url'] ?? ''));
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
}
$automation = null;
if (array_key_exists('automation_config', $params)) {
QywxPromotionConfig::assertInstalled();
if (!is_array($params['automation_config'])) {
throw new RuntimeException('获客配置格式不正确');
}
$automation = QywxPromotionConfig::normalize($params['automation_config']);
$automation = (new QywxPromotionMediaService())->validateConfig(
$automation, $adminId, $id > 0 ? QywxPromotionConfig::forPool($id) : []
);
if ($automation['tags_enabled']) {
$knownTags = [];
foreach ((new QywxPromotionContactApiService())->tagOptions()['tag_groups'] as $group) {
foreach ($group['tag'] as $tag) {
$knownTags[] = (string) $tag['id'];
}
}
if (array_diff($automation['tag_ids'], $knownTags) !== []) {
throw new RuntimeException('所选企业微信标签已删除或不在应用可用范围,请刷新标签后重新选择');
}
}
} elseif ($id > 0 && QywxPromotionConfig::installed()) {
$automation = QywxPromotionConfig::forPool($id);
}
$primaryIds = self::normalizePositiveIds((array) ($params['member_admin_ids'] ?? []));
$backupIds = $automation['backup_member_admin_ids'] ?? [];
if ($primaryIds === [] || array_intersect($primaryIds, $backupIds) !== []) {
throw new RuntimeException('请选择接待成员,且备用员工不能与接待成员重复');
}
$members = self::resolveMembers(array_merge($primaryIds, $backupIds), $adminId, $adminInfo, $id);
if ($automation !== null) {
$userIdByAdmin = array_column($members, 'userid', 'id');
$automation['backup_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $backupIds));
foreach ($automation['reception_schedule'] as &$slot) {
if (array_diff($slot['member_admin_ids'], $primaryIds) !== []) {
throw new RuntimeException('接待时段只能选择方案内的接待成员');
}
$slot['member_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $slot['member_admin_ids']));
}
unset($slot);
}
$userIds = array_values(array_column($members, 'userid'));
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members, $automation ?? []);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
$now = time();
$poolData = [
'name' => $name,
'status' => $status,
'fallback_url' => $fallback,
'update_time' => $now,
];
$existingLink = null;
if ($existingPool !== null) {
$existingLink = Db::name('qywx_promotion_link')
->where('pool_id', $id)
->whereNull('delete_time')
->where('remote_link_id', '<>', '')
->where('remote_status', '<>', 2)
->order('id', 'desc')
->find();
}
$api = new QywxCustomerAcquisitionApiService();
$createdRemote = false;
$remote = null;
if ($existingLink === null) {
$payload = [
'link_name' => mb_substr($name, 0, 30),
// 一个官方直链同时绑定全部当前可用医助,由企业微信原生多人路由直接分配。
'range' => ['user_list' => $eligibleUserIds],
'skip_verify' => $skipVerify === 1,
];
$created = $api->createLink($payload);
$remoteLinkId = self::extractRemoteLinkId($created);
if ($remoteLinkId === '') {
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请执行“同步企业微信”确认结果');
}
$createdRemote = true;
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)
|| $remote['range_department_ids'] !== []) {
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
}
} catch (\Throwable $e) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 保留详情读取的原始异常;孤立链接仍可通过企业微信同步找回。
}
throw $e;
}
} else {
$remoteLinkId = trim((string) ($existingLink['remote_link_id'] ?? ''));
}
$remoteData = $remote !== null ? self::remoteColumns($remote, $now) : [];
$linkId = (int) ($existingLink['id'] ?? 0);
try {
Db::transaction(function () use (
&$id,
&$linkId,
$poolData,
$remoteData,
$status,
$existingPool,
$members,
$automation,
$skipVerify,
$createdRemote,
&$eligibleUserIds,
$now,
$adminId
): void {
if ($existingPool !== null) {
$lockedPool = Db::name('qywx_promotion_pool')
->where('id', $id)
->whereNull('delete_time')
->lock(true)
->find();
if (!$lockedPool) {
throw new RuntimeException('分流方案不存在或已删除');
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->lock(true)->find();
$lockedRules = Db::name('qywx_promotion_pool_member')
->where('pool_id', $id)
->order('id', 'asc')
->lock(true)
->select()->toArray();
$lockedActiveRules = array_values(array_filter(
$lockedRules,
static fn (array $rule): bool => ($rule['delete_time'] ?? null) === null
));
// 用锁内最新状态再校验最终成员集合,避免与单个或批量下线并发后留下空范围。
$eligibleUserIds = self::eligibleSelectedUserIds(
$id,
$members,
$automation ?? [],
$lockedActiveRules
);
}
if ($existingPool === null) {
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [
'public_key' => bin2hex(random_bytes(16)),
'owner_admin_id' => $adminId,
'dept_id' => self::primaryDeptId($adminId),
'click_count' => 0,
'create_time' => $now,
]);
} else {
Db::name('qywx_promotion_pool')->where('id', $id)->update($poolData);
}
$linkCommon = [
'pool_id' => $id,
'name' => mb_substr((string) $poolData['name'], 0, 30),
'group_name' => '方案官方链接',
'status' => $status,
'skip_verify' => $skipVerify,
'update_time' => $now,
];
if ($linkId > 0) {
Db::name('qywx_promotion_link')->where('id', $linkId)->update($remoteData + $linkCommon);
} else {
$linkId = (int) Db::name('qywx_promotion_link')->insertGetId($remoteData + $linkCommon + [
'account_id' => 0,
'weight' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'active_start' => 0,
'active_end' => 0,
'click_count' => 0,
'last_click_time' => 0,
'owner_admin_id' => (int) ($existingPool['owner_admin_id'] ?? 0) ?: $adminId,
'dept_id' => (int) ($existingPool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
'remark' => '',
'create_time' => $now,
]);
}
self::persistPoolMembers($id, $members, $now);
if ($automation !== null) {
QywxPromotionConfig::save($id, $automation);
}
if ($createdRemote) {
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId);
}
// 旧多链接只在本地下线,企业微信远端与历史客户归因继续保留。
Db::name('qywx_promotion_link')
->where('pool_id', $id)
->where('id', '<>', $linkId)
->whereNull('delete_time')
->update(['status' => 0, 'update_time' => $now]);
});
} catch (\Throwable $e) {
if ($createdRemote) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 企业微信补偿失败时保留原始异常;可通过官方列表找回孤立链接。
}
}
throw $e;
}
$syncError = '';
// 已有方案补建官方链接时,创建接口发生在事务锁之前;若锁内成员范围已变化,
// 仍需把最终范围加入同步队列,避免新链接停留在过期的远端成员集合。
$createdRangeChanged = $createdRemote
&& $remote !== null
&& !QywxPromotionMemberRange::same((array) ($remote['range_userids'] ?? []), $eligibleUserIds);
$needsQueuedSync = !$createdRemote || $createdRangeChanged;
if ($needsQueuedSync) {
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
if ($syncImmediately) {
try {
(new QywxPromotionRangeSyncService())->syncPool($id);
} catch (\Throwable $e) {
// 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。
$syncError = $e->getMessage();
}
}
}
$savedLink = Db::name('qywx_promotion_link')->where('id', $linkId)->find() ?: [];
$savedUrl = (string) ($savedLink['wecom_url'] ?? $remote['url'] ?? '');
return [
'id' => $id,
'remote_link_id' => (string) ($savedLink['remote_link_id'] ?? $remoteLinkId),
'wecom_url' => $savedUrl,
'main_url' => QywxCustomerAcquisitionLinkService::withCustomerChannel(
$savedUrl,
'zyt_pool:' . $id
),
'member_userids' => $userIds,
'range_userids' => $eligibleUserIds,
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
'automation_saved' => $automation !== null,
] + self::memberSyncResult($id, $syncError);
}
/**
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;方案配置仍复用
* savePool 的成员、自动化、素材和企业微信同步校验,员工状态按方案合并更新。
*
* @return array{pool_ids:list<int>,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,member_matched:int,member_updated:int,results:list<array<string,mixed>>}
*/
public static function batchUpdatePools(array $params, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
self::assertBasePagePermission($adminId, $adminInfo);
$poolIds = self::normalizePositiveIds((array) ($params['pool_ids'] ?? []));
if ($poolIds === []) {
throw new RuntimeException('请至少选择一个分流方案');
}
if (count($poolIds) > 100) {
throw new RuntimeException('单次最多设置 100 个分流方案');
}
$changes = $params['changes'] ?? null;
if (!is_array($changes)) {
throw new RuntimeException('批量修改内容格式不正确');
}
$allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config', 'member_status'];
$unknownFields = array_diff(array_keys($changes), $allowedFields);
if ($unknownFields !== []) {
throw new RuntimeException('批量修改包含不支持的字段');
}
if ($changes === []) {
throw new RuntimeException('请至少选择一项需要批量修改的配置');
}
if (array_key_exists('fallback_url', $changes) && !is_string($changes['fallback_url'])) {
throw new RuntimeException('兜底获客助手链接格式不正确');
}
$memberStatusPatch = null;
if (array_key_exists('member_status', $changes)) {
if (!is_array($changes['member_status'])) {
throw new RuntimeException('员工上下线配置格式不正确');
}
if (array_diff(array_keys($changes['member_status']), ['member_admin_ids', 'status']) !== []) {
throw new RuntimeException('员工上下线配置包含不支持的字段');
}
$memberAdminIds = self::normalizePositiveIds((array) ($changes['member_status']['member_admin_ids'] ?? []));
if ($memberAdminIds === []) {
throw new RuntimeException('请至少选择一名需要批量上线或下线的员工');
}
if (count($memberAdminIds) > 100) {
throw new RuntimeException('单次最多设置 100 名员工');
}
$memberStatus = $changes['member_status']['status'] ?? null;
if (!in_array($memberStatus, [0, 1, '0', '1'], true)) {
throw new RuntimeException('员工上线状态只能为上线或下线');
}
$memberStatusPatch = [
'member_admin_ids' => $memberAdminIds,
'status' => (int) $memberStatus,
];
}
$automationPatch = null;
if (array_key_exists('automation_config', $changes)) {
if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) {
throw new RuntimeException('自动化配置格式不正确');
}
$allowedAutomationFields = [
'reception_mode', 'reception_schedule', 'backup_member_admin_ids',
'tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template',
'description_enabled', 'description', 'welcome_mode', 'welcome',
'welcome_schedule_enabled', 'welcome_schedule',
];
if (array_diff(array_keys($changes['automation_config']), $allowedAutomationFields) !== []) {
throw new RuntimeException('自动化配置包含不支持的字段');
}
QywxPromotionConfig::assertInstalled();
$automationPatch = $changes['automation_config'];
}
if ($memberStatusPatch !== null && count($changes) > 1) {
throw new RuntimeException('员工上下线需要单独批量保存,请勿与其他方案配置同时修改');
}
// 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。
$pools = [];
foreach ($poolIds as $poolId) {
$pools[$poolId] = self::assertScopedRow(
'qywx_promotion_pool',
$poolId,
$adminId,
$adminInfo,
false
);
}
// 在任何方案写入前验证员工范围和“至少一名上线员工”约束,避免可预见的部分更新。
if ($memberStatusPatch !== null) {
$memberRowsByPool = [];
foreach (Db::name('qywx_promotion_pool_member')
->whereIn('pool_id', $poolIds)
->whereNull('delete_time')
->select()->toArray() as $memberRow) {
$memberRowsByPool[(int) ($memberRow['pool_id'] ?? 0)][] = $memberRow;
}
$matchedMembers = 0;
foreach ($poolIds as $poolId) {
$simulatedAutomation = QywxPromotionConfig::forPool($poolId);
if ($automationPatch !== null) {
$simulatedAutomation = QywxPromotionConfig::normalize(array_replace(
$simulatedAutomation,
$automationPatch
));
}
$poolMembers = $memberRowsByPool[$poolId] ?? [];
$simulatedAutomation = self::automationWithMemberUserIds($simulatedAutomation, $poolMembers);
$preview = self::previewPoolMemberStatus(
$poolMembers,
$memberStatusPatch['member_admin_ids'],
$memberStatusPatch['status'],
$simulatedAutomation,
(string) ($pools[$poolId]['name'] ?? ('#' . $poolId))
);
$matchedMembers += count($preview['matched_ids']);
}
if ($matchedMembers === 0) {
throw new RuntimeException('所选方案中没有找到指定员工,请刷新页面后重试');
}
}
$results = [];
$updated = 0;
$failed = 0;
$syncErrorCount = 0;
$syncQueuedCount = 0;
$memberMatched = 0;
$memberUpdated = 0;
$hasPoolConfigChanges = array_diff(array_keys($changes), ['member_status']) !== [];
foreach ($poolIds as $poolId) {
$pool = $pools[$poolId];
$currentAutomation = QywxPromotionConfig::forPool($poolId);
$memberAdminIds = self::normalizePositiveIds(Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->whereNull('delete_time')
->column('admin_id'));
$primaryMemberAdminIds = array_values(array_diff(
$memberAdminIds,
self::normalizePositiveIds((array) ($currentAutomation['backup_member_admin_ids'] ?? []))
));
$officialLink = Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->whereNull('delete_time')
->where('remote_link_id', '<>', '')
->where('remote_status', '<>', 2)
->order('id', 'desc')
->find() ?: [];
$saveParams = [
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'fallback_url' => array_key_exists('fallback_url', $changes)
? trim($changes['fallback_url'])
: (string) ($pool['fallback_url'] ?? ''),
'status' => array_key_exists('status', $changes)
? ((int) $changes['status'] === 1 ? 1 : 0)
: (int) ($pool['status'] ?? 0),
'member_admin_ids' => $primaryMemberAdminIds,
'skip_verify' => array_key_exists('skip_verify', $changes)
? ((int) $changes['skip_verify'] === 1 ? 1 : 0)
: (int) ($officialLink['skip_verify'] ?? 0),
];
if ($automationPatch !== null) {
$saveParams['automation_config'] = array_replace($currentAutomation, $automationPatch);
}
try {
$saved = ['sync_error' => '', 'sync_queued' => false];
if ($hasPoolConfigChanges) {
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。
$saved = self::savePool($saveParams, $adminId, $adminInfo, false);
}
$poolMemberResult = ['matched' => 0, 'updated' => 0, 'dispatch' => null];
if ($memberStatusPatch !== null) {
$poolMemberResult = self::updatePoolMemberStatuses(
$poolId,
$memberStatusPatch['member_admin_ids'],
$memberStatusPatch['status']
);
$memberMatched += $poolMemberResult['matched'];
$memberUpdated += $poolMemberResult['updated'];
}
$syncResult = self::memberSyncResult($poolId, (string) ($saved['sync_error'] ?? ''));
$syncError = $syncResult['sync_error'];
$updated++;
if ($syncError !== '') {
$syncErrorCount++;
}
$syncQueued = $syncResult['sync_queued'];
if ($syncQueued) {
$syncQueuedCount++;
}
$results[] = [
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'success' => true,
'sync_status' => $syncResult['sync_status'],
'sync_error' => $syncError,
'sync_queued' => $syncQueued,
'member_matched' => $poolMemberResult['matched'],
'member_updated' => $poolMemberResult['updated'],
];
} catch (\Throwable $error) {
$failed++;
$results[] = [
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'success' => false,
'error' => $error->getMessage(),
];
}
}
return [
'pool_ids' => $poolIds,
'updated' => $updated,
'failed' => $failed,
'sync_error_count' => $syncErrorCount,
'sync_queued_count' => $syncQueuedCount,
'member_matched' => $memberMatched,
'member_updated' => $memberUpdated,
'results' => $results,
];
}
/**
* @param list<int> $memberAdminIds
* @return array{matched:int,updated:int,dispatch:?array}
*/
private static function updatePoolMemberStatuses(int $poolId, array $memberAdminIds, int $status): array
{
return Db::transaction(function () use ($poolId, $memberAdminIds, $status): array {
// 先锁方案阻止同方案获客回调进入,再按“同步任务 -> 成员规则”顺序加锁。
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$pool) {
throw new RuntimeException('分流方案不存在或已删除');
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
$members = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->whereNull('delete_time')
->lock(true)
->select()->toArray();
$preview = self::previewPoolMemberStatus(
$members,
$memberAdminIds,
$status,
QywxPromotionConfig::forPool($poolId)
);
$matchedIds = $preview['matched_ids'];
$updateIds = $preview['update_ids'];
if ($updateIds !== []) {
Db::name('qywx_promotion_pool_member')->whereIn('id', $updateIds)->update([
'enabled' => $status,
'update_time' => time(),
]);
}
// 状态相同也需要重算:上次保存可能只入队,远端仍保留已下线成员。
$dispatch = $matchedIds !== []
? QywxPromotionMemberSchedulerService::reconcilePool($poolId)
: null;
// 下线必须能够安全收缩远端范围;上线即使尚未到生效时段,也应先保存规则。
if ($dispatch !== null && $status === 0) {
self::assertMemberDispatchReady($dispatch);
}
return [
'matched' => count($matchedIds),
'updated' => count($updateIds),
'dispatch' => $dispatch,
];
});
}
/**
* @param list<array<string,mixed>> $members
* @param list<int> $memberAdminIds
* @param array<string,mixed> $automation
* @return array{members:list<array<string,mixed>>,matched_ids:list<int>,update_ids:list<int>}
*/
private static function previewPoolMemberStatus(
array $members,
array $memberAdminIds,
int $status,
array $automation,
string $poolName = ''
): array {
$targetAdminIds = array_fill_keys($memberAdminIds, true);
$matchedIds = [];
$updateIds = [];
$targetedEnabled = 0;
$remainingEnabled = 0;
foreach ($members as &$member) {
$isTarget = isset($targetAdminIds[(int) ($member['admin_id'] ?? 0)]);
if ($isTarget) {
$matchedIds[] = (int) ($member['id'] ?? 0);
if ((int) ($member['enabled'] ?? 0) !== $status) {
$updateIds[] = (int) ($member['id'] ?? 0);
}
}
if ((int) ($member['enabled'] ?? 0) === 1) {
if ($isTarget) {
$targetedEnabled++;
} else {
$remainingEnabled++;
}
}
if ($isTarget) {
$member['enabled'] = $status;
}
}
unset($member);
if ($status === 0 && $targetedEnabled > 0 && $remainingEnabled === 0) {
throw new RuntimeException(self::memberStatusError(
$poolName,
'至少需要保留一名上线员工'
));
}
if ($status === 0 && $targetedEnabled > 0) {
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), $automation);
if ($range['userids'] === []) {
throw new RuntimeException(self::memberStatusError(
$poolName,
'至少需要保留一名当前可用的上线员工'
));
}
}
return ['members' => $members, 'matched_ids' => $matchedIds, 'update_ids' => $updateIds];
}
private static function memberStatusError(string $poolName, string $message): string
{
return $poolName === '' ? $message : sprintf('分流方案“%s”%s', $poolName, $message);
}
/** @param array<string,mixed> $dispatch */
private static function assertMemberDispatchReady(array $dispatch): void
{
if (!empty($dispatch['blocked'])) {
throw new RuntimeException('企业微信成员范围当前无法同步,请先检查官方链接状态和可用员工');
}
}
/** @param array<string,mixed> $automation @param list<array<string,mixed>> $members */
private static function automationWithMemberUserIds(array $automation, array $members): array
{
$userIdByAdminId = [];
foreach ($members as $member) {
$adminId = (int) ($member['admin_id'] ?? 0);
$userId = trim((string) ($member['userid'] ?? ''));
if ($adminId > 0 && $userId !== '') {
$userIdByAdminId[$adminId] = $userId;
}
}
$automation['backup_userids'] = array_values(array_filter(array_map(
static fn ($adminId): string => $userIdByAdminId[(int) $adminId] ?? '',
(array) ($automation['backup_member_admin_ids'] ?? [])
)));
foreach ((array) ($automation['reception_schedule'] ?? []) as $index => $slot) {
$automation['reception_schedule'][$index]['member_userids'] = array_values(array_filter(array_map(
static fn ($adminId): string => $userIdByAdminId[(int) $adminId] ?? '',
(array) ($slot['member_admin_ids'] ?? [])
)));
}
return $automation;
}
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0));
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
$input = $params['widget_config'] ?? $params;
$config = QywxPromotionWidgetService::fromInput($input);
Db::name('qywx_promotion_pool')->where('id', $id)->update([
'widget_config_json' => QywxPromotionWidgetService::encode($config),
'update_time' => time(),
]);
return ['id' => $id, 'widget_config' => $config];
}
public static function deletePool(
int $id,
int $adminId,
array $adminInfo,
?QywxCustomerAcquisitionApiService $api = null
): void
{
self::assertBasePagePermission($adminId, $adminInfo);
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo, false);
$operatorAdminIds = self::normalizePositiveIds(Db::name('qywx_promotion_pool_operator')
->where('pool_id', $id)
->whereNull('delete_time')
->column('admin_id'));
$links = Db::name('qywx_promotion_link')
->where('pool_id', $id)
->order('id', 'asc')
->select()->toArray();
$now = time();
$remoteLinks = array_values(array_filter($links, static fn (array $link): bool =>
trim((string) ($link['remote_link_id'] ?? '')) !== ''
&& (int) ($link['remote_status'] ?? 0) !== 2
));
if ($remoteLinks !== []) {
// 使用方案级删除租约阻止双击、重复请求以及回调/分钟任务继续更新远端范围。
$deleteToken = bin2hex(random_bytes(16));
Db::transaction(function () use ($id, $remoteLinks, $deleteToken, $now): void {
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->lock(true)->find();
if ($sync && (int) ($sync['status'] ?? 0) === 5 && (int) ($sync['lock_until'] ?? 0) > $now) {
throw new RuntimeException('该分流方案正在删除,请勿重复提交');
}
$data = [
'promotion_link_id' => (int) ($sync['promotion_link_id'] ?? $remoteLinks[0]['id'] ?? 0),
'desired_member_id' => 0,
'desired_version' => max(1, (int) ($sync['desired_version'] ?? 0) + 1),
'status' => 5,
'next_retry' => 0,
'lock_token' => $deleteToken,
'lock_until' => $now + 300,
'last_error' => '分流方案正在删除企业微信官方链接',
'update_time' => $now,
];
if ($sync) {
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update($data);
} else {
Db::name('qywx_promotion_range_sync')->insert($data + [
'pool_id' => $id,
'applied_member_id' => 0,
'applied_version' => 0,
'attempts' => 0,
'create_time' => $now,
]);
}
});
$api ??= new QywxCustomerAcquisitionApiService();
foreach ($remoteLinks as $link) {
$linkId = (int) ($link['id'] ?? 0);
$remoteLinkId = trim((string) ($link['remote_link_id'] ?? ''));
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable $e) {
// 删除是幂等操作:企业微信明确返回 invalid link_id 时,远端目标已不存在
// 或已不再属于当前应用,继续完成本地清理;其他错误仍保留方案以便重试。
if (!self::isRemoteLinkAlreadyMissing($e)) {
$message = '企业微信官方获客链接删除失败,本地方案已保留:' . $e->getMessage();
Db::name('qywx_promotion_link')->where('id', $linkId)->update([
'sync_error' => mb_substr($message, 0, 500),
'update_time' => time(),
]);
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->where('lock_token', $deleteToken)->update([
'status' => 4,
'next_retry' => 0,
'lock_token' => '',
'lock_until' => 0,
'last_error' => mb_substr($message, 0, 500),
'update_time' => time(),
]);
throw new RuntimeException($message, 0, $e);
}
}
// 多个历史官方链接部分成功时也保存进度,用户重试删除不会再次请求已删除链接。
Db::name('qywx_promotion_link')->where('id', $linkId)->update([
'status' => 0,
'remote_status' => 2,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
}
}
Db::transaction(function () use ($id, $now): void {
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update([
'status' => 0,
'delete_time' => $now,
'update_time' => $now,
]);
Db::name('qywx_promotion_pool_member')->where('pool_id', $id)->whereNull('delete_time')->update([
'enabled' => 0,
'delete_time' => $now,
'update_time' => $now,
]);
Db::name('qywx_promotion_pool_operator')->where('pool_id', $id)->whereNull('delete_time')->update([
'delete_time' => $now,
'update_time' => $now,
]);
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update([
'status' => 4,
'next_retry' => 0,
'lock_token' => '',
'lock_until' => 0,
'last_error' => '分流方案及企业微信官方链接已删除',
'update_time' => $now,
]);
});
self::clearOperatorAuthCaches($operatorAdminIds);
}
/**
* 批量添加或移除分流方案共享操作人。
* 共享操作人可访问并编辑方案;删除方案和继续授权仍受 owner 数据范围控制。
*
* @return array{action:string,pool_ids:list<int>,operator_admin_ids:list<int>,affected:int}
*/
public static function batchSetOperators(array $params, int $adminId, array $adminInfo): array
{
self::assertPoolOperatorSchema();
self::assertBasePagePermission($adminId, $adminInfo);
$poolIds = self::normalizePositiveIds((array) ($params['pool_ids'] ?? []));
$operatorAdminIds = self::normalizePositiveIds((array) (
$params['operator_admin_ids'] ?? $params['admin_ids'] ?? []
));
$action = strtolower(trim((string) ($params['action'] ?? 'grant')));
if (!in_array($action, ['grant', 'revoke'], true)) {
throw new RuntimeException('批量设置方式仅支持添加或移除操作人');
}
if ($poolIds === []) {
throw new RuntimeException('请至少选择一个分流方案');
}
if (count($poolIds) > 100) {
throw new RuntimeException('单次最多设置 100 个分流方案');
}
if ($operatorAdminIds === []) {
throw new RuntimeException('请至少选择一名操作人');
}
if (count($operatorAdminIds) > 100) {
throw new RuntimeException('单次最多设置 100 名操作人');
}
if (in_array($adminId, $operatorAdminIds, true)) {
throw new RuntimeException('不能将当前账号设置为自己的共享操作人');
}
foreach ($poolIds as $poolId) {
self::assertScopedRow(
'qywx_promotion_pool',
$poolId,
$adminId,
$adminInfo,
false
);
}
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
if ($visibleIds !== null) {
foreach ($operatorAdminIds as $operatorAdminId) {
if (!in_array($operatorAdminId, $visibleIds, true)) {
throw new RuntimeException('选择的操作人超出当前角色或部门的数据范围');
}
}
}
$adminQuery = Db::name('admin')->whereIn('id', $operatorAdminIds)->whereNull('delete_time');
if ($action === 'grant') {
$adminQuery->where('disable', 0);
}
$operatorAdmins = $adminQuery->field('id')->select()->toArray();
$existingAdminIds = self::normalizePositiveIds(array_column($operatorAdmins, 'id'));
if (count($existingAdminIds) !== count($operatorAdminIds)) {
throw new RuntimeException($action === 'grant'
? '选择的操作人不存在或账号已被禁用'
: '选择的操作人不存在');
}
$now = time();
$affected = Db::transaction(function () use (
$action,
$poolIds,
$operatorAdminIds,
$adminId,
$now
): int {
$changed = 0;
foreach ($poolIds as $poolId) {
foreach ($operatorAdminIds as $operatorAdminId) {
$query = Db::name('qywx_promotion_pool_operator')
->where('pool_id', $poolId)
->where('admin_id', $operatorAdminId);
$existing = (clone $query)->lock(true)->find();
if ($action === 'grant') {
$data = [
'granted_by_admin_id' => $adminId,
'delete_time' => null,
'update_time' => $now,
];
if ($existing) {
if ($existing['delete_time'] !== null) {
$changed++;
}
$query->update($data);
} else {
Db::name('qywx_promotion_pool_operator')->insert($data + [
'pool_id' => $poolId,
'admin_id' => $operatorAdminId,
'create_time' => $now,
]);
$changed++;
}
continue;
}
if ($existing && $existing['delete_time'] === null) {
$query->update(['delete_time' => $now, 'update_time' => $now]);
$changed++;
}
}
}
return $changed;
});
self::clearOperatorAuthCaches($operatorAdminIds);
return [
'action' => $action,
'pool_ids' => $poolIds,
'operator_admin_ids' => $operatorAdminIds,
'affected' => $affected,
];
}
private static function isRemoteLinkAlreadyMissing(\Throwable $error): bool
{
$message = strtolower($error->getMessage());
$isInvalidLinkId = str_contains($message, 'invalid link_id');
$isInvalidParameter = (int) $error->getCode() === 40058
|| str_contains($message, '[40058]');
return $isInvalidParameter && $isInvalidLinkId;
}
public static function saveMember(array $params, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
$id = max(0, (int) ($params['id'] ?? 0));
$member = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
if (!$member) {
throw new RuntimeException('分流成员不存在或已移除');
}
$poolId = (int) $member['pool_id'];
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0;
$statusOnly = !empty($params['_status_only']);
$startAt = $statusOnly ? 0 : self::parseTime($params['active_start'] ?? null);
$endAt = $statusOnly ? 0 : self::parseTime($params['active_end'] ?? null);
if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
throw new RuntimeException('生效结束时间必须晚于开始时间');
}
$now = time();
$planned = Db::transaction(function () use (
$id,
$poolId,
$enabled,
$statusOnly,
$params,
$startAt,
$endAt,
$now
): array {
// 所有成员状态写入口使用相同锁顺序,防止单个与批量下线并发绕过保底校验。
$lockedPool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$lockedPool) {
throw new RuntimeException('分流方案不存在或已删除');
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
$members = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->whereNull('delete_time')
->order('id', 'asc')
->lock(true)
->select()->toArray();
$lockedMember = null;
foreach ($members as $current) {
if ((int) ($current['id'] ?? 0) === $id) {
$lockedMember = $current;
break;
}
}
if ($lockedMember === null) {
throw new RuntimeException('分流成员不存在或已移除');
}
$memberData = [
'enabled' => $enabled,
// 企业微信原生多人路由不支持逐成员权重;字段固定为 1,仅兼容已部署表结构。
'weight' => 1,
'daily_limit' => $statusOnly
? (int) ($lockedMember['daily_limit'] ?? 0)
: min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'active_start' => $statusOnly ? (int) ($lockedMember['active_start'] ?? 0) : $startAt,
'active_end' => $statusOnly ? (int) ($lockedMember['active_end'] ?? 0) : $endAt,
'remark' => $statusOnly
? (string) ($lockedMember['remark'] ?? '')
: mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
'update_time' => $now,
];
$isGoingOffline = (int) ($lockedMember['enabled'] ?? 0) === 1 && $enabled === 0;
$simulatedMembers = $members;
$enabledAfter = 0;
foreach ($simulatedMembers as &$simulatedMember) {
if ((int) ($simulatedMember['id'] ?? 0) === $id) {
$simulatedMember = array_replace($simulatedMember, $memberData);
}
if ((int) ($simulatedMember['enabled'] ?? 0) === 1) {
$enabledAfter++;
}
}
unset($simulatedMember);
if ($isGoingOffline) {
if ($enabledAfter === 0) {
throw new RuntimeException('至少需要保留一名启用的获客医助');
}
}
$today = date('Y-m-d', $now);
$automation = QywxPromotionConfig::forPool($poolId);
$currentRange = QywxPromotionMemberRange::evaluate($members, $today, $now, $automation);
$nextRange = QywxPromotionMemberRange::evaluate($simulatedMembers, $today, $now, $automation);
$rangeShrank = array_diff($currentRange['userids'], $nextRange['userids']) !== [];
if (($isGoingOffline || $rangeShrank) && $nextRange['userids'] === []) {
throw new RuntimeException('至少需要保留一名当前可用的上线员工');
}
Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->update($memberData);
$dispatch = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
if ($isGoingOffline || $rangeShrank) {
self::assertMemberDispatchReady($dispatch);
}
return $dispatch;
});
$syncError = '';
if ($planned['queued']) {
try {
(new QywxPromotionRangeSyncService())->syncPool($poolId);
} catch (\Throwable $e) {
// 本地规则已保存;后台分钟任务会继续重试,不把成员状态回滚成错误值。
$syncError = $e->getMessage();
}
}
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned]
+ self::memberSyncResult($poolId, $syncError);
}
/** 显式重新推送当前范围;一次请求仅处理一个方案,供批量前端逐方案调用。 */
public static function syncMemberRange(
int $poolId,
int $adminId,
array $adminInfo,
?QywxPromotionRangeSyncService $syncService = null
): array {
self::assertMemberDispatchSchema();
if (!QywxPromotionOperatorAccess::hasPagePermission($adminId, $adminInfo)) {
throw new RuntimeException('权限不足');
}
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$ready = Db::transaction(static function () use ($poolId): bool {
// 与成员保存、方案删除使用相同锁顺序,不重启删除中的同步任务。
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$pool) {
throw new RuntimeException('分流方案不存在或已删除');
}
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if ((int) ($sync['status'] ?? 0) === 5
|| ((int) ($sync['status'] ?? 0) === 4
&& str_starts_with((string) ($sync['last_error'] ?? ''), '企业微信官方获客链接删除失败'))) {
return false;
}
$plan = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
if ($plan['blocked']) {
return false;
}
$linkId = (int) (Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->value('promotion_link_id') ?? 0);
// 即便快照看起来一致也重新 update/get,修复未被本地快照发现的企微端变化。
QywxPromotionMemberSchedulerService::requestPoolSync($poolId, $linkId);
return true;
});
$error = '';
if ($ready) {
try {
($syncService ?? new QywxPromotionRangeSyncService())->syncPool($poolId);
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
return self::memberSyncResult($poolId, $error);
}
/** 返回已确认的远端范围及任务状态,不能把无异常的 noop 当成同步成功。 */
private static function memberSyncResult(int $poolId, string $error = ''): array
{
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find() ?: [];
$link = Db::name('qywx_promotion_link')->where('id', (int) ($sync['promotion_link_id'] ?? 0))
->whereNull('delete_time')->find() ?: [];
$members = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)
->whereNull('delete_time')->select()->toArray();
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
$remoteUsers = self::decodeStringList($link['range_user_json'] ?? null);
$remoteDepartments = self::decodeStringList($link['range_department_json'] ?? null);
$status = (int) ($sync['status'] ?? -1);
$syncError = trim($error ?: (string) ($sync['last_error'] ?? $link['sync_error'] ?? ''));
if ($link === [] || trim((string) ($link['remote_link_id'] ?? '')) === ''
|| (int) ($link['remote_status'] ?? 0) === 2 || $range['userids'] === [] || in_array($status, [4, 5], true)) {
$resultStatus = 'blocked';
$syncError = $syncError ?: '当前没有可同步的官方链接或可用成员,请检查方案和成员规则';
} elseif ($error !== '' || $status === 3) {
$resultStatus = 'failed';
$syncError = $syncError ?: '企业微信成员范围同步失败,请重试';
} elseif ($status === 0 && (int) ($sync['desired_version'] ?? 0) > 0
&& (int) ($sync['desired_version'] ?? 0) === (int) ($sync['applied_version'] ?? -1)
&& (int) ($link['last_sync_time'] ?? 0) > 0 && $remoteDepartments === []
&& QywxPromotionMemberRange::same($range['userids'], $remoteUsers)) {
$resultStatus = 'synced';
$syncError = '';
} else {
$resultStatus = 'pending';
}
return [
'pool_id' => $poolId,
'sync_status' => $resultStatus,
'sync_error' => $syncError,
'sync_queued' => in_array($resultStatus, ['pending', 'failed'], true),
'range_userids' => $remoteUsers,
'range_department_ids' => $remoteDepartments,
];
}
public static function toggleMember(int $id, int $status, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
$row = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
if (!$row) {
throw new RuntimeException('分流成员不存在或已移除');
}
return self::saveMember([
'id' => $id,
'status' => $status,
'_status_only' => true,
], $adminId, $adminInfo);
}
public static function saveLink(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['id'] ?? 0));
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
if ($existing === null || trim((string) ($existing['remote_link_id'] ?? '')) !== '') {
throw new RuntimeException('官方获客链接由分流方案统一维护,请直接编辑方案中的获客医助');
}
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
}
$startAt = self::parseTime($params['active_start'] ?? null);
$endAt = self::parseTime($params['active_end'] ?? null);
if ($startAt > 0 && $endAt > 0 && $startAt >= $endAt) {
throw new RuntimeException('生效结束时间必须晚于开始时间');
}
$now = time();
$data = [
'pool_id' => $poolId,
'account_id' => 0,
'name' => $name,
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'active_start' => $startAt,
'active_end' => $endAt,
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
'update_time' => $now,
];
// 仅保留历史手工链接的兼容编辑;官方链接只能经 savePool 和版本化同步服务修改。
$url = trim((string) ($params['wecom_url'] ?? $existing['wecom_url'] ?? ''));
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
throw new RuntimeException('历史链接必须是 https://work.weixin.qq.com/ca/... 格式');
}
$data['wecom_url'] = $url;
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
return ['id' => $id, 'mode' => 'legacy'];
}
/** 验证 CorpID、应用 Secret、可信 IP 与获客助手接口权限。 */
public static function checkApiPermission(): array
{
return (new QywxCustomerAcquisitionApiService())->checkPermission();
}
/**
* 将企业微信端获客链接同步进指定分流方案。
* 非全量权限账号仅导入 range.user_list 与其可见成员有交集的链接,未知部门映射时严格隐藏。
*/
public static function syncRemoteLinks(int $poolId, int $adminId, array $adminInfo): array
{
self::assertBasePagePermission($adminId, $adminInfo);
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo, false);
$legacyCount = (int) Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->whereNull('delete_time')
->whereRaw("(remote_link_id IS NULL OR remote_link_id = '')")
->count();
$visibleAdminIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
$visibleUserIds = null;
if ($visibleAdminIds !== null) {
$visibleUserIds = array_fill_keys(array_column(self::memberOptions(
$adminId,
$adminInfo,
self::poolMemberAdminIds([$poolId])
), 'userid'), true);
}
$api = new QywxCustomerAcquisitionApiService();
$cursor = '';
$seen = 0;
$created = 0;
$updated = 0;
$skipped = 0;
$failed = 0;
$errors = [];
do {
$page = $api->listLinks($cursor, 100);
foreach ($page['link_id_list'] as $remoteLinkId) {
if ($seen >= 500) {
break 2;
}
$seen++;
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
$skipped++;
continue;
}
$result = self::upsertRemoteLink($remote, $pool, $adminId, $adminInfo);
$result === 'created' ? $created++ : $updated++;
} catch (\Throwable $e) {
$failed++;
if (count($errors) < 5) {
$errors[] = $remoteLinkId . '' . $e->getMessage();
}
}
}
$cursor = (string) ($page['next_cursor'] ?? '');
} while ($cursor !== '');
return [
'scanned' => $seen,
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'failed' => $failed,
'legacy_count' => $legacyCount,
'empty_reason' => $seen === 0
? '当前获客助手可调用应用没有通过 API 创建的官方获客链接;历史手工链接及其他应用创建的链接不会出现在该应用的同步列表中。'
: '',
'suggestion' => $seen === 0
? '请点击“创建官方获客链接”通过当前应用创建。历史手工链接仍可参与本地分流,但无法同步官方 link_id 和官方获客数据。'
: '',
'truncated' => $cursor !== '',
'errors' => $errors,
];
}
/** 获取并刷新单条企业微信官方详情。 */
public static function remoteLinkDetail(int $id, int $adminId, array $adminInfo): array
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
if ($remoteLinkId === '') {
throw new RuntimeException('这是历史手工链接,没有企业微信 link_id');
}
$api = new QywxCustomerAcquisitionApiService();
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
$visibleUserIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo) === null
? null
: array_fill_keys(array_column(self::memberOptions(
$adminId,
$adminInfo,
self::poolMemberAdminIds([(int) ($row['pool_id'] ?? 0)])
), 'userid'), true);
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
throw new RuntimeException('该获客链接已不在当前角色或部门的数据范围内');
}
Db::name('qywx_promotion_link')->where('id', $id)->update(self::remoteColumns($remote, time()));
return self::remotePublicPayload($remote);
}
/** 官方链接由方案统一删除,避免绕过方案删除租约和同步状态机。 */
public static function deleteRemoteLink(int $id, int $adminId, array $adminInfo): void
{
self::assertBasePagePermission($adminId, $adminInfo);
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo, false);
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
if ($remoteLinkId === '') {
throw new RuntimeException('历史手工链接只能从本地移除');
}
throw new RuntimeException('官方获客链接由分流方案统一维护,请使用“删除分流方案”同时删除企业微信链接');
}
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
if ($status === 1 && (int) ($row['remote_status'] ?? 0) === 2) {
throw new RuntimeException('企业微信端已永久删除该链接,不能重新上线');
}
Db::name('qywx_promotion_link')->where('id', $id)->update([
'status' => $status === 1 ? 1 : 0,
'update_time' => time(),
]);
}
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
{
self::assertBasePagePermission($adminId, $adminInfo);
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo, false);
if (trim((string) ($row['remote_link_id'] ?? '')) !== '') {
throw new RuntimeException('官方获客链接不能仅从本地移除,请使用“删除分流方案”同步删除企业微信链接');
}
Db::name('qywx_promotion_link')->where('id', $id)->update([
'delete_time' => time(),
'update_time' => time(),
]);
}
/** @return list<array{id:int,name:string,disable:int,can_grant:bool,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
private static function operatorOptions(int $adminId, array $adminInfo): array
{
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->whereNull('a.delete_time')
->where('a.id', '<>', $adminId);
if ($visibleIds !== null) {
$query->whereIn('a.id', $visibleIds);
}
$admins = $query->field('a.id,a.name,a.disable')->order('a.disable', 'asc')->order('a.name', 'asc')->order('a.id', 'asc')->select()->toArray();
if ($admins === []) {
return [];
}
$adminIds = self::normalizePositiveIds(array_column($admins, 'id'));
$departments = self::adminDepartmentMaps($adminIds);
$result = [];
foreach ($admins as $admin) {
$aid = (int) $admin['id'];
$deptIds = array_values(array_unique(array_filter($departments[$aid]['ids'] ?? [])));
$result[] = [
'id' => $aid,
'name' => (string) ($admin['name'] ?? ('账号 ' . $aid)),
'disable' => (int) ($admin['disable'] ?? 0),
'can_grant' => (int) ($admin['disable'] ?? 0) === 0,
'display_dept_id' => (int) ($deptIds[0] ?? 0),
'dept_ids' => $deptIds,
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
];
}
return $result;
}
/** @param list<int> $poolIds @return array<int,list<array{id:int,name:string,disable:int,dept_names:list<string>}>> */
private static function poolOperators(array $poolIds): array
{
if ($poolIds === []) {
return [];
}
$rows = Db::name('qywx_promotion_pool_operator')->alias('po')
->join('admin a', 'a.id = po.admin_id')
->whereIn('po.pool_id', $poolIds)
->whereNull('po.delete_time')
->whereNull('a.delete_time')
->field('po.pool_id,a.id,a.name,a.disable')
->order('a.name', 'asc')
->order('a.id', 'asc')
->select()->toArray();
$departments = self::adminDepartmentMaps(self::normalizePositiveIds(array_column($rows, 'id')));
$result = [];
foreach ($rows as $row) {
$aid = (int) ($row['id'] ?? 0);
$result[(int) $row['pool_id']][] = [
'id' => $aid,
'name' => (string) ($row['name'] ?? ('账号 ' . $aid)),
'disable' => (int) ($row['disable'] ?? 0),
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
];
}
return $result;
}
/** @return list<array{id:int,name:string,userid:string,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
private static function memberOptions(int $adminId, array $adminInfo, array $extraAdminIds = []): array
{
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
$allowedIds = $visibleIds === null
? null
: self::normalizePositiveIds(array_merge($visibleIds, $extraAdminIds));
if ($allowedIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->whereNull('a.delete_time')
->where('a.disable', 0)
->where('a.work_wechat_userid', '<>', '');
if ($allowedIds !== null) {
$query->whereIn('a.id', $allowedIds);
}
$admins = $query->field('a.id,a.name,a.work_wechat_userid')->order('a.id', 'asc')->select()->toArray();
if ($admins === []) {
return [];
}
$departments = self::adminDepartmentMaps(self::normalizePositiveIds(array_column($admins, 'id')));
$result = [];
$seenUserIds = [];
foreach ($admins as $admin) {
$userId = trim((string) ($admin['work_wechat_userid'] ?? ''));
if ($userId === '' || isset($seenUserIds[$userId])) {
continue;
}
$seenUserIds[$userId] = true;
$aid = (int) $admin['id'];
$deptIds = array_values(array_unique(array_filter($departments[$aid]['ids'] ?? [])));
$result[] = [
'id' => $aid,
'name' => (string) ($admin['name'] ?? $userId),
'userid' => $userId,
// admin_dept 没有主部门字段;与现有获客归属、复诊统计口径一致,
// 取最小 dept_id 作为树形下拉的唯一展示归属,避免多部门成员重复选择。
'display_dept_id' => (int) ($deptIds[0] ?? 0),
'dept_ids' => $deptIds,
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
];
}
return $result;
}
/** @return list<array{id:int,name:string,userid:string,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
private static function resolveMembers(array $adminIds, int $adminId, array $adminInfo, int $poolId = 0): array
{
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
if ($requested === []) {
throw new RuntimeException('请至少选择一名当前角色或部门范围内的获客成员');
}
$available = [];
$existingMemberAdminIds = $poolId > 0 ? self::poolMemberAdminIds([$poolId]) : [];
foreach (self::memberOptions($adminId, $adminInfo, $existingMemberAdminIds) as $member) {
$available[(int) $member['id']] = $member;
}
$members = [];
foreach ($requested as $requestedId) {
if (!isset($available[$requestedId])) {
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
}
$members[] = $available[$requestedId];
}
if (count($members) > 500) {
throw new RuntimeException('单个获客链接最多配置 500 名成员');
}
return $members;
}
/** @return list<string> */
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
{
return array_values(array_column(self::resolveMembers($adminIds, $adminId, $adminInfo), 'userid'));
}
/** @param list<array{id:int,userid:string}> $members @return list<string> */
private static function eligibleSelectedUserIds(
int $poolId,
array $members,
array $config = [],
?array $existingRules = null
): array
{
if ($members === []) {
throw new RuntimeException('请至少选择一名获客成员');
}
$rules = $existingRules ?? ($poolId > 0
? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
: []);
$rulesByUserId = [];
foreach ($rules as $rule) {
$rulesByUserId[(string) $rule['userid']] = $rule;
}
$now = time();
$today = date('Y-m-d', $now);
$candidates = [];
foreach ($members as $member) {
$userId = (string) $member['userid'];
$rule = $rulesByUserId[$userId] ?? [
'enabled' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => $today,
'active_start' => 0,
'active_end' => 0,
];
$rule['userid'] = $userId;
$candidates[] = $rule;
}
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now, $config);
if ($range['userids'] === []) {
throw new RuntimeException('至少需要一名已启用、已生效且未达到今日上限的获客医助');
}
return $range['userids'];
}
/** @param list<array{id:int,userid:string}> $members */
private static function persistPoolMembers(int $poolId, array $members, int $now): void
{
$selectedUserIds = [];
foreach ($members as $member) {
$userId = (string) $member['userid'];
$selectedUserIds[] = $userId;
$existing = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)->where('userid', $userId)->find();
if ($existing) {
Db::name('qywx_promotion_pool_member')->where('id', (int) $existing['id'])->update([
'admin_id' => (int) $member['id'],
'userid' => $userId,
'enabled' => $existing['delete_time'] !== null ? 1 : (int) $existing['enabled'],
'delete_time' => null,
'update_time' => $now,
]);
continue;
}
Db::name('qywx_promotion_pool_member')->insert([
'pool_id' => $poolId,
'admin_id' => (int) $member['id'],
'userid' => $userId,
'enabled' => 1,
'weight' => 1,
'current_weight' => 0,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'total_count' => 0,
'active_start' => 0,
'active_end' => 0,
'last_assigned_time' => 0,
'remark' => '',
'create_time' => $now,
'update_time' => $now,
]);
}
$removeQuery = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)->whereNull('delete_time');
if ($selectedUserIds !== []) {
$removeQuery->whereNotIn('userid', $selectedUserIds);
}
$removeQuery->update(['delete_time' => $now, 'enabled' => 0, 'update_time' => $now]);
}
/**
* 旧版方案第一次打开时,把官方链接原有 range.user_list 回填为成员规则;不修改远端链接。
*
* @param list<array<string,mixed>> $pools
* @param array<int,list<array<string,mixed>>> $linksByPool
* @param array<string,int> $adminIdByUserId
*/
private static function backfillPoolMembers(array $pools, array $linksByPool, array $adminIdByUserId): void
{
$now = time();
foreach ($pools as $pool) {
$poolId = (int) ($pool['id'] ?? 0);
if ($poolId <= 0 || Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->count() > 0) {
continue;
}
$officialLink = null;
foreach ($linksByPool[$poolId] ?? [] as $link) {
if (!empty($link['is_official']) && (int) ($link['remote_status'] ?? 0) !== 2) {
$officialLink = $link;
break;
}
}
if ($officialLink === null) {
continue;
}
$rangeUserIds = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
(array) ($officialLink['range_userids'] ?? [])
), static fn (string $value): bool => $value !== '')));
if ($rangeUserIds === []) {
continue;
}
$mappedMembers = [];
foreach ($rangeUserIds as $userId) {
$mappedAdminId = (int) ($adminIdByUserId[$userId] ?? 0);
if ($mappedAdminId <= 0) {
// 受限账号或本地缺少映射时禁止部分回填,避免定时任务误删企微中的不可见成员。
$mappedMembers = [];
break;
}
$mappedMembers[] = ['userid' => $userId, 'admin_id' => $mappedAdminId];
}
if (count($mappedMembers) !== count($rangeUserIds)) {
continue;
}
foreach ($mappedMembers as $mappedMember) {
$userId = (string) $mappedMember['userid'];
$mappedAdminId = (int) $mappedMember['admin_id'];
if ($userId === '' || $mappedAdminId <= 0) {
continue;
}
try {
Db::name('qywx_promotion_pool_member')->insert([
'pool_id' => $poolId,
'admin_id' => $mappedAdminId,
'userid' => $userId,
'enabled' => 1,
'weight' => 1,
'current_weight' => 0,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'total_count' => 0,
'active_start' => 0,
'active_end' => 0,
'last_assigned_time' => 0,
'remark' => '',
'create_time' => $now,
'update_time' => $now,
]);
} catch (\Throwable) {
// 并发打开 overview 时唯一键会阻止重复回填。
}
}
if ($mappedMembers !== []) {
QywxPromotionMemberSchedulerService::initialisePool($poolId, (int) $officialLink['id']);
}
}
}
/** @return array<string,mixed> */
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
{
return QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $fallbackId);
}
/** @return array<string,mixed> */
private static function remoteColumns(array $remote, int $now): array
{
return [
'name' => mb_substr((string) ($remote['link_name'] ?? ''), 0, 80),
'wecom_url' => (string) ($remote['url'] ?? ''),
'remote_link_id' => (string) ($remote['link_id'] ?? ''),
'remote_status' => 1,
'remote_create_time' => (int) ($remote['create_time'] ?? 0),
'range_user_json' => self::encodeJson($remote['range_userids'] ?? []),
'range_department_json' => self::encodeJson($remote['range_department_ids'] ?? []),
'skip_verify' => !empty($remote['skip_verify']) ? 1 : 0,
'priority_option_json' => self::encodeJson($remote['priority_option'] ?? []),
'remote_snapshot' => self::encodeJson($remote['snapshot'] ?? []),
'last_sync_time' => $now,
'sync_error' => '',
'update_time' => $now,
];
}
private static function upsertRemoteLink(array $remote, array $pool, int $adminId, array $adminInfo): string
{
$remoteLinkId = (string) $remote['link_id'];
$now = time();
$existing = Db::name('qywx_promotion_link')->where('remote_link_id', $remoteLinkId)->find();
$remoteData = self::remoteColumns($remote, $now);
if ($existing) {
if ((int) ($existing['pool_id'] ?? 0) !== (int) ($pool['id'] ?? 0)) {
throw new RuntimeException('该企业微信链接已归属其他分流方案');
}
$remoteData['delete_time'] = null;
Db::name('qywx_promotion_link')->where('id', (int) $existing['id'])->update($remoteData);
return 'updated';
}
Db::name('qywx_promotion_link')->insert($remoteData + [
'pool_id' => (int) $pool['id'],
'account_id' => 0,
'group_name' => '企业微信同步',
'weight' => 1,
'status' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'active_start' => 0,
'active_end' => 0,
'click_count' => 0,
'last_click_time' => 0,
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
'remark' => '',
'create_time' => $now,
'delete_time' => null,
]);
return 'created';
}
private static function canSeeRemoteLink(array $remote, ?array $visibleUserIds): bool
{
if ($visibleUserIds === null) {
return true;
}
foreach ((array) ($remote['range_userids'] ?? []) as $userId) {
if (isset($visibleUserIds[(string) $userId])) {
return true;
}
}
return false;
}
/** @return array<string,mixed> */
private static function remotePublicPayload(array $remote): array
{
return [
'link_id' => (string) ($remote['link_id'] ?? ''),
'link_name' => (string) ($remote['link_name'] ?? ''),
'url' => (string) ($remote['url'] ?? ''),
'create_time' => (int) ($remote['create_time'] ?? 0),
'range_userids' => (array) ($remote['range_userids'] ?? []),
'range_department_ids' => (array) ($remote['range_department_ids'] ?? []),
'skip_verify' => !empty($remote['skip_verify']),
'priority_option' => (array) ($remote['priority_option'] ?? []),
];
}
private static function extractRemoteLinkId(array $response): string
{
if (isset($response['link']) && is_array($response['link'])) {
return trim((string) ($response['link']['link_id'] ?? ''));
}
return trim((string) ($response['link_id'] ?? ''));
}
/** @return list<string> */
private static function normaliseScalarList(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
$value
), static fn (string $item): bool => $item !== '')));
}
/** @return list<string> */
private static function decodeStringList(mixed $value): array
{
if (!is_string($value) || $value === '') {
return [];
}
$decoded = json_decode($value, true);
return self::normaliseScalarList(is_array($decoded) ? $decoded : []);
}
/** @return array<string,mixed> */
private static function decodeObject(mixed $value): array
{
if (!is_string($value) || $value === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
private static function encodeJson(mixed $value): string
{
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $encoded === false ? '[]' : $encoded;
}
private static function assertScopedRow(
string $table,
int $id,
int $adminId,
array $adminInfo,
bool $allowOperator = true
): array
{
if ($id <= 0) {
throw new RuntimeException('数据不存在');
}
$row = Db::name($table)->where('id', $id)->whereNull('delete_time')->find();
if (!$row) {
throw new RuntimeException('数据不存在或已删除');
}
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
$pool = $table === 'qywx_promotion_pool'
? $row
: ($table === 'qywx_promotion_link'
? Db::name('qywx_promotion_pool')
->where('id', (int) ($row['pool_id'] ?? 0))
->whereNull('delete_time')
->find()
: null);
if ($pool !== null) {
if (self::ownerInScope((int) ($pool['owner_admin_id'] ?? 0), $visibleIds)) {
return $row;
}
if ($allowOperator && self::isPoolOperator((int) ($pool['id'] ?? 0), $adminId)) {
return $row;
}
throw new RuntimeException($allowOperator
? '无权访问或操作该分流方案'
: '只有方案原有管理范围内的账号可以执行此操作');
}
if (!self::ownerInScope((int) ($row['owner_admin_id'] ?? 0), $visibleIds)) {
throw new RuntimeException('数据不存在或超出当前权限范围');
}
return $row;
}
/** @param list<int>|null $visibleIds */
private static function ownerInScope(int $ownerAdminId, ?array $visibleIds): bool
{
if ($visibleIds === null) {
return true;
}
return in_array($ownerAdminId, $visibleIds, true);
}
/** @param list<int>|null $visibleIds @param list<int> $operatorPoolIds */
private static function applyPoolAccessScope(
$query,
string $alias,
?array $visibleIds,
array $operatorPoolIds
): void {
if ($visibleIds === null) {
return;
}
if ($visibleIds === [] && $operatorPoolIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->where(function ($scope) use ($alias, $visibleIds, $operatorPoolIds): void {
if ($visibleIds !== []) {
$scope->whereIn($alias . '.owner_admin_id', $visibleIds);
if ($operatorPoolIds !== []) {
$scope->whereOr($alias . '.id', 'in', $operatorPoolIds);
}
return;
}
$scope->whereIn($alias . '.id', $operatorPoolIds);
});
}
/** @return list<int> */
private static function operatorPoolIds(int $adminId): array
{
return QywxPromotionOperatorAccess::activePoolIds($adminId);
}
private static function isPoolOperator(int $poolId, int $adminId): bool
{
if ($poolId <= 0 || $adminId <= 0) {
return false;
}
return Db::name('qywx_promotion_pool_operator')
->where('pool_id', $poolId)
->where('admin_id', $adminId)
->whereNull('delete_time')
->count() > 0;
}
/** @param list<int> $poolIds @return list<int> */
private static function poolMemberAdminIds(array $poolIds): array
{
$poolIds = self::normalizePositiveIds($poolIds);
if ($poolIds === []) {
return [];
}
return self::normalizePositiveIds(Db::name('qywx_promotion_pool_member')
->whereIn('pool_id', $poolIds)
->whereNull('delete_time')
->column('admin_id'));
}
/** @param list<int> $adminIds @return array<int,array{ids:list<int>,names:list<string>}> */
private static function adminDepartmentMaps(array $adminIds): array
{
if ($adminIds === []) {
return [];
}
$rows = Db::name('admin_dept')->alias('ad')
->leftJoin('dept d', 'd.id = ad.dept_id')
->whereIn('ad.admin_id', $adminIds)
->field('ad.admin_id,ad.dept_id,d.name as dept_name')
->order('ad.dept_id', 'asc')->select()->toArray();
$departments = [];
foreach ($rows as $row) {
$aid = (int) ($row['admin_id'] ?? 0);
$departments[$aid]['ids'][] = (int) ($row['dept_id'] ?? 0);
if (trim((string) ($row['dept_name'] ?? '')) !== '') {
$departments[$aid]['names'][] = (string) $row['dept_name'];
}
}
return $departments;
}
/** @return list<int> */
private static function normalizePositiveIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map(
static fn ($value): int => (int) $value,
$ids
), static fn (int $value): bool => $value > 0)));
}
private static function primaryDeptId(int $adminId): int
{
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
}
private static function parseTime(mixed $value): int
{
if ($value === null || $value === '') {
return 0;
}
if (is_numeric($value)) {
return max(0, (int) $value);
}
$time = strtotime((string) $value);
return $time === false ? 0 : $time;
}
private static function assertBasePagePermission(int $adminId, array $adminInfo): void
{
if (!QywxPromotionOperatorAccess::hasBasePagePermission($adminId, $adminInfo)) {
throw new RuntimeException('共享操作人无权执行新建、删除、转授权或全局同步操作');
}
}
/** @param list<int> $adminIds */
private static function clearOperatorAuthCaches(array $adminIds): void
{
foreach (self::normalizePositiveIds($adminIds) as $operatorAdminId) {
try {
(new AdminAuthCache($operatorAdminId))->clearAuthCache();
} catch (\Throwable $error) {
// 授权关系已提交,缓存清理失败不应回滚数据;业务接口仍有实时权限校验。
Log::warning(sprintf(
'清理获客助手共享操作人权限缓存失败 admin_id=%d: %s',
$operatorAdminId,
$error->getMessage()
));
}
}
}
private static function assertMemberDispatchSchema(): void
{
try {
Db::name('qywx_promotion_pool_member')->limit(1)->find();
Db::name('qywx_promotion_dispatch_event')->limit(1)->find();
Db::name('qywx_promotion_range_sync')->limit(1)->find();
} catch (\Throwable $e) {
throw new RuntimeException(
'获客成员调度数据表尚未安装,请先执行 server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql'
);
}
}
private static function assertPoolOperatorSchema(): void
{
try {
Db::name('qywx_promotion_pool_operator')->limit(1)->find();
} catch (\Throwable $e) {
throw new RuntimeException(
'分流方案共享操作人数据表尚未安装,请先执行 server/sql/1.9.20260828/add_wecom_promotion_pool_operators.sql'
);
}
}
private static function mask(string $value): string
{
$length = strlen($value);
if ($length <= 8) {
return $value === '' ? '' : str_repeat('*', $length);
}
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
}
private static function publicDomain(string $requestDomain): string
{
$configuredDomain = trim((string) config('app.app_host', ''));
foreach ([$configuredDomain, trim($requestDomain)] as $candidate) {
if ($candidate === '') {
continue;
}
$parts = parse_url($candidate);
if (!is_array($parts)) {
continue;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = (string) ($parts['host'] ?? '');
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
continue;
}
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
return $scheme . '://' . $host . $port;
}
throw new RuntimeException('未配置有效的应用访问域名');
}
/**
* 内部应用直接复用项目现有 work_wechat 配置,不经过第三方服务商授权。
*
* @return array<string, mixed>
*/
private static function internalApplicationStatus(string $domain): array
{
$corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
$agentId = trim((string) env('WECHAT_WORK_AGENT_ID', ''));
if ($agentId === '') {
$agentId = trim((string) env('work_wechat.agent_id', ''));
}
$apiStatus = QywxCustomerAcquisitionApiService::configurationStatus();
$callbackTokenConfigured = trim((string) config('pay.wechat_work.contact_callback_token', '')) !== '';
$callbackAesConfigured = trim((string) config('pay.wechat_work.contact_callback_aes_key', '')) !== '';
return [
'mode' => 'internal',
'configured' => $apiStatus['configured'],
'ready' => $apiStatus['configured'],
'missing' => $apiStatus['missing'],
'corp_id_masked' => self::mask($corpId),
'agent_id' => $agentId,
'secret_configured' => trim((string) config('qywx_customer_acquisition.secret', '')) !== '',
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
// 复用自建应用现有的「API 接收消息」入口;获客助手事件与其他应用事件
// 由同一个控制器按 Event/ChangeType 分发,不需要再配置第二个回调地址。
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
];
}
}