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
@@ -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;