Files
zyt/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php
T
2026-08-25 09:35:47 +08:00

1248 lines
54 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\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionWeightedRandom;
use app\common\service\qywx\QywxPromotionWidgetService;
use RuntimeException;
use think\facade\Db;
/** 一诊 / 企业微信获客助手管理逻辑。 */
class WecomPromotionLogic
{
public static function overview(int $adminId, array $adminInfo, string $domain): array
{
self::assertMemberDispatchSchema();
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$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::applyOwnerScope($poolsQuery, 'p', $visibleIds);
$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['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);
$memberOptions = self::memberOptions($adminId, $adminInfo);
$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']] ?? [];
foreach ($memberRules as &$memberRule) {
$memberRule['is_current'] = (int) ($memberRule['id'] ?? 0)
=== (int) ($sync['desired_member_id'] ?? 0);
$memberRule['is_applied'] = (int) ($memberRule['id'] ?? 0)
=== (int) ($sync['applied_member_id'] ?? 0);
$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_rules'] = $memberRules;
$pool['dispatch_sync'] = $sync;
$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' => [
'scope_label' => 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,
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
public static function savePool(array $params, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
$id = max(0, (int) ($params['id'] ?? 0));
$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 链接');
}
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
$userIds = array_values(array_column($members, 'userid'));
$selectedUserId = self::preferredDispatchUserId($id, $members);
$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();
$payload = [
'link_name' => mb_substr($name, 0, 30),
// 对外始终是同一个官方直链;当前只激活调度器选中的成员,回调后再切换下一位。
'range' => ['user_list' => [$selectedUserId]],
'skip_verify' => $skipVerify === 1,
];
$createdRemote = false;
if ($existingLink !== null) {
$remoteLinkId = trim((string) ($existingLink['remote_link_id'] ?? ''));
$payload['link_id'] = $remoteLinkId;
$api->updateLink($payload);
} else {
$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);
} catch (\Throwable $e) {
if ($createdRemote) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 保留详情读取的原始异常;孤立链接仍可通过企业微信同步找回。
}
}
throw $e;
}
$remoteData = self::remoteColumns($remote, $now);
$linkId = (int) ($existingLink['id'] ?? 0);
try {
Db::transaction(function () use (
&$id,
&$linkId,
$poolData,
$remoteData,
$status,
$existingPool,
$members,
$selectedUserId,
$now,
$adminId
): void {
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,
'group_name' => '方案官方链接',
'status' => $status,
'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);
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId, $selectedUserId);
// 旧多链接只在本地下线,企业微信远端与历史客户归因继续保留。
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;
}
return [
'id' => $id,
'remote_link_id' => (string) ($remote['link_id'] ?? ''),
'wecom_url' => (string) ($remote['url'] ?? ''),
'main_url' => QywxCustomerAcquisitionLinkService::withCustomerChannel(
(string) ($remote['url'] ?? ''),
'zyt_pool:' . $id
),
'member_userids' => $userIds,
'current_userid' => $selectedUserId,
];
}
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): void
{
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
$now = 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(['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_range_sync')->where('pool_id', $id)->update([
'status' => 4,
'next_retry' => 0,
'last_error' => '分流方案已删除',
'update_time' => $now,
]);
});
}
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;
if ($enabled === 0) {
$otherEnabled = (int) Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->where('id', '<>', $id)
->where('enabled', 1)
->whereNull('delete_time')
->count();
if ($otherEnabled <= 0) {
throw new RuntimeException('至少需要保留一名启用的获客医助');
}
}
$startAt = self::parseTime($params['active_start'] ?? null);
$endAt = self::parseTime($params['active_end'] ?? null);
if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
throw new RuntimeException('生效结束时间必须晚于开始时间');
}
$now = time();
Db::transaction(function () use ($id, $enabled, $params, $startAt, $endAt, $now): void {
Db::name('qywx_promotion_pool_member')->where('id', $id)->update([
'enabled' => $enabled,
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
'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,
]);
});
$planned = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
$syncError = '';
if ($planned['queued']) {
try {
(new QywxPromotionRangeSyncService())->syncPool($poolId);
} catch (\Throwable $e) {
// 本地规则已保存;后台分钟任务会继续重试,不把成员状态回滚成错误值。
$syncError = $e->getMessage();
}
}
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned, 'sync_error' => $syncError];
}
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,
'weight' => (int) ($row['weight'] ?? 1),
'daily_limit' => (int) ($row['daily_limit'] ?? 0),
'active_start' => (int) ($row['active_start'] ?? 0),
'active_end' => (int) ($row['active_end'] ?? 0),
'remark' => (string) ($row['remark'] ?? ''),
], $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));
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
if ($existing === null) {
$officialCount = (int) Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->whereNull('delete_time')
->where('remote_link_id', '<>', '')
->where('remote_status', '<>', 2)
->count();
if ($officialCount > 0) {
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,
];
// 历史手工链接只维护本地分流规则,不会在企业微信端创建重复链接。
if ($existing !== null && trim((string) ($existing['remote_link_id'] ?? '')) === '') {
$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'];
}
$userIds = self::resolveMemberUserIds((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$payload = [
'link_name' => $name,
'range' => ['user_list' => $userIds],
'skip_verify' => $skipVerify === 1,
];
$api = new QywxCustomerAcquisitionApiService();
if ($existing !== null) {
$remoteLinkId = trim((string) ($existing['remote_link_id'] ?? ''));
$payload['link_id'] = $remoteLinkId;
$api->updateLink($payload);
} else {
$created = $api->createLink($payload);
$remoteLinkId = self::extractRemoteLinkId($created);
if ($remoteLinkId === '') {
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请先执行“同步企业微信”确认结果');
}
}
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
$data += self::remoteColumns($remote, $now);
if ($existing !== null) {
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
} else {
$data += [
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
'click_count' => 0,
'today_count' => 0,
'today_date' => null,
'last_click_time' => 0,
'create_time' => $now,
];
try {
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
} catch (\Throwable $e) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 远端补偿失败时保留原始异常,管理员可通过“同步企业微信”找回链接。
}
throw $e;
}
}
return ['id' => $id, 'remote_link_id' => $remoteLinkId, 'mode' => 'official'];
}
/** 验证 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
{
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$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 = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$visibleUserIds = null;
if ($visibleAdminIds !== null) {
$visibleUserIds = array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), '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 = DataScopeService::getVisibleAdminIds($adminId, $adminInfo) === null
? null
: array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), '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
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
if ($remoteLinkId === '') {
throw new RuntimeException('历史手工链接只能从本地移除');
}
(new QywxCustomerAcquisitionApiService())->deleteLink($remoteLinkId);
Db::name('qywx_promotion_link')->where('id', $id)->update([
'status' => 0,
'remote_status' => 2,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
}
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::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
Db::name('qywx_promotion_link')->where('id', $id)->update([
'delete_time' => time(),
'update_time' => time(),
]);
}
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
private static function memberOptions(int $adminId, array $adminInfo): array
{
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->whereNull('a.delete_time')
->where('a.work_wechat_userid', '<>', '');
if ($visibleIds !== null) {
$query->whereIn('a.id', $visibleIds);
}
$admins = $query->field('a.id,a.name,a.work_wechat_userid')->order('a.id', 'asc')->select()->toArray();
if ($admins === []) {
return [];
}
$adminIds = array_map('intval', array_column($admins, 'id'));
$deptRows = 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 ($deptRows 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'];
}
}
$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'];
$result[] = [
'id' => $aid,
'name' => (string) ($admin['name'] ?? $userId),
'userid' => $userId,
'dept_ids' => array_values(array_unique(array_filter($departments[$aid]['ids'] ?? []))),
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
];
}
return $result;
}
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
private static function resolveMembers(array $adminIds, int $adminId, array $adminInfo): array
{
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
if ($requested === []) {
throw new RuntimeException('请至少选择一名当前角色或部门范围内的获客成员');
}
$available = [];
foreach (self::memberOptions($adminId, $adminInfo) 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 */
private static function preferredDispatchUserId(int $poolId, array $members): string
{
if ($members === []) {
throw new RuntimeException('请至少选择一名获客成员');
}
$selected = [];
foreach ($members as $member) {
$selected[(string) $member['userid']] = true;
}
$rules = $poolId > 0
? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
: [];
$rulesById = [];
$rulesByUserId = [];
foreach ($rules as $rule) {
$rulesById[(int) $rule['id']] = $rule;
$rulesByUserId[(string) $rule['userid']] = $rule;
}
$desiredId = $poolId > 0
? (int) (Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->value('desired_member_id') ?? 0)
: 0;
$now = time();
$today = date('Y-m-d', $now);
if (isset($rulesById[$desiredId])) {
$current = $rulesById[$desiredId];
if ((string) ($current['today_date'] ?? '') !== $today) {
$current['today_count'] = 0;
$current['today_date'] = $today;
}
if (isset($selected[(string) $current['userid']]) && QywxPromotionWeightedRandom::eligible($current, $now)) {
return (string) $current['userid'];
}
}
$randomCandidates = [];
$candidateUserIds = [];
foreach ($members as $index => $member) {
$userId = (string) $member['userid'];
$rule = $rulesByUserId[$userId] ?? [
'enabled' => 1,
'weight' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => $today,
'active_start' => 0,
'active_end' => 0,
];
if ((string) ($rule['today_date'] ?? '') !== $today) {
$rule['today_count'] = 0;
$rule['today_date'] = $today;
}
$candidateId = $index + 1;
$rule['id'] = $candidateId;
$randomCandidates[] = $rule;
$candidateUserIds[$candidateId] = $userId;
}
$selection = QywxPromotionWeightedRandom::select($randomCandidates, $today, $now);
$selectedCandidateId = (int) ($selection['selected_id'] ?? 0);
if ($selectedCandidateId > 0 && isset($candidateUserIds[$selectedCandidateId])) {
return $candidateUserIds[$selectedCandidateId];
}
// 企业微信不接受空范围;仅在所有启用成员恰好达到上限时保留一名,队列会标记“无可用成员”。
foreach ($members as $member) {
$rule = $rulesByUserId[(string) $member['userid']] ?? null;
if ($rule === null || (int) ($rule['enabled'] ?? 1) === 1) {
return (string) $member['userid'];
}
}
throw new RuntimeException('至少需要启用一名获客医助');
}
/** @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,
'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;
}
$firstUserId = '';
foreach ((array) ($officialLink['range_userids'] ?? []) as $userIdValue) {
$userId = trim((string) $userIdValue);
$mappedAdminId = (int) ($adminIdByUserId[$userId] ?? 0);
if ($userId === '' || $mappedAdminId <= 0) {
continue;
}
if ($firstUserId === '') {
$firstUserId = $userId;
}
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 ($firstUserId !== '') {
QywxPromotionMemberSchedulerService::initialisePool(
$poolId,
(int) $officialLink['id'],
$firstUserId
);
// 旧链接可能仍包含多人范围;排队收敛为当前调度成员,官方 URL 本身保持不变。
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => 1,
'applied_member_id' => 0,
'applied_version' => 0,
'next_retry' => $now,
'update_time' => $now,
]);
}
}
}
/** @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) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds !== null && !in_array((int) ($existing['owner_admin_id'] ?? 0), $visibleIds, true)) {
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): array
{
if ($id <= 0) {
throw new RuntimeException('数据不存在');
}
$query = Db::name($table)->where('id', $id)->whereNull('delete_time');
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds !== null) {
if ($visibleIds === []) {
throw new RuntimeException('无权访问该数据');
}
$query->whereIn('owner_admin_id', $visibleIds);
}
$row = $query->find();
if (!$row) {
throw new RuntimeException('数据不存在或超出当前权限范围');
}
return $row;
}
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
}
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 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 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',
];
}
}