This commit is contained in:
Your Name
2026-09-07 10:07:47 +08:00
parent cf3fbdc5ef
commit d5164b7369
388 changed files with 19863 additions and 18720 deletions
@@ -359,9 +359,37 @@ class WecomPromotionLogic
$automation,
$skipVerify,
$createdRemote,
&$eligibleUserIds,
$now,
$adminId
): void {
if ($existingPool !== null) {
$lockedPool = Db::name('qywx_promotion_pool')
->where('id', $id)
->whereNull('delete_time')
->lock(true)
->find();
if (!$lockedPool) {
throw new RuntimeException('分流方案不存在或已删除');
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->lock(true)->find();
$lockedRules = Db::name('qywx_promotion_pool_member')
->where('pool_id', $id)
->order('id', 'asc')
->lock(true)
->select()->toArray();
$lockedActiveRules = array_values(array_filter(
$lockedRules,
static fn (array $rule): bool => ($rule['delete_time'] ?? null) === null
));
// 用锁内最新状态再校验最终成员集合,避免与单个或批量下线并发后留下空范围。
$eligibleUserIds = self::eligibleSelectedUserIds(
$id,
$members,
$automation ?? [],
$lockedActiveRules
);
}
if ($existingPool === null) {
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [
'public_key' => bin2hex(random_bytes(16)),
@@ -428,7 +456,13 @@ class WecomPromotionLogic
}
$syncError = '';
if (!$createdRemote) {
// 已有方案补建官方链接时,创建接口发生在事务锁之前;若锁内成员范围已变化,
// 仍需把最终范围加入同步队列,避免新链接停留在过期的远端成员集合。
$createdRangeChanged = $createdRemote
&& $remote !== null
&& !QywxPromotionMemberRange::same((array) ($remote['range_userids'] ?? []), $eligibleUserIds);
$needsQueuedSync = !$createdRemote || $createdRangeChanged;
if ($needsQueuedSync) {
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
if ($syncImmediately) {
try {
@@ -455,15 +489,15 @@ class WecomPromotionLogic
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
'automation_saved' => $automation !== null,
'sync_error' => $syncError,
'sync_queued' => !$createdRemote && !$syncImmediately,
'sync_queued' => $needsQueuedSync && !$syncImmediately,
];
}
/**
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;每个方案仍复用
* savePool 的成员、自动化、素材和企业微信同步校验。
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;方案配置仍复用
* savePool 的成员、自动化、素材和企业微信同步校验,员工状态按方案合并更新
*
* @return array{pool_ids:list<int>,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,results:list<array<string,mixed>>}
* @return array{pool_ids:list<int>,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,member_matched:int,member_updated:int,results:list<array<string,mixed>>}
*/
public static function batchUpdatePools(array $params, int $adminId, array $adminInfo): array
{
@@ -481,7 +515,7 @@ class WecomPromotionLogic
if (!is_array($changes)) {
throw new RuntimeException('批量修改内容格式不正确');
}
$allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config'];
$allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config', 'member_status'];
$unknownFields = array_diff(array_keys($changes), $allowedFields);
if ($unknownFields !== []) {
throw new RuntimeException('批量修改包含不支持的字段');
@@ -493,6 +527,31 @@ class WecomPromotionLogic
throw new RuntimeException('兜底获客助手链接格式不正确');
}
$memberStatusPatch = null;
if (array_key_exists('member_status', $changes)) {
if (!is_array($changes['member_status'])) {
throw new RuntimeException('员工上下线配置格式不正确');
}
if (array_diff(array_keys($changes['member_status']), ['member_admin_ids', 'status']) !== []) {
throw new RuntimeException('员工上下线配置包含不支持的字段');
}
$memberAdminIds = self::normalizePositiveIds((array) ($changes['member_status']['member_admin_ids'] ?? []));
if ($memberAdminIds === []) {
throw new RuntimeException('请至少选择一名需要批量上线或下线的员工');
}
if (count($memberAdminIds) > 100) {
throw new RuntimeException('单次最多设置 100 名员工');
}
$memberStatus = $changes['member_status']['status'] ?? null;
if (!in_array($memberStatus, [0, 1, '0', '1'], true)) {
throw new RuntimeException('员工上线状态只能为上线或下线');
}
$memberStatusPatch = [
'member_admin_ids' => $memberAdminIds,
'status' => (int) $memberStatus,
];
}
$automationPatch = null;
if (array_key_exists('automation_config', $changes)) {
if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) {
@@ -510,6 +569,9 @@ class WecomPromotionLogic
QywxPromotionConfig::assertInstalled();
$automationPatch = $changes['automation_config'];
}
if ($memberStatusPatch !== null && count($changes) > 1) {
throw new RuntimeException('员工上下线需要单独批量保存,请勿与其他方案配置同时修改');
}
// 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。
$pools = [];
@@ -523,11 +585,48 @@ class WecomPromotionLogic
);
}
// 在任何方案写入前验证员工范围和“至少一名上线员工”约束,避免可预见的部分更新。
if ($memberStatusPatch !== null) {
$memberRowsByPool = [];
foreach (Db::name('qywx_promotion_pool_member')
->whereIn('pool_id', $poolIds)
->whereNull('delete_time')
->select()->toArray() as $memberRow) {
$memberRowsByPool[(int) ($memberRow['pool_id'] ?? 0)][] = $memberRow;
}
$matchedMembers = 0;
foreach ($poolIds as $poolId) {
$simulatedAutomation = QywxPromotionConfig::forPool($poolId);
if ($automationPatch !== null) {
$simulatedAutomation = QywxPromotionConfig::normalize(array_replace(
$simulatedAutomation,
$automationPatch
));
}
$poolMembers = $memberRowsByPool[$poolId] ?? [];
$simulatedAutomation = self::automationWithMemberUserIds($simulatedAutomation, $poolMembers);
$preview = self::previewPoolMemberStatus(
$poolMembers,
$memberStatusPatch['member_admin_ids'],
$memberStatusPatch['status'],
$simulatedAutomation,
(string) ($pools[$poolId]['name'] ?? ('#' . $poolId))
);
$matchedMembers += count($preview['matched_ids']);
}
if ($matchedMembers === 0) {
throw new RuntimeException('所选方案中没有找到指定员工,请刷新页面后重试');
}
}
$results = [];
$updated = 0;
$failed = 0;
$syncErrorCount = 0;
$syncQueuedCount = 0;
$memberMatched = 0;
$memberUpdated = 0;
$hasPoolConfigChanges = array_diff(array_keys($changes), ['member_status']) !== [];
foreach ($poolIds as $poolId) {
$pool = $pools[$poolId];
$currentAutomation = QywxPromotionConfig::forPool($poolId);
@@ -565,14 +664,28 @@ class WecomPromotionLogic
}
try {
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。
$saved = self::savePool($saveParams, $adminId, $adminInfo, false);
$saved = ['sync_error' => '', 'sync_queued' => false];
if ($hasPoolConfigChanges) {
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。
$saved = self::savePool($saveParams, $adminId, $adminInfo, false);
}
$poolMemberResult = ['matched' => 0, 'updated' => 0, 'dispatch' => null];
if ($memberStatusPatch !== null) {
$poolMemberResult = self::updatePoolMemberStatuses(
$poolId,
$memberStatusPatch['member_admin_ids'],
$memberStatusPatch['status']
);
$memberMatched += $poolMemberResult['matched'];
$memberUpdated += $poolMemberResult['updated'];
}
$syncError = trim((string) ($saved['sync_error'] ?? ''));
$updated++;
if ($syncError !== '') {
$syncErrorCount++;
}
$syncQueued = !empty($saved['sync_queued']);
$syncQueued = !empty($saved['sync_queued'])
|| !empty($poolMemberResult['dispatch']['queued']);
if ($syncQueued) {
$syncQueuedCount++;
}
@@ -582,6 +695,8 @@ class WecomPromotionLogic
'success' => true,
'sync_error' => $syncError,
'sync_queued' => $syncQueued,
'member_matched' => $poolMemberResult['matched'],
'member_updated' => $poolMemberResult['updated'],
];
} catch (\Throwable $error) {
$failed++;
@@ -600,10 +715,156 @@ class WecomPromotionLogic
'failed' => $failed,
'sync_error_count' => $syncErrorCount,
'sync_queued_count' => $syncQueuedCount,
'member_matched' => $memberMatched,
'member_updated' => $memberUpdated,
'results' => $results,
];
}
/**
* @param list<int> $memberAdminIds
* @return array{matched:int,updated:int,dispatch:?array}
*/
private static function updatePoolMemberStatuses(int $poolId, array $memberAdminIds, int $status): array
{
return Db::transaction(function () use ($poolId, $memberAdminIds, $status): array {
// 先锁方案阻止同方案获客回调进入,再按“同步任务 -> 成员规则”顺序加锁。
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$pool) {
throw new RuntimeException('分流方案不存在或已删除');
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
$members = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->whereNull('delete_time')
->lock(true)
->select()->toArray();
$preview = self::previewPoolMemberStatus(
$members,
$memberAdminIds,
$status,
QywxPromotionConfig::forPool($poolId)
);
$matchedIds = $preview['matched_ids'];
$updateIds = $preview['update_ids'];
if ($updateIds !== []) {
Db::name('qywx_promotion_pool_member')->whereIn('id', $updateIds)->update([
'enabled' => $status,
'update_time' => time(),
]);
}
$dispatch = $updateIds !== []
? QywxPromotionMemberSchedulerService::reconcilePool($poolId)
: null;
// 下线必须能够安全收缩远端范围;上线即使尚未到生效时段,也应先保存规则。
if ($dispatch !== null && $status === 0) {
self::assertMemberDispatchReady($dispatch);
}
return [
'matched' => count($matchedIds),
'updated' => count($updateIds),
'dispatch' => $dispatch,
];
});
}
/**
* @param list<array<string,mixed>> $members
* @param list<int> $memberAdminIds
* @param array<string,mixed> $automation
* @return array{members:list<array<string,mixed>>,matched_ids:list<int>,update_ids:list<int>}
*/
private static function previewPoolMemberStatus(
array $members,
array $memberAdminIds,
int $status,
array $automation,
string $poolName = ''
): array {
$targetAdminIds = array_fill_keys($memberAdminIds, true);
$matchedIds = [];
$updateIds = [];
$targetedEnabled = 0;
$remainingEnabled = 0;
foreach ($members as &$member) {
$isTarget = isset($targetAdminIds[(int) ($member['admin_id'] ?? 0)]);
if ($isTarget) {
$matchedIds[] = (int) ($member['id'] ?? 0);
if ((int) ($member['enabled'] ?? 0) !== $status) {
$updateIds[] = (int) ($member['id'] ?? 0);
}
}
if ((int) ($member['enabled'] ?? 0) === 1) {
if ($isTarget) {
$targetedEnabled++;
} else {
$remainingEnabled++;
}
}
if ($isTarget) {
$member['enabled'] = $status;
}
}
unset($member);
if ($status === 0 && $targetedEnabled > 0 && $remainingEnabled === 0) {
throw new RuntimeException(self::memberStatusError(
$poolName,
'至少需要保留一名上线员工'
));
}
if ($status === 0 && $targetedEnabled > 0) {
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), $automation);
if ($range['userids'] === []) {
throw new RuntimeException(self::memberStatusError(
$poolName,
'至少需要保留一名当前可用的上线员工'
));
}
}
return ['members' => $members, 'matched_ids' => $matchedIds, 'update_ids' => $updateIds];
}
private static function memberStatusError(string $poolName, string $message): string
{
return $poolName === '' ? $message : sprintf('分流方案“%s”%s', $poolName, $message);
}
/** @param array<string,mixed> $dispatch */
private static function assertMemberDispatchReady(array $dispatch): void
{
if (!empty($dispatch['blocked'])) {
throw new RuntimeException('企业微信成员范围当前无法同步,请先检查官方链接状态和可用员工');
}
}
/** @param array<string,mixed> $automation @param list<array<string,mixed>> $members */
private static function automationWithMemberUserIds(array $automation, array $members): array
{
$userIdByAdminId = [];
foreach ($members as $member) {
$adminId = (int) ($member['admin_id'] ?? 0);
$userId = trim((string) ($member['userid'] ?? ''));
if ($adminId > 0 && $userId !== '') {
$userIdByAdminId[$adminId] = $userId;
}
}
$automation['backup_userids'] = array_values(array_filter(array_map(
static fn ($adminId): string => $userIdByAdminId[(int) $adminId] ?? '',
(array) ($automation['backup_member_admin_ids'] ?? [])
)));
foreach ((array) ($automation['reception_schedule'] ?? []) as $index => $slot) {
$automation['reception_schedule'][$index]['member_userids'] = array_values(array_filter(array_map(
static fn ($adminId): string => $userIdByAdminId[(int) $adminId] ?? '',
(array) ($slot['member_admin_ids'] ?? [])
)));
}
return $automation;
}
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0));
@@ -877,36 +1138,93 @@ class WecomPromotionLogic
$poolId = (int) $member['pool_id'];
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0;
if ($enabled === 0) {
$otherEnabled = (int) Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->where('id', '<>', $id)
->where('enabled', 1)
->whereNull('delete_time')
->count();
if ($otherEnabled <= 0) {
throw new RuntimeException('至少需要保留一名启用的获客医助');
}
}
$startAt = self::parseTime($params['active_start'] ?? null);
$endAt = self::parseTime($params['active_end'] ?? null);
$statusOnly = !empty($params['_status_only']);
$startAt = $statusOnly ? 0 : self::parseTime($params['active_start'] ?? null);
$endAt = $statusOnly ? 0 : self::parseTime($params['active_end'] ?? null);
if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
throw new RuntimeException('生效结束时间必须晚于开始时间');
}
$now = time();
Db::transaction(function () use ($id, $enabled, $params, $startAt, $endAt, $now): void {
Db::name('qywx_promotion_pool_member')->where('id', $id)->update([
$planned = Db::transaction(function () use (
$id,
$poolId,
$enabled,
$statusOnly,
$params,
$startAt,
$endAt,
$now
): array {
// 所有成员状态写入口使用相同锁顺序,防止单个与批量下线并发绕过保底校验。
$lockedPool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$lockedPool) {
throw new RuntimeException('分流方案不存在或已删除');
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
$members = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->whereNull('delete_time')
->order('id', 'asc')
->lock(true)
->select()->toArray();
$lockedMember = null;
foreach ($members as $current) {
if ((int) ($current['id'] ?? 0) === $id) {
$lockedMember = $current;
break;
}
}
if ($lockedMember === null) {
throw new RuntimeException('分流成员不存在或已移除');
}
$memberData = [
'enabled' => $enabled,
// 企业微信原生多人路由不支持逐成员权重;字段固定为 1,仅兼容已部署表结构。
'weight' => 1,
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'active_start' => $startAt,
'active_end' => $endAt,
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
'daily_limit' => $statusOnly
? (int) ($lockedMember['daily_limit'] ?? 0)
: min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'active_start' => $statusOnly ? (int) ($lockedMember['active_start'] ?? 0) : $startAt,
'active_end' => $statusOnly ? (int) ($lockedMember['active_end'] ?? 0) : $endAt,
'remark' => $statusOnly
? (string) ($lockedMember['remark'] ?? '')
: mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
'update_time' => $now,
]);
];
$isGoingOffline = (int) ($lockedMember['enabled'] ?? 0) === 1 && $enabled === 0;
$simulatedMembers = $members;
$enabledAfter = 0;
foreach ($simulatedMembers as &$simulatedMember) {
if ((int) ($simulatedMember['id'] ?? 0) === $id) {
$simulatedMember = array_replace($simulatedMember, $memberData);
}
if ((int) ($simulatedMember['enabled'] ?? 0) === 1) {
$enabledAfter++;
}
}
unset($simulatedMember);
if ($isGoingOffline) {
if ($enabledAfter === 0) {
throw new RuntimeException('至少需要保留一名启用的获客医助');
}
}
$today = date('Y-m-d', $now);
$automation = QywxPromotionConfig::forPool($poolId);
$currentRange = QywxPromotionMemberRange::evaluate($members, $today, $now, $automation);
$nextRange = QywxPromotionMemberRange::evaluate($simulatedMembers, $today, $now, $automation);
$rangeShrank = array_diff($currentRange['userids'], $nextRange['userids']) !== [];
if (($isGoingOffline || $rangeShrank) && $nextRange['userids'] === []) {
throw new RuntimeException('至少需要保留一名当前可用的上线员工');
}
Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->update($memberData);
$dispatch = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
if ($isGoingOffline || $rangeShrank) {
self::assertMemberDispatchReady($dispatch);
}
return $dispatch;
});
$planned = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
$syncError = '';
if ($planned['queued']) {
try {
@@ -931,10 +1249,7 @@ class WecomPromotionLogic
return self::saveMember([
'id' => $id,
'status' => $status,
'daily_limit' => (int) ($row['daily_limit'] ?? 0),
'active_start' => (int) ($row['active_start'] ?? 0),
'active_end' => (int) ($row['active_end'] ?? 0),
'remark' => (string) ($row['remark'] ?? ''),
'_status_only' => true,
], $adminId, $adminInfo);
}
@@ -1274,14 +1589,19 @@ class WecomPromotionLogic
}
/** @param list<array{id:int,userid:string}> $members @return list<string> */
private static function eligibleSelectedUserIds(int $poolId, array $members, array $config = []): array
private static function eligibleSelectedUserIds(
int $poolId,
array $members,
array $config = [],
?array $existingRules = null
): array
{
if ($members === []) {
throw new RuntimeException('请至少选择一名获客成员');
}
$rules = $poolId > 0
$rules = $existingRules ?? ($poolId > 0
? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
: [];
: []);
$rulesByUserId = [];
foreach ($rules as $rule) {
$rulesByUserId[(string) $rule['userid']] = $rule;
@@ -9,6 +9,7 @@ use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxSyncSettings;
use app\common\service\qywx\MediaChannelService;
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
use app\common\service\wechat\WechatWorkService;
use think\facade\Cache;
use think\facade\Db;
@@ -589,7 +590,11 @@ class CustomerLogic extends BaseLogic
*
* @see https://developer.work.weixin.qq.com/document/path/92130
*/
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
public static function upsertSingleExternalContactFromApi(
string $externalUserId,
int $snapshotEventId = 0,
string $snapshotFollowUserId = ''
): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
@@ -652,6 +657,13 @@ class CustomerLogic extends BaseLogic
$updateCount,
$skippedCount
);
if ($snapshotEventId > 0 && $snapshotFollowUserId !== '') {
QywxExternalContactEventTagSnapshotService::captureFromFollowUsers(
$snapshotEventId,
$snapshotFollowUserId,
$followUsers
);
}
MediaChannelService::forgetCurrentTagCatalogCache();
}
@@ -674,12 +686,12 @@ class CustomerLogic extends BaseLogic
*
* @param array<string, mixed> $data
*/
public static function recordExternalContactEvent(array $data): void
public static function recordExternalContactEvent(array $data): int
{
$changeType = (string) ($data['change_type'] ?? '');
if ($changeType === '') {
// 没有 ChangeType 的事件流水没有价值,直接丢弃
return;
return 0;
}
$eventTime = (int) ($data['event_time'] ?? 0);
@@ -716,9 +728,18 @@ class CustomerLogic extends BaseLogic
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES ('
. implode(',', array_fill(0, count($cols), '?')) . ')';
Db::execute($sql, array_values($row));
return (int) Db::name('qywx_external_contact_event')
->where('change_type', $row['change_type'])
->where('user_id', $row['user_id'])
->where('external_userid', $row['external_userid'])
->where('event_time', $row['event_time'])
->value('id');
} catch (\Throwable $e) {
// 事件流水只用于统计,失败只记日志不阻塞主回调
Log::warning('qywx external contact event insert failed: ' . $e->getMessage());
return 0;
}
}
@@ -340,8 +340,8 @@ class ConversionLogic
return $result;
}
/**
* Return the distinct external contacts behind an add_fans_count row.
/**
* Return the add events behind an add_fans_count row.
*
* The caller must pass the already-authorized admin range and the exact
* department ids represented by the clicked tree node. This keeps the
@@ -505,7 +505,7 @@ class ConversionLogic
return $empty;
}
$pairs = self::loadFanDetailRows(
$pairs = self::loadFanDetailRows(
$startTimestamp,
$endTimestamp,
$mediaChannel,
@@ -515,15 +515,20 @@ class ConversionLogic
);
// Target membership has already been resolved before the event query,
// so sorting and pagination operate on the clicked row's small set.
$matched = $pairs;
$matched = $pairs;
usort($matched, static function (array $left, array $right): int {
$timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0));
if ($timeCompare !== 0) {
return $timeCompare;
}
return strcmp(
if ($timeCompare !== 0) {
return $timeCompare;
}
$eventCompare = ((int) ($right['add_event_id'] ?? 0)) <=> ((int) ($left['add_event_id'] ?? 0));
if ($eventCompare !== 0) {
return $eventCompare;
}
return strcmp(
(string) ($left['external_userid'] ?? ''),
(string) ($right['external_userid'] ?? '')
) ?: strcmp(
@@ -552,45 +557,45 @@ class ConversionLogic
$pageRows,
static fn (array $row): bool => !empty($row['is_deleted'])
));
$deleteTimesByPair = [];
if ($deletedPageRows !== []) {
$deletedUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$deletedPageRows
))));
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
$deletedPageRows
))));
$deletedEvents = Db::name('qywx_external_contact_event')
->where('change_type', 'del_external_contact')
->where('event_time', '<=', $endTimestamp)
->whereIn('user_id', $deletedUserIds)
->whereIn('external_userid', $deletedExternalUserIds)
->field('user_id,external_userid,MAX(event_time) AS delete_time')
->group('user_id,external_userid')
->select()
->toArray();
foreach ($deletedEvents as $deletedEvent) {
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
. "\0"
. trim((string) ($deletedEvent['external_userid'] ?? ''));
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
}
}
$deleteTimesByPair = [];
if ($deletedPageRows !== []) {
$deletedUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$deletedPageRows
))));
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
$deletedPageRows
))));
$deletedEvents = Db::name('qywx_external_contact_event')
->where('change_type', 'del_external_contact')
->where('event_time', '<=', $endTimestamp)
->whereIn('user_id', $deletedUserIds)
->whereIn('external_userid', $deletedExternalUserIds)
->field('user_id,external_userid,MAX(event_time) AS delete_time')
->group('user_id,external_userid')
->select()
->toArray();
foreach ($deletedEvents as $deletedEvent) {
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
. "\0"
. trim((string) ($deletedEvent['external_userid'] ?? ''));
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
}
}
$staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$pageRows
)))));
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
$addTime = max(0, (int) ($row['add_time'] ?? 0));
$deleted = !empty($row['is_deleted']);
$deleteTime = $deleted
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
: 0;
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
$addTime = max(0, (int) ($row['add_time'] ?? 0));
$deleted = !empty($row['is_deleted']);
$deleteTime = $deleted
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
: 0;
if ($deleteTime < $addTime) {
$deleteTime = 0;
}
@@ -606,18 +611,18 @@ class ConversionLogic
];
}, $pageRows);
return [
'lists' => $lists,
'count' => $count,
'deleted_count' => $deletedCount,
return [
'lists' => $lists,
'count' => $count,
'deleted_count' => $deletedCount,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$startDate, $endDate],
];
}
/**
* Department scope imposed by an actively maintained media-channel cost
'date_range' => [$startDate, $endDate],
];
}
/**
* Department scope imposed by an actively maintained media-channel cost
* binding. null means the channel has no binding and therefore does not
* restrict business statistics.
*
@@ -1262,17 +1267,17 @@ class ConversionLogic
}
}
/**
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数)
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计)
/**
* 区间新增客户关系:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径:
* - (user_id, external_userid) 作为唯一组合,同一员工的同一客户只计一次
* - 同一客户添加到不同员工名下时,因 user_id 不同,每名员工分别计一次;
* - 同一组合在区间开始前已经产生过 add_external_contact 时,不再算区间新增
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除,仍计入加粉总数,
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 组合在统计结束时已删除,仍计入加粉总数,并额外计入已删提示子集;
* 不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
*
@@ -1285,25 +1290,21 @@ class ConversionLogic
int $endTimestamp,
?array $mediaChannel,
?array $adminIds = null
): array {
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
$effectivePairs = array_values(array_filter(
$detailRows,
static fn (array $row): bool => empty($row['is_deleted'])
));
return self::buildFanCountRows($detailRows, $effectivePairs);
}
/**
* Load the exact distinct (user_id, external_userid) pairs represented by
* add_fans_count. Both aggregate statistics and the detail endpoint consume
* this method, so channel, add-way, prior-add and deletion rules cannot
* drift apart.
): array {
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
return self::buildFanCountRows($detailRows);
}
/**
* Load the exact distinct (user_id, external_userid) pairs represented by
* add_fans_count. Both aggregate statistics and the detail endpoint consume
* this method, so channel, add-way, prior-add and deletion rules cannot
* drift apart.
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function loadFanDetailRows(
int $startTimestamp,
@@ -1335,7 +1336,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
$baseKey = self::requestRowsCacheKey('fans-detail-v3-employee-customer', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1379,23 +1380,56 @@ class ConversionLogic
}
}
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
. ' WHERE prev_e.user_id = e.user_id'
. ' AND prev_e.external_userid = e.external_userid'
. ' AND prev_e.change_type = ?'
. ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp]
)
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
->group('e.user_id, e.external_userid');
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$livePairPredicate =
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time <= ?'
. ' AND (surviving_del.event_time > surviving_e.event_time'
. ' OR (surviving_del.event_time = surviving_e.event_time'
. ' AND surviving_del.id > surviving_e.id))))';
$livePairBindings = [
'add_external_contact',
$startTimestamp,
$endTimestamp,
'del_external_contact',
$endTimestamp,
];
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
. ' WHERE prev_e.user_id = e.user_id'
. ' AND prev_e.external_userid = e.external_userid'
. ' AND prev_e.change_type = ?'
. ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp]
)
// 同一员工+客户只保留区间内最早事件;不同员工拥有不同 user_id,会分别保留。
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` earlier_e'
. ' WHERE earlier_e.user_id = e.user_id'
. ' AND earlier_e.external_userid = e.external_userid'
. ' AND earlier_e.change_type = ?'
. ' AND earlier_e.event_time >= ?'
. ' AND earlier_e.event_time <= ?'
. ' AND (earlier_e.event_time < e.event_time'
. ' OR (earlier_e.event_time = e.event_time AND earlier_e.id < e.id)))',
['add_external_contact', $startTimestamp, $endTimestamp]
)
->field('e.id AS add_event_id,e.user_id,e.external_userid,e.event_time AS add_time');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
} elseif ($unboundOnly) {
@@ -1406,58 +1440,49 @@ class ConversionLogic
. ' AND bound_a.delete_time IS NULL)'
);
}
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel);
}
$effectivePairs = $effectiveQuery
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time >= surviving_e.event_time'
. ' AND surviving_del.event_time <= ?))',
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
)
->select()
->toArray();
$deletedQuery = clone $query;
if ($mediaChannel !== null) {
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel);
}
$deletedPairs = $deletedQuery
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time >= surviving_e.event_time'
. ' AND surviving_del.event_time <= ?))',
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
)
->select()
->toArray();
// add_way 筛选对有效与已删粉丝使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserEventChannelFilter(
$effectiveQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel
);
}
$effectivePairs = $effectiveQuery
->whereRaw($livePairPredicate, $livePairBindings)
->select()
->toArray();
foreach ($effectivePairs as &$effectivePair) {
$effectivePair['is_deleted'] = false;
}
unset($effectivePair);
$deletedQuery = clone $query;
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserEventChannelFilter(
$deletedQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel,
true
);
}
$deletedPairs = $deletedQuery
->whereRaw('NOT (' . $livePairPredicate . ')', $livePairBindings)
->select()
->toArray();
foreach ($deletedPairs as &$deletedPair) {
$deletedPair['is_deleted'] = true;
}
unset($deletedPair);
// add_way 筛选对有效与已删组合使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanDetailRows($candidatePairs);
self::$requestRowsCache[$cacheKey] = $rows;
@@ -1465,97 +1490,69 @@ class ConversionLogic
}
/**
* @param array<int,array<string,mixed>> $candidatePairs
* @param array<int,array<string,mixed>> $effectivePairs
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function buildFanDetailRows(
array $candidatePairs,
array $effectivePairs
): array {
$effectiveKeys = [];
foreach ($effectivePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId !== '' && $externalUserId !== '') {
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
}
}
$detailsByKey = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
$addTime = max(0, (int) ($pair['add_time'] ?? 0));
if (isset($detailsByKey[$key])) {
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
$detailsByKey[$key]['add_time'] = $addTime;
}
continue;
}
$detailsByKey[$key] = [
'user_id' => $userId,
'external_userid' => $externalUserId,
'add_time' => $addTime,
'is_deleted' => !isset($effectiveKeys[$key]),
'delete_time' => 0,
];
}
return array_values($detailsByKey);
}
/**
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
*
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
* 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。
*
* @param array<int, array<string, mixed>> $candidatePairs
* @param array<int, array<string, mixed>> $effectivePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array
{
$effectiveKeys = [];
foreach ($effectivePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
}
$countsByUser = [];
$seenCandidateKeys = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($seenCandidateKeys[$key])) {
continue;
}
$seenCandidateKeys[$key] = true;
$countsByUser[$userId] ??= [
* @param array<int,array<string,mixed>> $candidatePairs
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function buildFanDetailRows(array $candidatePairs): array
{
$detailsByPair = [];
foreach ($candidatePairs as $pair) {
$eventId = max(0, (int) ($pair['add_event_id'] ?? 0));
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($eventId <= 0 || $userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($detailsByPair[$key])) {
continue;
}
$detailsByPair[$key] = [
'add_event_id' => $eventId,
'user_id' => $userId,
'external_userid' => $externalUserId,
'add_time' => max(0, (int) ($pair['add_time'] ?? 0)),
'is_deleted' => !empty($pair['is_deleted']),
'delete_time' => 0,
];
}
return array_values($detailsByPair);
}
/**
* 按员工聚合全部新增员工+客户组合,并标记其中期末已删除的子集。
*
* 每个不同 (user_id, external_userid) 都计入 add_fans_countis_deleted=true 的组合同时计入
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
*
* @param array<int, array<string, mixed>> $candidatePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function buildFanCountRows(array $candidatePairs): array
{
$countsByUser = [];
$seenPairs = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($seenPairs[$key])) {
continue;
}
$seenPairs[$key] = true;
$countsByUser[$userId] ??= [
'user_id' => $userId,
'add_fans_count' => 0,
'deleted_fans_count' => 0,
];
++$countsByUser[$userId]['add_fans_count'];
if (!isset($effectiveKeys[$key])) {
++$countsByUser[$userId]['deleted_fans_count'];
}
];
++$countsByUser[$userId]['add_fans_count'];
if (!empty($pair['is_deleted'])) {
++$countsByUser[$userId]['deleted_fans_count'];
}
}
return array_values($countsByUser);
@@ -1569,25 +1566,25 @@ class ConversionLogic
* - add_way=201/202 继承/分配(内部成员共享、管理员/负责人分配,含在职/离职继承)。
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
*
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeUncountedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeUncountedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($extId !== '') {
$externalUserIds[$extId] = true;
}
}
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
}
/** @var array<string, true> $excludedKeys user_id\0external_userid */
@@ -1628,21 +1625,21 @@ class ConversionLogic
}
}
if ($excludedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($excludedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $extId === '') {
continue;
}
if (isset($excludedKeys[$userId . "\0" . $extId])) {
continue;
}
$kept[] = $pair;
$kept[] = $pair;
}
return $kept;