This commit is contained in:
Your Name
2026-08-25 11:40:58 +08:00
parent b8ccbaf567
commit f24afa116f
350 changed files with 1910 additions and 371 deletions
@@ -340,6 +340,304 @@ class ConversionLogic
return $result;
}
/**
* Return the distinct external contacts 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
* detail endpoint on the same data-scope and department-primary-mapping
* rules as overview(), including descendant departments and virtual
* buckets.
*
* @param array<string,mixed> $params
* @param array<string,mixed> $target
* @param int[]|null $trustedVisibleAdminIdsOverride
* @param array<string,mixed>|null $trustedMediaChannelOverride
* @return array<string,mixed>
*/
public static function fanDetails(
array $params,
array $target,
int $adminId = 0,
?array $adminInfo = null,
?array $trustedVisibleAdminIdsOverride = null,
?array $trustedMediaChannelOverride = null
): array {
self::$requestRowsCache = [];
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
$visibleAdminIds = $trustedVisibleAdminIdsOverride;
if ($trustedVisibleAdminIdsOverride === null && $adminInfo !== null && $adminId > 0) {
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
if ($visibleAdminIds !== null) {
$visibleAdminIds = array_values(array_unique(array_filter(
array_map('intval', $visibleAdminIds),
static fn (int $id): bool => $id > 0
)));
}
$empty = [
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$startDate, $endDate],
];
if ($visibleAdminIds === []) {
return $empty;
}
$mediaChannel = $trustedMediaChannelOverride;
$requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
}
$targetType = (string) ($target['type'] ?? '');
$targetAdminId = max(0, (int) ($target['admin_id'] ?? 0));
$targetWecomUserId = trim((string) ($target['wecom_userid'] ?? ''));
$targetDeptIds = array_values(array_unique(array_map('intval', (array) ($target['dept_ids'] ?? []))));
$targetDeptSet = array_fill_keys($targetDeptIds, true);
if (($targetType === 'member' && $targetAdminId <= 0)
|| ($targetType === 'wecom_user' && $targetWecomUserId === '')
|| ($targetType === 'dept' && $targetDeptSet === [])
) {
return $empty;
}
$allEntities = [];
$entityIds = [];
$adminToDeptIds = [];
$targetWorkWechatUserIds = [];
$unboundOnly = false;
if ($targetType === 'dept') {
$allEntities = self::loadEntities('dept', $params, $visibleAdminIds);
$adminToDeptIds = self::loadAdminDeptMap();
$channelDeptIds = self::fanDetailChannelDeptIds($mediaChannel);
if ($channelDeptIds !== null) {
$allEntities = self::filterEntitiesByChannelDeptScope(
$allEntities,
'dept',
$channelDeptIds,
$adminToDeptIds
);
}
$entityIds = array_keys($allEntities);
foreach ($targetDeptIds as $targetDeptId) {
if (!isset($allEntities[$targetDeptId])) {
return $empty;
}
}
if (isset($targetDeptSet[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
// The unbound bucket exists only under SCOPE_ALL. Keep the
// exclusion in SQL so this bucket does not first load every
// fan pair in the installation.
if ($visibleAdminIds !== null || count($targetDeptSet) !== 1) {
return $empty;
}
$unboundOnly = true;
} else {
$candidateAdminQuery = Db::name('admin')
->whereNull('delete_time')
->where('work_wechat_userid', '<>', '')
->field('id,work_wechat_userid');
if ($visibleAdminIds !== null) {
$candidateAdminQuery->whereIn('id', $visibleAdminIds);
}
foreach ($candidateAdminQuery->select()->toArray() as $candidateAdmin) {
$candidateAdminId = (int) ($candidateAdmin['id'] ?? 0);
$mappedIds = self::mapEntityIds(
'dept',
$candidateAdminId,
$entityIds,
$adminToDeptIds,
$visibleAdminIds
);
$mappedDeptId = $mappedIds !== []
? (int) $mappedIds[0]
: self::VIRTUAL_DEPT_UNASSIGNED_ID;
if (isset($targetDeptSet[$mappedDeptId])) {
$workWechatUserId = trim((string) ($candidateAdmin['work_wechat_userid'] ?? ''));
if ($workWechatUserId !== '') {
$targetWorkWechatUserIds[$workWechatUserId] = $workWechatUserId;
}
}
}
}
} elseif ($targetType === 'member') {
if ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true)) {
return $empty;
}
$targetAdmin = Db::name('admin')
->where('id', $targetAdminId)
->whereNull('delete_time')
->where('work_wechat_userid', '<>', '')
->field('id,work_wechat_userid')
->find();
if (!$targetAdmin) {
return $empty;
}
$targetWorkWechatUserIds[] = trim((string) $targetAdmin['work_wechat_userid']);
} else {
// Unbound WeCom rows are never visible in a restricted scope.
if ($visibleAdminIds !== null) {
return $empty;
}
$boundAdminExists = Db::name('admin')
->whereNull('delete_time')
->where('work_wechat_userid', $targetWecomUserId)
->count() > 0;
if ($boundAdminExists) {
return $empty;
}
$targetWorkWechatUserIds[] = $targetWecomUserId;
}
$targetWorkWechatUserIds = array_values(array_unique(array_filter(
array_map('strval', $targetWorkWechatUserIds),
static fn (string $userId): bool => $userId !== ''
)));
if (!$unboundOnly && $targetWorkWechatUserIds === []) {
return $empty;
}
$pairs = self::loadFanDetailRows(
$startTimestamp,
$endTimestamp,
$mediaChannel,
null,
$unboundOnly ? null : $targetWorkWechatUserIds,
$unboundOnly
);
// Target membership has already been resolved before the event query,
// so sorting and pagination operate on the clicked row's small set.
$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(
(string) ($left['external_userid'] ?? ''),
(string) ($right['external_userid'] ?? '')
) ?: strcmp(
(string) ($left['user_id'] ?? ''),
(string) ($right['user_id'] ?? '')
);
});
$count = count($matched);
$deletedCount = count(array_filter(
$matched,
static fn (array $row): bool => !empty($row['is_deleted'])
));
$offset = ($pageNo - 1) * $pageSize;
$pageRows = array_slice($matched, $offset, $pageSize);
$externalUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
$pageRows
))));
$customerNames = $externalUserIds === []
? []
: Db::name('qywx_external_contact')
->whereIn('external_userid', $externalUserIds)
->column('name', 'external_userid');
$deletedPageRows = array_values(array_filter(
$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));
}
}
$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;
if ($deleteTime < $addTime) {
$deleteTime = 0;
}
return [
'external_userid' => $externalUserId,
'customer_name' => trim((string) ($customerNames[$externalUserId] ?? '')),
'wecom_userid' => $wecomUserId,
'wecom_staff_name' => trim((string) ($staffNames[$wecomUserId] ?? '')),
'add_time' => $addTime > 0 ? date('Y-m-d H:i:s', $addTime) : null,
'is_deleted' => $deleted,
'delete_time' => $deleted && $deleteTime > 0 ? date('Y-m-d H:i:s', $deleteTime) : null,
];
}, $pageRows);
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
* binding. null means the channel has no binding and therefore does not
* restrict business statistics.
*
* @param array<string,mixed>|null $mediaChannel
* @return int[]|null
*/
public static function fanDetailChannelDeptIds(?array $mediaChannel): ?array
{
if ($mediaChannel === null) {
return null;
}
$mediaChannelCodes = MediaChannelService::getChannelCodesForStats($mediaChannel);
if ($mediaChannelCodes === []) {
return null;
}
$boundDeptIds = self::loadChannelBoundDeptIds($mediaChannelCodes);
return $boundDeptIds === [] ? null : self::expandDeptIdsWithDescendants($boundDeptIds);
}
/**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL
* @param int[] $eligibleDeptIds 当媒体渠道筛选生效时收窄过的部门作用域
@@ -987,6 +1285,33 @@ 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.
*
* @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}>
*/
private static function loadFanDetailRows(
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $adminIds = null,
?array $exactWorkWechatUserIds = null,
bool $unboundOnly = false
): array {
if ($adminIds !== null) {
$adminIds = array_values(array_unique(array_filter(
@@ -999,21 +1324,38 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-effective-v9', [
if ($exactWorkWechatUserIds !== null) {
$exactWorkWechatUserIds = array_values(array_unique(array_filter(
array_map('strval', $exactWorkWechatUserIds),
static fn (string $userId): bool => $userId !== ''
)));
sort($exactWorkWechatUserIds);
if ($exactWorkWechatUserIds === [] && !$unboundOnly) {
return [];
}
}
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
]);
$allKey = $baseKey . ':all';
$cacheKey = $adminIds === null
? $allKey
: $baseKey . ':admins:' . implode(',', $adminIds);
if ($unboundOnly) {
$cacheKey = $baseKey . ':unbound';
} elseif ($exactWorkWechatUserIds !== null) {
$cacheKey = $baseKey . ':wecom:' . implode(',', $exactWorkWechatUserIds);
} else {
$cacheKey = $adminIds === null
? $allKey
: $baseKey . ':admins:' . implode(',', $adminIds);
}
if (isset(self::$requestRowsCache[$cacheKey])) {
return self::$requestRowsCache[$cacheKey];
}
$workWechatUserIds = null;
if ($adminIds !== null) {
$workWechatUserIds = $exactWorkWechatUserIds;
if ($workWechatUserIds === null && $adminIds !== null) {
$workWechatUserIds = Db::name('admin')
->whereIn('id', $adminIds)
->whereNull('delete_time')
@@ -1052,10 +1394,17 @@ class ConversionLogic
. ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp]
)
->field(['e.user_id', 'e.external_userid'])
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
->group('e.user_id, e.external_userid');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
} elseif ($unboundOnly) {
$adminTable = config('database.connections.mysql.prefix') . 'admin';
$query->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $adminTable . '` bound_a'
. ' WHERE bound_a.work_wechat_userid = e.user_id'
. ' AND bound_a.delete_time IS NULL)'
);
}
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
@@ -1064,13 +1413,19 @@ class ConversionLogic
}
$effectivePairs = $effectiveQuery
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
. ' WHERE del_e.user_id = e.user_id'
. ' AND del_e.external_userid = e.external_userid'
. ' AND del_e.change_type = ?'
. ' AND del_e.event_time >= e.event_time'
. ' AND del_e.event_time <= ?)',
['del_external_contact', $endTimestamp]
'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();
@@ -1102,13 +1457,59 @@ class ConversionLogic
// add_way 筛选对有效与已删粉丝使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanCountRows($candidatePairs, $effectivePairs);
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
self::$requestRowsCache[$cacheKey] = $rows;
return self::$requestRowsCache[$cacheKey];
}
/**
* @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);
}
/**
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
*