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']);
}
}
}
@@ -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());
@@ -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
@@ -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<string> */
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<array<string,mixed>> $members */
private static function persistMemberCursors(array $members, int $now): void
{
@@ -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)
@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use InvalidArgumentException;
/** 获客方案成员的加权随机选择;数据库锁与持久化由调度服务负责。 */
class QywxPromotionWeightedRandom
{
/**
* @param list<array<string,mixed>> $members
* @param int|null $draw 测试用固定抽签值;生产环境留空并使用 random_int。
* @return array{selected_id:int,members:list<array<string,mixed>>,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<string,mixed> $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;
}
}