From b8ccbaf5674026354c58eb0c9bd9bf213b931b14 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 25 Aug 2026 10:45:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/api/first_visit.ts | 6 +- .../first_visit/wecom_promotion/index.vue | 76 ++-- .../firstvisit/WecomPromotionController.php | 2 +- .../logic/firstvisit/WecomPromotionLogic.php | 368 +++++++++--------- .../QywxExternalContactCallbackController.php | 4 +- .../app/command/QywxSyncPromotionRanges.php | 4 +- .../QywxPromotionMemberSchedulerService.php | 164 +++++--- .../qywx/QywxPromotionRangeSyncService.php | 75 ++-- .../qywx/QywxPromotionWeightedRandom.php | 83 ---- server/docs/qywx_promotion.md | 16 +- ...upgrade_qywx_promotion_member_dispatch.sql | 14 +- .../QywxCustomerAcquisitionApiServiceTest.php | 14 +- .../tests/QywxPromotionWeightedRandomTest.php | 46 --- 13 files changed, 420 insertions(+), 452 deletions(-) delete mode 100644 server/app/common/service/qywx/QywxPromotionWeightedRandom.php delete mode 100644 server/tests/QywxPromotionWeightedRandomTest.php diff --git a/admin/src/api/first_visit.ts b/admin/src/api/first_visit.ts index 2787e34e4..bb2e4486e 100644 --- a/admin/src/api/first_visit.ts +++ b/admin/src/api/first_visit.ts @@ -198,9 +198,9 @@ export function wecomPromotionSaveWidget(params: Record) { return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params }) } -export function wecomPromotionDeletePool(params: { id: number }) { - return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params }) -} +export function wecomPromotionDeletePool(params: { id: number }) { + return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 }) +} export function wecomPromotionSaveLink(params: Record) { return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params }) diff --git a/admin/src/views/first_visit/wecom_promotion/index.vue b/admin/src/views/first_visit/wecom_promotion/index.vue index ea30a48a3..e4278c734 100644 --- a/admin/src/views/first_visit/wecom_promotion/index.vue +++ b/admin/src/views/first_visit/wecom_promotion/index.vue @@ -5,7 +5,7 @@

企业微信获客助手

-

一个方案保留一条企业微信官方直链,按回调结果自动调度医助权重与数量

+

一个方案保留一条企业微信官方直链,由企业微信在多个可用医助之间原生路由

@@ -26,7 +26,7 @@
-
官方链接{{ overview.summary.online_links }}

链接不变,成员范围自动切换

+
官方链接{{ overview.summary.online_links }}

链接不变,成员范围自动更新

