更新
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user