This commit is contained in:
Your Name
2026-08-25 10:45:24 +08:00
parent af1db59c07
commit b8ccbaf567
13 changed files with 420 additions and 452 deletions
@@ -62,7 +62,7 @@ class WecomPromotionController extends BaseAdminController
return $this->run(function () use ($id) {
WecomPromotionLogic::deletePool($id, $this->adminId, $this->adminInfo);
return $this->success('分流方案删除');
return $this->success('分流方案及企业微信官方获客链接已永久删除');
});
}
@@ -7,9 +7,9 @@ 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\QywxPromotionMemberRange;
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;
@@ -126,11 +126,11 @@ class WecomPromotionLogic
$officialLink = $officialLinks[0] ?? null;
$memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
$sync = $syncByPool[(int) $pool['id']] ?? [];
$remoteUserIds = array_fill_keys((array) ($officialLink['range_userids'] ?? []), true);
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['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'] ?? '');
}
@@ -204,7 +204,7 @@ class WecomPromotionLogic
}
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
$userIds = array_values(array_column($members, 'userid'));
$selectedUserId = self::preferredDispatchUserId($id, $members);
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
$now = time();
@@ -227,38 +227,38 @@ class WecomPromotionLogic
}
$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 {
$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);
} catch (\Throwable $e) {
if ($createdRemote) {
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)) {
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
}
} catch (\Throwable $e) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 保留详情读取的原始异常;孤立链接仍可通过企业微信同步找回。
}
throw $e;
}
throw $e;
} else {
$remoteLinkId = trim((string) ($existingLink['remote_link_id'] ?? ''));
}
$remoteData = self::remoteColumns($remote, $now);
$remoteData = $remote !== null ? self::remoteColumns($remote, $now) : [];
$linkId = (int) ($existingLink['id'] ?? 0);
try {
@@ -270,7 +270,8 @@ class WecomPromotionLogic
$status,
$existingPool,
$members,
$selectedUserId,
$skipVerify,
$createdRemote,
$now,
$adminId
): void {
@@ -288,8 +289,10 @@ class WecomPromotionLogic
$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) {
@@ -312,7 +315,9 @@ class WecomPromotionLogic
]);
}
self::persistPoolMembers($id, $members, $now);
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId, $selectedUserId);
if ($createdRemote) {
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId);
}
// 旧多链接只在本地下线,企业微信远端与历史客户归因继续保留。
Db::name('qywx_promotion_link')
@@ -332,16 +337,30 @@ class WecomPromotionLogic
throw $e;
}
$syncError = '';
if (!$createdRemote) {
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
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) ($remote['link_id'] ?? ''),
'wecom_url' => (string) ($remote['url'] ?? ''),
'remote_link_id' => (string) ($savedLink['remote_link_id'] ?? $remoteLinkId),
'wecom_url' => $savedUrl,
'main_url' => QywxCustomerAcquisitionLinkService::withCustomerChannel(
(string) ($remote['url'] ?? ''),
$savedUrl,
'zyt_pool:' . $id
),
'member_userids' => $userIds,
'current_userid' => $selectedUserId,
'range_userids' => $eligibleUserIds,
'sync_error' => $syncError,
];
}
@@ -360,13 +379,95 @@ class WecomPromotionLogic
return ['id' => $id, 'widget_config' => $config];
}
public static function deletePool(int $id, int $adminId, array $adminInfo): void
public static function deletePool(
int $id,
int $adminId,
array $adminInfo,
?QywxCustomerAcquisitionApiService $api = null
): void
{
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
$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) {
$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(['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,
@@ -375,7 +476,9 @@ class WecomPromotionLogic
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update([
'status' => 4,
'next_retry' => 0,
'last_error' => '分流方案已删除',
'lock_token' => '',
'lock_until' => 0,
'last_error' => '分流方案及企业微信官方链接已删除',
'update_time' => $now,
]);
});
@@ -412,7 +515,8 @@ class WecomPromotionLogic
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))),
// 企业微信原生多人路由不支持逐成员权重;字段固定为 1,仅兼容已部署表结构。
'weight' => 1,
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'active_start' => $startAt,
'active_end' => $endAt,
@@ -445,7 +549,6 @@ class WecomPromotionLogic
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),
@@ -457,18 +560,10 @@ class WecomPromotionLogic
{
$id = max(0, (int) ($params['id'] ?? 0));
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
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('一个分流方案只能有一个官方获客链接,请直接编辑分流方案中的获客成员');
}
if ($existing === null || trim((string) ($existing['remote_link_id'] ?? '')) !== '') {
throw new RuntimeException('官方获客链接由分流方案统一维护,请直接编辑方案中的获客医助');
}
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
@@ -494,66 +589,15 @@ class WecomPromotionLogic
'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'];
// 仅保留历史手工链接的兼容编辑;官方链接只能经 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);
$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'];
return ['id' => $id, 'mode' => 'legacy'];
}
/** 验证 CorpID、应用 Secret、可信 IP 与获客助手接口权限。 */
@@ -653,7 +697,7 @@ class WecomPromotionLogic
return self::remotePublicPayload($remote);
}
/** 永久删除企业微信端获客链接,本地保留审计记录并停止分流。 */
/** 官方链接由方案统一删除,避免绕过方案删除租约和同步状态机。 */
public static function deleteRemoteLink(int $id, int $adminId, array $adminInfo): void
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
@@ -661,14 +705,8 @@ class WecomPromotionLogic
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(),
]);
throw new RuntimeException('官方获客链接由分流方案统一维护,请使用“删除分流方案”同时删除企业微信链接');
}
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
@@ -685,7 +723,10 @@ class WecomPromotionLogic
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
{
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
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(),
@@ -776,76 +817,41 @@ class WecomPromotionLogic
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
/** @param list<array{id:int,userid:string}> $members @return list<string> */
private static function eligibleSelectedUserIds(int $poolId, array $members): array
{
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) {
$candidates = [];
foreach ($members as $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;
$rule['userid'] = $userId;
$candidates[] = $rule;
}
$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'];
}
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now);
if ($range['userids'] === []) {
throw new RuntimeException('至少需要一名已启用、已生效且未达到今日上限的获客医助');
}
throw new RuntimeException('至少需要启用一名获客医助');
return $range['userids'];
}
/** @param list<array{id:int,userid:string}> $members */
@@ -918,16 +924,32 @@ class WecomPromotionLogic
if ($officialLink === null) {
continue;
}
$firstUserId = '';
foreach ((array) ($officialLink['range_userids'] ?? []) as $userIdValue) {
$userId = trim((string) $userIdValue);
$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;
}
if ($firstUserId === '') {
$firstUserId = $userId;
}
try {
Db::name('qywx_promotion_pool_member')->insert([
'pool_id' => $poolId,
@@ -951,20 +973,8 @@ class WecomPromotionLogic
// 并发打开 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,
]);
if ($mappedMembers !== []) {
QywxPromotionMemberSchedulerService::initialisePool($poolId, (int) $officialLink['id']);
}
}
}