@@ -91,7 +91,7 @@ 复制链接 复制 JS 编辑方案 - 删除 + 删除
@@ -110,8 +110,8 @@ type="info" show-icon :closable="false" - title="回调驱动分流" - description="企业微信确认实际承接成员后,系统累计今日数量,并在符合启用状态、有效期和上限的成员中按权重随机抽取下一位;随机结果可能连续命中同一人,并发访问及企微同步存在短暂延迟。" + title="企业微信原生多人路由" + description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。" /> @@ -125,12 +125,12 @@ - @@ -197,7 +197,7 @@ show-icon :closable="false" title="消息次数回调尚未配置完成" - description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 /api/qywx/external-contact/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法自动累计成员获客数量和切换下一位。" + description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 /api/qywx/external-contact/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法自动累计成员获客数量,也无法在成员达到上限后自动更新官方范围。" /> -
- - - 权重越高,在后续回调调度中被选中的频率越高。 - - - - 0 表示不限;数量按实际获客回调累计。 - -
+ + + 0 表示不限;数量按实际获客回调累计,达到上限后会从企微官方成员范围移除。 + @@ -446,8 +440,9 @@ const checkingApi = ref(false) const memberDialogVisible = ref(false) const savingMember = ref(false) const togglingMemberId = ref(0) +const deletingPoolId = ref(0) const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' }) -const memberForm = reactive({ id: 0, name: '', userid: '', weight: 1, daily_limit: 0, status: 1, active_range: [] as string[], remark: '' }) +const memberForm = reactive({ id: 0, name: '', userid: '', daily_limit: 0, status: 1, active_range: [] as string[], remark: '' }) const customerStatsLoading = ref(false) const customerStatsLoaded = ref(false) const syncingCustomers = ref(false) @@ -534,7 +529,9 @@ async function savePool() { poolDialogVisible.value = false await loadOverview() if (result?.id) selectedPoolId.value = Number(result.id) - ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建') + result?.sync_error + ? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步') + : ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建') } catch (error: any) { ElMessage.error(error?.message || '分流方案保存失败') } finally { @@ -543,14 +540,25 @@ async function savePool() { } async function removePool(pool: any) { - await ElMessageBox.confirm(`删除“${pool.name}”后,本站将停止使用该链接,但不会永久删除企业微信后台中的官方链接。确认继续?`, '删除分流方案', { type: 'warning' }) - try { - await wecomPromotionDeletePool({ id: Number(pool.id) }) - ElMessage.success('分流方案已删除') - await loadOverview() - } catch (error: any) { - ElMessage.error(error?.message || '删除失败') - } + try { + await ElMessageBox.confirm( + `删除“${pool.name}”将同时永久删除企业微信后台中的官方获客链接,已投放的链接会失效且无法恢复。确认继续?`, + '永久删除分流方案', + { type: 'warning', confirmButtonText: '永久删除', cancelButtonText: '取消' } + ) + } catch { + return + } + deletingPoolId.value = Number(pool.id) + try { + await wecomPromotionDeletePool({ id: Number(pool.id) }) + ElMessage.success('分流方案及企业微信官方获客链接已永久删除') + await loadOverview() + } catch (error: any) { + ElMessage.error(error?.message || '删除失败') + } finally { + deletingPoolId.value = 0 + } } function openMemberDialog(row: any) { @@ -561,7 +569,6 @@ function openMemberDialog(row: any) { id: Number(row.id), name: row.name || '', userid: row.userid || '', - weight: Number(row.weight) || 1, daily_limit: Number(row.daily_limit) || 0, status: Number(row.enabled) === 1 ? 1 : 0, active_range: range, @@ -597,7 +604,7 @@ async function handleMemberToggle(row: any, value: unknown) { await loadOverview() result?.sync_error ? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步') - : ElMessage.success(value ? '医助已启用' : '医助已禁用并退出后续调度') + : ElMessage.success('成员规则已保存,企微多人路由范围已重新计算') } catch (error: any) { ElMessage.error(error?.message || '成员状态更新失败') await loadOverview() @@ -727,9 +734,8 @@ function eligibility(row: any) { if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' } if (Number(row.active_end) > 0 && Number(row.active_end) < now) return { label: '已过期', className: 'is-error' } if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' } - const isSyncTarget = Boolean(row.is_current || row.is_applied) - if (isSyncTarget && Number(row.sync_status) === 3) return { label: '企微同步重试中', className: 'is-waiting' } - if (isSyncTarget && Number(row.sync_status) === 4) return { label: '暂无可用调度', className: 'is-error' } + if (Number(row.sync_status) === 3) return { label: '企微同步重试中', className: 'is-waiting' } + if (Number(row.sync_status) === 4) return { label: '暂无可用成员', className: 'is-error' } return { label: '可参与分流', className: 'is-ok' } } diff --git a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php index 7b27f4122..f47ce3eee 100644 --- a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php +++ b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php @@ -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('分流方案及企业微信官方获客链接已永久删除'); }); } diff --git a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php index fdc01414b..868a066db 100644 --- a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php +++ b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php @@ -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 $members */ - private static function preferredDispatchUserId(int $poolId, array $members): string + /** @param list $members @return list */ + 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 $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']); } } } diff --git a/server/app/api/controller/QywxExternalContactCallbackController.php b/server/app/api/controller/QywxExternalContactCallbackController.php index 5e7f76dce..aa56bf346 100755 --- a/server/app/api/controller/QywxExternalContactCallbackController.php +++ b/server/app/api/controller/QywxExternalContactCallbackController.php @@ -181,7 +181,7 @@ class QywxExternalContactCallbackController extends BaseApiController } if ($changeType === 'add_external_contact' && $state !== '' && $userId !== '') { - // customer_channel=zyt_pool:{id} 会原样进入 State;以实际 UserID 幂等记账并规划下一位。 + // customer_channel=zyt_pool:{id} 会原样进入 State;以实际 UserID 幂等记账并更新可用成员范围。 try { $dispatch = QywxPromotionMemberSchedulerService::recordFromState( $state, @@ -192,7 +192,7 @@ class QywxExternalContactCallbackController extends BaseApiController ); if (($dispatch['status'] ?? '') === 'counted' && (int) ($dispatch['pool_id'] ?? 0) > 0) { try { - // 回调后立即切换;分钟任务仍负责网络异常、并发版本变化等情况的兜底重试。 + // 回调后立即应用上限变化;分钟任务仍负责网络异常、并发版本变化等情况的兜底重试。 (new QywxPromotionRangeSyncService())->syncPool((int) $dispatch['pool_id']); } catch (\Throwable $e) { Log::warning('qywx promotion immediate range sync failed: ' . $e->getMessage()); diff --git a/server/app/command/QywxSyncPromotionRanges.php b/server/app/command/QywxSyncPromotionRanges.php index f2a2739f5..354509a15 100644 --- a/server/app/command/QywxSyncPromotionRanges.php +++ b/server/app/command/QywxSyncPromotionRanges.php @@ -9,13 +9,13 @@ use think\console\Command; use think\console\Input; use think\console\Output; -/** 重试回调调度后尚未同步到企业微信的获客链接成员范围。 */ +/** 重算并重试尚未同步到企业微信的获客链接多人范围。 */ class QywxSyncPromotionRanges extends Command { protected function configure() { $this->setName('qywx:sync-promotion-ranges') - ->setDescription('同步企业微信官方获客链接的下一名承接成员'); + ->setDescription('同步企业微信官方获客链接的多人路由成员范围'); } protected function execute(Input $input, Output $output): int diff --git a/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php b/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php index 0cb116730..3a357772d 100644 --- a/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php +++ b/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php @@ -6,7 +6,7 @@ namespace app\common\service\qywx; use think\facade\Db; -/** 根据实际获客回调记账,并为同一个官方链接规划下一名承接成员。 */ +/** 根据实际获客回调记账,并维护同一个官方链接的可用成员范围。 */ class QywxPromotionMemberSchedulerService { public static function poolIdFromState(string $state): int @@ -54,7 +54,7 @@ class QywxPromotionMemberSchedulerService } /** - * 管理端变更成员开关、权重或上限后,确保当前远端成员仍可用;必要时立即排队切换。 + * 管理端变更成员开关、上限或有效期后,重新计算企业微信官方多人路由范围。 * * @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool} */ @@ -66,55 +66,30 @@ class QywxPromotionMemberSchedulerService $linkId = self::promotionLinkId($poolId); $sync = self::lockedSyncRow($poolId); $members = self::lockedMembers($poolId); + $syncStatus = (int) ($sync['status'] ?? 0); + $syncError = (string) ($sync['last_error'] ?? ''); + if ($syncStatus === 5 || ($syncStatus === 4 && str_starts_with($syncError, '企业微信官方获客链接删除失败'))) { + return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true]; + } if ($linkId <= 0 || $members === []) { return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true]; } - $desiredId = (int) ($sync['desired_member_id'] ?? 0); - $desiredEligible = false; - foreach ($members as &$member) { - if ((string) ($member['today_date'] ?? '') !== $today) { - $member['today_date'] = $today; - $member['today_count'] = 0; - } - if ((int) $member['id'] === $desiredId) { - $desiredEligible = QywxPromotionWeightedRandom::eligible($member, $now); - } - } - unset($member); - self::persistMemberCursors($members, $now); - if ($desiredEligible) { - if ((int) ($sync['status'] ?? 0) === 4) { - Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ - 'status' => 0, - 'next_retry' => 0, - 'last_error' => '', - 'update_time' => $now, - ]); - } - return ['pool_id' => $poolId, 'next_member_id' => $desiredId, 'queued' => false, 'blocked' => false]; - } - - return self::selectAndQueueLocked($poolId, $linkId, $sync, $members, $today, $now); + return self::queueEligibleRangeLocked($poolId, $linkId, $sync, $members, $today, $now); }); } - public static function initialisePool(int $poolId, int $promotionLinkId, string $selectedUserId): int + public static function initialisePool(int $poolId, int $promotionLinkId): int { - $memberId = (int) (Db::name('qywx_promotion_pool_member') - ->where('pool_id', $poolId) - ->where('userid', $selectedUserId) - ->whereNull('delete_time') - ->value('id') ?? 0); - if ($memberId <= 0 || $promotionLinkId <= 0) { + if ($poolId <= 0 || $promotionLinkId <= 0) { return 0; } $now = time(); $existing = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find(); $data = [ 'promotion_link_id' => $promotionLinkId, - 'desired_member_id' => $memberId, - 'applied_member_id' => $memberId, + 'desired_member_id' => 0, + 'applied_member_id' => 0, 'status' => 0, 'attempts' => 0, 'next_retry' => 0, @@ -133,7 +108,55 @@ class QywxPromotionMemberSchedulerService Db::name('qywx_promotion_range_sync')->insert($data); } - return $memberId; + return $promotionLinkId; + } + + /** 强制排队一次成员范围同步,也用于方案名称或免验证配置变化。 */ + public static function requestPoolSync(int $poolId, int $promotionLinkId): void + { + if ($poolId <= 0 || $promotionLinkId <= 0) { + return; + } + Db::transaction(function () use ($poolId, $promotionLinkId): void { + $now = time(); + $existing = self::lockedSyncRow($poolId); + if ($existing === null) { + Db::name('qywx_promotion_range_sync')->insert([ + 'pool_id' => $poolId, + 'promotion_link_id' => $promotionLinkId, + 'desired_member_id' => 0, + 'desired_version' => 1, + 'applied_member_id' => 0, + 'applied_version' => 0, + 'status' => 1, + 'attempts' => 0, + 'next_retry' => $now, + 'lock_token' => '', + 'lock_until' => 0, + 'last_error' => '', + 'create_time' => $now, + 'update_time' => $now, + ]); + + return; + } + $existingStatus = (int) ($existing['status'] ?? 0); + $leaseActive = in_array($existingStatus, [2, 5], true) + && (int) ($existing['lock_until'] ?? 0) > $now; + $data = [ + 'promotion_link_id' => $promotionLinkId, + 'desired_member_id' => 0, + 'desired_version' => max(1, (int) ($existing['desired_version'] ?? 0) + 1), + 'applied_member_id' => 0, + 'status' => $leaseActive ? $existingStatus : 1, + 'next_retry' => $leaseActive ? (int) ($existing['next_retry'] ?? 0) : $now, + 'update_time' => $now, + ]; + if (!$leaseActive) { + $data += ['attempts' => 0, 'last_error' => '']; + } + Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data); + }); } /** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */ @@ -167,7 +190,8 @@ class QywxPromotionMemberSchedulerService return ['status' => 'ignored_member', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0]; } $actualMemberId = (int) $actualMember['id']; - $eventKey = hash('sha256', $poolId . '|' . $userId . '|' . $externalUserId); + // 同一方案中的同一客户只计一次;后续更换跟进成员不能重复占用医助额度。 + $eventKey = hash('sha256', $poolId . '|' . $externalUserId); try { Db::name('qywx_promotion_dispatch_event')->insert([ 'event_key' => $eventKey, @@ -205,12 +229,11 @@ class QywxPromotionMemberSchedulerService $linkId = self::promotionLinkId($poolId); $sync = self::lockedSyncRow($poolId); - $desiredId = (int) ($sync['desired_member_id'] ?? 0); - if ($linkId <= 0 || ($desiredId > 0 && $desiredId !== $actualMemberId)) { - return ['status' => 'counted_stale', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => $desiredId]; + if ($linkId <= 0) { + return ['status' => 'counted_stale', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => 0]; } - $planned = self::selectAndQueueLocked($poolId, $linkId, $sync, $members, $today, $now); + $planned = self::queueEligibleRangeLocked($poolId, $linkId, $sync, $members, $today, $now); return [ 'status' => $planned['blocked'] ? 'counted_blocked' : 'counted', @@ -222,7 +245,7 @@ class QywxPromotionMemberSchedulerService } /** @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool} */ - private static function selectAndQueueLocked( + private static function queueEligibleRangeLocked( int $poolId, int $linkId, ?array $sync, @@ -230,40 +253,51 @@ class QywxPromotionMemberSchedulerService string $today, int $now ): array { - $selection = QywxPromotionWeightedRandom::select($members, $today, $now); - self::persistMemberCursors($selection['members'], $now); - $selectedId = (int) $selection['selected_id']; - if ($selectedId <= 0) { - self::upsertSync($poolId, $linkId, (int) ($sync['desired_member_id'] ?? 0), false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限'); + $range = QywxPromotionMemberRange::evaluate($members, $today, $now); + self::persistMemberCursors($range['members'], $now); + if ($range['userids'] === []) { + self::upsertSync($poolId, $linkId, false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限'); return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true]; } - $changed = $selectedId !== (int) ($sync['desired_member_id'] ?? 0); - self::upsertSync($poolId, $linkId, $selectedId, $changed, $sync, $now); + $appliedUserIds = self::linkRangeUserIds($linkId); + $changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds); + $alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 3], true); + $needsSync = $changed || $alreadyPending; + self::upsertSync($poolId, $linkId, $needsSync, $sync, $now); - return ['pool_id' => $poolId, 'next_member_id' => $selectedId, 'queued' => $changed, 'blocked' => false]; + return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => $needsSync, 'blocked' => false]; } private static function upsertSync( int $poolId, int $linkId, - int $desiredMemberId, bool $pending, ?array $existing, int $now, string $error = '' ): void { - $version = max(1, (int) ($existing['desired_version'] ?? 0) + ($pending ? 1 : 0)); + $desiredChanged = $pending || ($error !== '' && (int) ($existing['status'] ?? 0) !== 4); + $version = max(1, (int) ($existing['desired_version'] ?? 0) + ($desiredChanged ? 1 : 0)); + $existingStatus = (int) ($existing['status'] ?? 0); + $leaseActive = in_array($existingStatus, [2, 5], true) + && (int) ($existing['lock_until'] ?? 0) > $now; $data = [ 'promotion_link_id' => $linkId, - 'desired_member_id' => $desiredMemberId, + 'desired_member_id' => 0, 'desired_version' => $version, - 'status' => $error !== '' ? 4 : ($pending ? 1 : (int) ($existing['status'] ?? 0)), - 'next_retry' => $error !== '' ? strtotime('tomorrow', $now) : ($pending ? $now : 0), - 'last_error' => mb_substr($error, 0, 500), + // 活跃租约不能被回调或分钟重算抢占;版本变化会让当前工作完成后继续同步。 + 'status' => $leaseActive ? $existingStatus : ($error !== '' ? 4 : ($pending ? 1 : 0)), + 'next_retry' => $leaseActive + ? (int) ($existing['next_retry'] ?? 0) + : ($error !== '' ? strtotime('tomorrow', $now) : ($pending ? $now : 0)), + 'last_error' => $leaseActive + ? (string) ($existing['last_error'] ?? '') + : mb_substr($error, 0, 500), + 'applied_member_id' => 0, 'update_time' => $now, ]; - if ($pending) { + if (!$leaseActive) { $data['attempts'] = 0; } if ($existing) { @@ -318,6 +352,18 @@ class QywxPromotionMemberSchedulerService ->value('id') ?? 0); } + /** @return list */ + private static function linkRangeUserIds(int $linkId): array + { + $json = (string) (Db::name('qywx_promotion_link')->where('id', $linkId)->value('range_user_json') ?? '[]'); + $decoded = json_decode($json, true); + + return array_values(array_filter(array_map( + static fn (mixed $value): string => trim((string) $value), + is_array($decoded) ? $decoded : [] + ), static fn (string $value): bool => $value !== '')); + } + /** @param list> $members */ private static function persistMemberCursors(array $members, int $now): void { diff --git a/server/app/common/service/qywx/QywxPromotionRangeSyncService.php b/server/app/common/service/qywx/QywxPromotionRangeSyncService.php index 232120420..202239585 100644 --- a/server/app/common/service/qywx/QywxPromotionRangeSyncService.php +++ b/server/app/common/service/qywx/QywxPromotionRangeSyncService.php @@ -7,7 +7,7 @@ namespace app\common\service\qywx; use RuntimeException; use think\facade\Db; -/** 把回调调度出的单个目标成员同步到同一条企业微信官方获客链接。 */ +/** 把当前全部可用医助同步到同一条企业微信官方获客链接,由企微执行原生多人路由。 */ class QywxPromotionRangeSyncService { private QywxCustomerAcquisitionApiService $api; @@ -27,7 +27,7 @@ class QywxPromotionRangeSyncService } $status = (int) ($row['status'] ?? 0); $now = time(); - if ($status === 0 || $status === 4 || ($status === 2 && (int) ($row['lock_until'] ?? 0) > $now)) { + if ($status === 0 || $status === 4 || $status === 5 || ($status === 2 && (int) ($row['lock_until'] ?? 0) > $now)) { return null; } if ($status === 3 && (int) ($row['next_retry'] ?? 0) > $now) { @@ -52,34 +52,55 @@ class QywxPromotionRangeSyncService $token = (string) $claim['lock_token']; $desiredVersion = (int) ($claim['desired_version'] ?? 0); - $memberId = (int) ($claim['desired_member_id'] ?? 0); try { $pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->find(); $link = Db::name('qywx_promotion_link') ->where('id', (int) ($claim['promotion_link_id'] ?? 0)) ->whereNull('delete_time')->find(); - $member = Db::name('qywx_promotion_pool_member') - ->where('id', $memberId)->where('pool_id', $poolId)->whereNull('delete_time')->find(); - if (!$pool || !$link || !$member || (int) ($member['enabled'] ?? 0) !== 1) { - throw new RuntimeException('分流方案、官方链接或目标成员已失效'); + $members = Db::name('qywx_promotion_pool_member') + ->where('pool_id', $poolId)->whereNull('delete_time')->order('id', 'asc')->select()->toArray(); + if (!$pool || !$link || $members === []) { + throw new RuntimeException('分流方案、官方链接或方案成员已失效'); } $remoteLinkId = trim((string) ($link['remote_link_id'] ?? '')); - $userId = trim((string) ($member['userid'] ?? '')); - if ($remoteLinkId === '' || $userId === '') { - throw new RuntimeException('官方链接 ID 或目标成员 userid 为空'); + if ($remoteLinkId === '') { + throw new RuntimeException('官方链接 ID 为空'); + } + $range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time()); + $desiredUserIds = $range['userids']; + if ($desiredUserIds === []) { + $message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员'; + Db::name('qywx_promotion_range_sync') + ->where('pool_id', $poolId)->where('lock_token', $token)->update([ + 'status' => 4, + 'desired_member_id' => 0, + 'applied_member_id' => 0, + 'next_retry' => strtotime('tomorrow'), + 'attempts' => 0, + 'lock_token' => '', + 'lock_until' => 0, + 'last_error' => $message, + 'update_time' => time(), + ]); + Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([ + 'sync_error' => $message, + 'update_time' => time(), + ]); + + return ['status' => 'blocked', 'pool_id' => $poolId, 'member_id' => 0]; } $this->api->updateLink([ 'link_id' => $remoteLinkId, 'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30), - 'range' => ['user_list' => [$userId]], + 'range' => ['user_list' => $desiredUserIds], 'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1, ]); $response = $this->api->getLink($remoteLinkId); $remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId); $actualUserIds = $remote['range_userids']; - if ($actualUserIds !== [$userId]) { - throw new RuntimeException('企业微信返回的成员范围与待同步成员不一致'); + if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)) { + throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致'); } $url = $remote['url']; $snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -101,7 +122,8 @@ class QywxPromotionRangeSyncService ->where('lock_token', $token) ->update([ 'status' => $freshVersion === $desiredVersion ? 0 : 1, - 'applied_member_id' => $memberId, + 'desired_member_id' => 0, + 'applied_member_id' => 0, 'applied_version' => $desiredVersion, 'next_retry' => $freshVersion === $desiredVersion ? 0 : time(), 'attempts' => 0, @@ -111,7 +133,7 @@ class QywxPromotionRangeSyncService 'update_time' => time(), ]); - return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => $memberId]; + return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => 0]; } catch (\Throwable $e) { $attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1); Db::name('qywx_promotion_range_sync') @@ -136,16 +158,21 @@ class QywxPromotionRangeSyncService /** @return array{selected:int,synced:int,failed:int} */ public function syncPending(int $limit = 100): array { + // 每分钟分页重算全部方案,避免有效期、跨日上限变化漏同步,也避免方案数超过批次后饥饿。 + $lastPoolId = 0; + do { + $reconcilePoolIds = Db::name('qywx_promotion_range_sync') + ->where('pool_id', '>', $lastPoolId) + ->order('pool_id', 'asc') + ->limit(500) + ->column('pool_id'); + foreach ($reconcilePoolIds as $reconcilePoolId) { + $lastPoolId = (int) $reconcilePoolId; + QywxPromotionMemberSchedulerService::reconcilePool($lastPoolId); + } + } while (count($reconcilePoolIds) === 500); + $now = time(); - $blockedPoolIds = Db::name('qywx_promotion_range_sync') - ->where('status', 4) - ->where('next_retry', '>', 0) - ->where('next_retry', '<=', $now) - ->limit(min(500, max(1, $limit))) - ->column('pool_id'); - foreach ($blockedPoolIds as $blockedPoolId) { - QywxPromotionMemberSchedulerService::reconcilePool((int) $blockedPoolId); - } $poolIds = Db::name('qywx_promotion_range_sync') ->where(function ($query) use ($now): void { $query->whereIn('status', [1, 3])->where('next_retry', '<=', $now) diff --git a/server/app/common/service/qywx/QywxPromotionWeightedRandom.php b/server/app/common/service/qywx/QywxPromotionWeightedRandom.php deleted file mode 100644 index de74cc025..000000000 --- a/server/app/common/service/qywx/QywxPromotionWeightedRandom.php +++ /dev/null @@ -1,83 +0,0 @@ -> $members - * @param int|null $draw 测试用固定抽签值;生产环境留空并使用 random_int。 - * @return array{selected_id:int,members:list>,eligible_count:int,total_weight:int} - */ - public static function select(array $members, string $today, int $now, ?int $draw = null): array - { - $eligibleIndexes = []; - $totalWeight = 0; - foreach ($members as $index => &$member) { - if ((string) ($member['today_date'] ?? '') !== $today) { - $member['today_date'] = $today; - $member['today_count'] = 0; - } - // current_weight 是旧版平滑轮询游标;随机模式不再使用,统一归零。 - $member['current_weight'] = 0; - if (!self::eligible($member, $now)) { - continue; - } - $weight = max(1, (int) ($member['weight'] ?? 1)); - $eligibleIndexes[] = ['index' => $index, 'weight' => $weight]; - $totalWeight += $weight; - } - unset($member); - - if ($eligibleIndexes === []) { - return [ - 'selected_id' => 0, - 'members' => array_values($members), - 'eligible_count' => 0, - 'total_weight' => 0, - ]; - } - - $ticket = $draw ?? random_int(1, $totalWeight); - if ($ticket < 1 || $ticket > $totalWeight) { - throw new InvalidArgumentException('加权随机抽签值超出有效范围'); - } - $cursor = 0; - $selectedIndex = (int) $eligibleIndexes[0]['index']; - foreach ($eligibleIndexes as $candidate) { - $cursor += (int) $candidate['weight']; - if ($ticket <= $cursor) { - $selectedIndex = (int) $candidate['index']; - break; - } - } - - return [ - 'selected_id' => (int) ($members[$selectedIndex]['id'] ?? 0), - 'members' => array_values($members), - 'eligible_count' => count($eligibleIndexes), - 'total_weight' => $totalWeight, - ]; - } - - /** @param array $member */ - public static function eligible(array $member, int $now): bool - { - if ((int) ($member['enabled'] ?? 0) !== 1 || (int) ($member['weight'] ?? 0) <= 0) { - return false; - } - $start = max(0, (int) ($member['active_start'] ?? 0)); - $end = max(0, (int) ($member['active_end'] ?? 0)); - if (($start > 0 && $start > $now) || ($end > 0 && $end < $now)) { - return false; - } - $limit = max(0, (int) ($member['daily_limit'] ?? 0)); - - return $limit === 0 || (int) ($member['today_count'] ?? 0) < $limit; - } -} diff --git a/server/docs/qywx_promotion.md b/server/docs/qywx_promotion.md index bb8ddf8e9..587db1656 100644 --- a/server/docs/qywx_promotion.md +++ b/server/docs/qywx_promotion.md @@ -30,15 +30,15 @@ HOST = "https://公开访问域名" - 更新获客链接 `update_link` - 删除获客链接 `delete_link` -当前管理端按“一个分流方案对应一个官方获客链接”管理。删除分流方案只软删除本地记录,不调用企业微信 `delete_link`,因此不会破坏已有客户归因;官方永久删除接口仅保留给兼容接口使用,调用后无法恢复。 +当前管理端按“一个分流方案对应一个官方获客链接”管理。删除方案时先调用企业微信 `delete_link` 永久删除关联的官方链接,成功后再软删除本地方案、成员和链接记录;如果企业微信删除失败,本地方案会保留并返回错误,避免两端状态不一致。官方链接删除后无法恢复,已投放 URL 会失效,历史本地客户归因记录仍保留。 获客成员来自后台管理员的 `work_wechat_userid`。管理员可管理全量;组长、医助等账号只返回 `DataScopeService` 当前角色与部门范围内的成员。同步远端链接时,非全量账号只导入 `range.user_list` 与其可见成员有交集的数据;企业微信部门 ID 尚未建立本地映射时按安全原则隐藏,不会越权放行。 `list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面创建分流方案并选择获客成员。 -创建分流方案时必须选择一名或多名医助。所有医助分别保存启用状态、权重、每日上限、有效时间和实际获客计数,但一个方案仍只创建一条企业微信官方链接。官方链接当前的 `range.user_list` 只放调度器选中的一名成员,回调确认实际承接结果后再按权重随机抽取下一名并更新同一个 `link_id`,因此对外 URL 始终不变。 +创建分流方案时必须选择一名或多名医助。一个方案只创建一条企业微信官方链接,当前全部可用医助会同时写入该链接的 `range.user_list`,由企业微信在打开、添加阶段执行官方多人路由。成员可配置启用状态、每日上限和有效时间,系统按实际获客回调累计数量。 -新建方案时的首名成员也会从所选医助中随机产生;编辑已有方案时,如果当前成员仍可用则保持不变,避免无获客事件时无故切换。 +企业微信 `range.user_list` 只接受成员 userid 列表,不提供逐成员权重字段,因此本站不再展示或执行 2:1:1 一类权重规则。官方多人路由的实际承接还会受到成员可服务状态、客户已有好友关系等企业微信规则影响,不能承诺每次刷新严格随机或短期样本绝对平均。 系统只使用企业微信获客助手生成的链接: @@ -56,7 +56,7 @@ https://work.weixin.qq.com/ca/xxxxxxxx?customer_channel=zyt_pool:123 其中 `customer_channel` 是本站写入的自定义渠道值,格式为 `zyt_pool:分流方案ID`;它与示例中的 `qywx_ca:...` 作用相同,但命名空间和数值由各系统自行定义。 -## 回调驱动成员调度 +## 回调统计与成员范围维护 部署时必须执行: @@ -70,11 +70,11 @@ server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql https://你的域名/api/qywx/external-contact/notify ``` -调度优先使用 `change_external_contact/add_external_contact` 事件中的 `State`、`UserID` 和 `ExternalUserID`。`State` 来自主链接的 `customer_channel=zyt_pool:方案ID`,因此可以定位方案及实际承接医助;获客会话回调会通过 `ChatKey → get_chat_info` 作为补偿。方案、成员和客户组合使用唯一幂等键,同一实际获客不会因重复回调重复计数。 +回调优先使用 `change_external_contact/add_external_contact` 事件中的 `State`、`UserID` 和 `ExternalUserID`。`State` 来自主链接的 `customer_channel=zyt_pool:方案ID`,因此可以定位方案及实际承接医助;获客会话回调会通过 `ChatKey → get_chat_info` 作为补偿。方案和客户组合使用唯一幂等键,同一实际获客不会因重复回调或后续更换跟进成员而重复计数。 -每次确认实际承接后,系统执行加权随机抽取:权重越大,被抽中的概率越高;禁用、尚未生效、已过期或达到今日上限的成员不会进入随机池。随机允许连续抽中同一成员。待同步范围由 `qywx:sync-promotion-ranges` 每分钟重试,管理端主动禁用当前成员时也会立即尝试同步。 +创建或编辑方案时,系统把所有已启用、已生效且未达到今日上限的成员一次写入同一个官方链接。禁用、尚未生效、已过期或达到今日上限的成员会从官方范围移出;跨日或重新进入有效期后会自动加入。`qywx:sync-promotion-ranges` 每分钟重算全部方案并重试失败同步,管理端修改成员规则时也会立即尝试同步。 -权重和数量属于回调驱动的近实时控制,并非点击前的强事务:多个客户在企微回调或 `update_link` 生效前并发访问时,可能仍由同一成员承接;所有成员都达到上限时,企业微信不允许把 `range.user_list` 更新为空,系统会标记“无可用成员”并保留最后一次有效范围。因此数量上限用于自动退出后续调度,不承诺并发场景下绝对零超量。 +每日数量属于回调驱动的近实时软上限,并非点击前的强事务:多个客户在企微回调或 `update_link` 生效前并发访问时,可能出现少量超量;所有成员都达到上限时,企业微信不允许把 `range.user_list` 更新为空,系统会标记“无可用成员”并保留最后一次有效范围。因此该上限用于自动退出后续官方路由,不承诺并发场景下绝对零超量。 如果需要为点击 IP 生成不可逆服务端哈希,可在 `[qywx_promotion]` 下额外设置独立的 `CREDENTIAL_KEY`。 @@ -124,4 +124,4 @@ window.WecomPromotion['分流方案KEY'].destroy() 旧 `/go` 兼容入口记录点击来源时,只保存页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也不应放置手机号、患者 ID、重置令牌等敏感信息。 -用户始终看到同一个企业微信官方获客链接。当前激活成员由本站根据实际回调、启用状态、权重和上限动态更新;企业微信仍可能根据成员可服务状态和已有好友关系等规则影响最终承接结果,后续回调会按实际结果自动纠偏。 +用户始终看到同一个企业微信官方获客链接。本站只维护该链接的可用成员集合,实际多人分流由企业微信完成;回调负责记录实际承接结果,并在成员达到上限后更新集合。 diff --git a/server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql b/server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql index 84a1ed4ee..e301b350b 100644 --- a/server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql +++ b/server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql @@ -1,4 +1,4 @@ --- 企业微信获客助手:单官方直链的成员规则与回调驱动调度 +-- 企业微信获客助手:单官方直链的成员规则与企业微信原生多人路由 START TRANSACTION; @@ -8,8 +8,8 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_pool_member` ( `admin_id` int unsigned NOT NULL DEFAULT 0, `userid` varchar(128) NOT NULL DEFAULT '' COMMENT '企业微信 userid 快照', `enabled` tinyint unsigned NOT NULL DEFAULT 1, - `weight` smallint unsigned NOT NULL DEFAULT 1, - `current_weight` bigint NOT NULL DEFAULT 0 COMMENT '旧版平滑轮询游标,随机调度模式保留兼容', + `weight` smallint unsigned NOT NULL DEFAULT 1 COMMENT '兼容字段;企业微信原生多人路由不支持逐成员权重', + `current_weight` bigint NOT NULL DEFAULT 0 COMMENT '旧版调度游标,原生多人路由模式固定归零', `daily_limit` int unsigned NOT NULL DEFAULT 0 COMMENT '0为不限;按回调确认的实际获客计数', `today_count` int unsigned NOT NULL DEFAULT 0, `today_date` date NULL DEFAULT NULL, @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_pool_member` ( CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_dispatch_event` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `event_key` char(64) NOT NULL COMMENT '方案+成员+客户的幂等键', + `event_key` char(64) NOT NULL COMMENT '方案+客户的幂等键,成员变更不重复计数', `pool_id` int unsigned NOT NULL DEFAULT 0, `member_id` bigint unsigned NOT NULL DEFAULT 0, `userid` varchar(128) NOT NULL DEFAULT '', @@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_range_sync` ( `desired_version` bigint unsigned NOT NULL DEFAULT 0, `applied_member_id` bigint unsigned NOT NULL DEFAULT 0, `applied_version` bigint unsigned NOT NULL DEFAULT 0, - `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0已同步 1待同步 2同步中 3失败待重试 4无可用成员', + `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0已同步 1待同步 2同步中 3失败待重试 4阻塞 5正在永久删除', `attempts` int unsigned NOT NULL DEFAULT 0, `next_retry` int unsigned NOT NULL DEFAULT 0, `lock_token` char(32) NOT NULL DEFAULT '', @@ -62,7 +62,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_range_sync` ( PRIMARY KEY (`pool_id`), KEY `idx_pending` (`status`,`next_retry`,`lock_until`), KEY `idx_link` (`promotion_link_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='获客官方链接当前成员范围同步队列'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='获客官方链接多人路由范围同步队列'; INSERT INTO `zyt_dev_crontab` ( `name`, `type`, `system`, `remark`, `command`, `params`, @@ -70,7 +70,7 @@ INSERT INTO `zyt_dev_crontab` ( ) SELECT '同步企微获客分流成员', 1, 0, - '按回调实际承接结果、成员权重与每日上限更新同一条企业微信官方获客链接', + '按禁用、有效期与每日上限更新同一条企业微信官方获客链接的多人范围', 'qywx:sync-promotion-ranges', '', 1, '* * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() FROM DUAL WHERE NOT EXISTS ( diff --git a/server/tests/QywxCustomerAcquisitionApiServiceTest.php b/server/tests/QywxCustomerAcquisitionApiServiceTest.php index 60fe15248..6a7c65271 100644 --- a/server/tests/QywxCustomerAcquisitionApiServiceTest.php +++ b/server/tests/QywxCustomerAcquisitionApiServiceTest.php @@ -52,8 +52,12 @@ $service = new QywxCustomerAcquisitionApiService(new Client([ $list = $service->listLinks('', 100); $detail = $service->getLink('link_1'); -$created = $service->createLink(['link_name' => '官网获客', 'range' => ['user_list' => ['zhangsan']]]); -$service->updateLink(['link_id' => 'link_1', 'link_name' => '官网获客-更新']); +$created = $service->createLink(['link_name' => '官网获客', 'range' => ['user_list' => ['zhangsan', 'lisi']]]); +$service->updateLink([ + 'link_id' => 'link_1', + 'link_name' => '官网获客-更新', + 'range' => ['user_list' => ['lisi', 'zhangsan']], +]); $service->deleteLink('link_1'); $customers = $service->listCustomers('link_1', '', 1000); $chat = $service->getChatInfo('chat_key_1'); @@ -88,7 +92,11 @@ $actualPaths = array_map(static fn (array $entry): string => $entry['request']-> $assert($actualPaths === $expectedPaths, '请求端点不正确:' . implode(', ', $actualPaths)); $createPayload = json_decode((string) $history[2]['request']->getBody(), true); -$assert(($createPayload['range']['user_list'][0] ?? '') === 'zhangsan', 'create_link 成员范围请求体不正确'); +$assert(($createPayload['range']['user_list'] ?? []) === ['zhangsan', 'lisi'], 'create_link 多人成员范围请求体不正确'); +$updatePayload = json_decode((string) $history[3]['request']->getBody(), true); +$assert(($updatePayload['range']['user_list'] ?? []) === ['lisi', 'zhangsan'], 'update_link 多人成员范围请求体不正确'); +$deletePayload = json_decode((string) $history[4]['request']->getBody(), true); +$assert(($deletePayload['link_id'] ?? '') === 'link_1', 'delete_link 请求体缺少官方 link_id'); $customerPayload = json_decode((string) $history[5]['request']->getBody(), true); $assert(($customerPayload['link_id'] ?? '') === 'link_1' && ($customerPayload['limit'] ?? 0) === 1000, 'customer 请求体不正确'); $chatPayload = json_decode((string) $history[6]['request']->getBody(), true); diff --git a/server/tests/QywxPromotionWeightedRandomTest.php b/server/tests/QywxPromotionWeightedRandomTest.php deleted file mode 100644 index 3e78ea2e5..000000000 --- a/server/tests/QywxPromotionWeightedRandomTest.php +++ /dev/null @@ -1,46 +0,0 @@ - 1, 'enabled' => 1, 'weight' => 2, 'current_weight' => 9, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today], - ['id' => 2, 'enabled' => 1, 'weight' => 1, 'current_weight' => -3, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today], - ['id' => 3, 'enabled' => 1, 'weight' => 1, 'current_weight' => 4, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today], -]; - -$assert(QywxPromotionWeightedRandom::select($members, $today, $now, 1)['selected_id'] === 1, '权重 2 的第一个区间映射错误'); -$assert(QywxPromotionWeightedRandom::select($members, $today, $now, 2)['selected_id'] === 1, '权重 2 的第二个区间映射错误'); -$assert(QywxPromotionWeightedRandom::select($members, $today, $now, 3)['selected_id'] === 2, '第二名成员的随机区间映射错误'); -$fourth = QywxPromotionWeightedRandom::select($members, $today, $now, 4); -$assert($fourth['selected_id'] === 3, '第三名成员的随机区间映射错误'); -$assert($fourth['total_weight'] === 4 && $fourth['eligible_count'] === 3, '随机池权重合计错误'); -$assert(array_column($fourth['members'], 'current_weight') === [0, 0, 0], '旧版平滑游标没有归零'); - -$limited = QywxPromotionWeightedRandom::select([ - ['id' => 1, 'enabled' => 0, 'weight' => 10, 'current_weight' => 0, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today], - ['id' => 2, 'enabled' => 1, 'weight' => 5, 'current_weight' => 0, 'daily_limit' => 3, 'today_count' => 3, 'today_date' => $today], - ['id' => 3, 'enabled' => 1, 'weight' => 1, 'current_weight' => 0, 'daily_limit' => 0, 'today_count' => 8, 'today_date' => $today], -], $today, $now, 1); -$assert($limited['selected_id'] === 3 && $limited['eligible_count'] === 1, '禁用成员或达到上限的成员仍进入随机池'); - -$reset = QywxPromotionWeightedRandom::select([ - ['id' => 4, 'enabled' => 1, 'weight' => 1, 'current_weight' => 0, 'daily_limit' => 1, 'today_count' => 1, 'today_date' => '2026-08-24'], -], $today, $now, 1); -$assert($reset['selected_id'] === 4 && $reset['members'][0]['today_count'] === 0, '跨日数量没有自动重置'); -$assert(QywxPromotionMemberSchedulerService::poolIdFromState('zyt_pool:123') === 123, 'customer_channel 方案 ID 解析失败'); -$assert(QywxPromotionMemberSchedulerService::poolIdFromState('qywx_ca:123') === 0, '不应接管其他系统的 customer_channel'); - -echo "QYWX_PROMOTION_WEIGHTED_RANDOM_OK\n";