>|null */ private static ?array $activeChannelRowsCache = null; private static float $activeChannelRowsCachedAt = 0.0; /** @var array|null */ private static ?array $currentTagCatalogCache = null; private static float $currentTagCatalogCachedAt = 0.0; /** @var array>|null */ private static ?array $currentTagChannelRowsCache = null; private static float $currentTagChannelRowsCachedAt = 0.0; /** * 与业绩看板「渠道来源」相同的分组(按 source_group_name),不含客户数统计。 * * @return array * }> */ public static function getOptionGroups(): array { $rows = self::getActiveChannelRows(); if ($rows === []) { return []; } $groups = []; foreach ($rows as $r) { $g = (string) (($r['source_group_name'] ?? '') !== '' ? $r['source_group_name'] : '其它'); $groups[$g] ??= ['group_name' => $g, 'channels' => []]; $groups[$g]['channels'][] = [ 'channel_code' => (string) ($r['channel_code'] ?? ''), 'channel_name' => (string) ($r['channel_name'] ?? ''), ]; } $sortedGroups = array_values($groups); usort($sortedGroups, static function (array $a, array $b): int { $aMedia = mb_strpos($a['group_name'], '自媒体') !== false ? 0 : 1; $bMedia = mb_strpos($b['group_name'], '自媒体') !== false ? 0 : 1; if ($aMedia !== $bMedia) { return $aMedia <=> $bMedia; } return strcmp($a['group_name'], $b['group_name']); }); return $sortedGroups; } /** * @return array */ public static function getOptions(): array { $rows = self::getActiveChannelRows(); usort($rows, static function (array $a, array $b): int { $nameCompare = strnatcasecmp( (string) ($a['channel_name'] ?? ''), (string) ($b['channel_name'] ?? '') ); return $nameCompare !== 0 ? $nameCompare : strcmp((string) ($a['channel_code'] ?? ''), (string) ($b['channel_code'] ?? '')); }); return array_map(static fn (array $row): array => [ 'code' => (string) ($row['channel_code'] ?? ''), 'name' => (string) ($row['channel_name'] ?? ''), ], $rows); } /** * 企微客户页与一诊渠道共用的当前标签目录。 * * 只统计仍关联未删除客户的标签;同一个 tag_id 只保留更新时间最新、 * 同时间 id 最大的一份名称和分组,避免标签改名后同时展示新旧快照。 * * @return array */ public static function getCurrentTagCatalog(): array { $now = microtime(true); if (self::$currentTagCatalogCache !== null && ($now - self::$currentTagCatalogCachedAt) < self::CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS) { return self::$currentTagCatalogCache; } try { $cachedCatalog = Cache::get(self::CURRENT_TAG_CATALOG_CACHE_KEY); } catch (\Throwable) { // 缓存目录/服务不可用时直接查库,缓存不能阻断业务接口。 $cachedCatalog = null; } if (is_array($cachedCatalog)) { self::$currentTagCatalogCache = $cachedCatalog; self::$currentTagCatalogCachedAt = microtime(true); return self::$currentTagCatalogCache; } $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $contactTable = self::tableWithPrefix('qywx_external_contact'); $sql = << '' GROUP BY tagged.tag_id ) current_tag ON current_tag.tag_id = latest_tag.tag_id AND current_tag.latest_sort_key = CONCAT( LPAD(latest_tag.update_time, 10, '0'), LPAD(latest_tag.id, 10, '0') ) ORDER BY current_tag.customer_count DESC, latest_tag.tag_id ASC SQL; self::$currentTagCatalogCache = array_map(static fn (array $row): array => [ 'tag_id' => trim((string) ($row['tag_id'] ?? '')), 'tag_name' => trim((string) ($row['tag_name'] ?? '')), 'group_name' => trim((string) ($row['group_name'] ?? '')), 'customer_count' => (int) ($row['customer_count'] ?? 0), ], Db::query($sql)); self::$currentTagCatalogCachedAt = microtime(true); try { Cache::set( self::CURRENT_TAG_CATALOG_CACHE_KEY, self::$currentTagCatalogCache, self::CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS ); } catch (\Throwable) { // 同上:共享缓存仅用于加速,当前请求的内存缓存仍然有效。 } return self::$currentTagCatalogCache; } public static function forgetCurrentTagCatalogCache(): void { self::$currentTagCatalogCache = null; self::$currentTagCatalogCachedAt = 0.0; self::$currentTagChannelRowsCache = null; self::$currentTagChannelRowsCachedAt = 0.0; try { Cache::delete(self::CURRENT_TAG_CATALOG_CACHE_KEY); } catch (\Throwable) { // 缓存不可用不影响标签同步和后续数据库读取。 } } /** * 一诊专用渠道选项:严格投影当前企微标签,不混入历史 name-only 渠道。 * * @return array */ public static function getCurrentTagOptions(): array { return array_map(static fn (array $row): array => [ 'code' => (string) ($row['channel_code'] ?? ''), 'name' => (string) ($row['channel_name'] ?? ''), 'tag_id' => (string) ($row['source_tag_id'] ?? ''), 'group_name' => (string) ($row['source_group_name'] ?? ''), 'customer_count' => (int) ($row['customer_count'] ?? 0), ], self::getCurrentTagChannelRows()); } /** * 一诊专用解析器。当前标签即使在历史渠道注册表中被停用,也仍按企微当前标签生效; * 全局财务渠道的启停语义继续由 getChannelByCode() 维护。 * * @return array|null */ public static function getCurrentTagChannelByCode(string $channelCode): ?array { $channelCode = trim($channelCode); if ($channelCode === '') { return null; } foreach (self::getCurrentTagChannelRows() as $row) { if ((string) ($row['channel_code'] ?? '') === $channelCode) { return $row; } } return null; } public static function getDefaultCode(): string { $rows = self::getActiveChannelRows(); return (string) ($rows[0]['channel_code'] ?? ''); } public static function isValidCode(string $channelCode): bool { $channelCode = trim($channelCode); if ($channelCode === '') { return false; } return self::getChannelByCode($channelCode) !== null; } public static function normalizeStatsCode(string $channelCode): string { return self::isValidCode($channelCode) ? trim($channelCode) : ''; } /** * @return array|null */ public static function getChannelByCode(string $channelCode): ?array { $channelCode = trim($channelCode); if ($channelCode === '') { return null; } $model = QywxMediaChannel::where('channel_code', $channelCode)->find(); if ($model !== null) { $row = $model->toArray(); if ((int) ($row['status'] ?? 0) !== 1) { return null; } $tagId = trim((string) ($row['source_tag_id'] ?? '')); if ($tagId === '') { return $row; } $merged = self::mergeConfiguredChannelsWithTags([$row], self::loadCurrentTagRows([$tagId])); return $merged[0] ?? $row; } foreach (self::getActiveChannelRows() as $row) { if ((string) ($row['channel_code'] ?? '') === $channelCode) { return $row; } } return null; } public static function getNameByCode(string $channelCode): string { $channel = self::getChannelByCode($channelCode); return (string) ($channel['channel_name'] ?? ''); } /** * 挂号老数据渠道:doctor_appointment.channels 对应 dict_data(type_value=channels) 的 value。 * * @param array|null $channel * @return int[] */ public static function getLegacyAppointmentChannelValues(?array $channel): array { if ($channel === null) { return []; } $names = array_values(array_unique(array_filter([ trim((string) ($channel['channel_name'] ?? '')), trim((string) ($channel['source_tag_name'] ?? '')), trim((string) ($channel['legacy_channel_name'] ?? '')), trim((string) ($channel['legacy_source_tag_name'] ?? '')), ]))); if ($names === []) { return []; } $rows = Db::name('dict_data') ->where('type_value', 'channels') ->whereIn('name', $names) ->column('value'); return array_values(array_filter(array_map('intval', $rows), static fn (int $value): bool => $value > 0)); } /** * @param array|null $channel */ public static function applyFollowUsersChannelFilter(Query $query, string $field, ?array $channel): void { if ($channel === null) { return; } $patterns = self::buildLikePatterns($channel); if ($patterns === []) { $query->whereRaw('1 = 0'); return; } $segments = []; $bindings = []; foreach ($patterns as $pattern) { $segments[] = $field . ' LIKE ?'; $bindings[] = $pattern; } $query->whereRaw('(' . implode(' OR ', $segments) . ')', $bindings); } /** * Filter a fact table by its external_userid without joining the denormalized * contact rows. The contact table may contain several rows for one customer; * a normal JOIN therefore both scans follow_users TEXT repeatedly and * multiplies facts. Enterprise tag channels use the normalized relation * table, while legacy name-only channels keep a deduplicated JSON fallback. * * @param array|null $channel */ public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void { if ($channel === null) { return; } $tagId = trim((string) ($channel['source_tag_id'] ?? '')); if ($tagId !== '') { $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $contactTable = self::tableWithPrefix('qywx_external_contact'); $query->whereRaw( "{$field} IN (" . "SELECT channel_tag.external_userid FROM {$tagTable} channel_tag " . 'WHERE channel_tag.tag_id = ? ' . "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact " . 'WHERE active_channel_contact.external_userid = channel_tag.external_userid ' . 'AND active_channel_contact.delete_time IS NULL))', [$tagId] ); return; } $patterns = self::buildLikePatterns($channel); if ($patterns === []) { $query->whereRaw('1 = 0'); return; } $segments = []; $bindings = []; foreach ($patterns as $pattern) { $segments[] = 'channel_contact.follow_users LIKE ?'; $bindings[] = $pattern; } $contactTable = self::tableWithPrefix('qywx_external_contact'); $query->whereRaw( "{$field} IN (SELECT channel_contact.external_userid FROM {$contactTable} channel_contact" . ' WHERE channel_contact.delete_time IS NULL AND (' . implode(' OR ', $segments) . '))', $bindings ); } /** * @return array{scanned_contacts: int, discovered_tags: int, inserted_or_updated: int} */ public static function scanFromContacts(int $batchSize = 200): array { $lastId = 0; $scannedContacts = 0; $discoveredTags = []; $upserted = 0; $now = time(); while (true) { $rows = QywxExternalContact::where('id', '>', $lastId) ->whereNull('delete_time') ->field('id, follow_users') ->order('id asc') ->limit($batchSize) ->select() ->toArray(); if ($rows === []) { break; } foreach ($rows as $row) { $lastId = (int) ($row['id'] ?? 0); if ($lastId <= 0) { continue; } $scannedContacts++; $followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true); $followUsers = is_array($followUsers) ? $followUsers : []; foreach (self::extractTagsFromFollowUsers($followUsers) as $tag) { $tagKey = self::buildTagUniqKey($tag['source_tag_id'], $tag['source_tag_name']); if ($tagKey === '' || isset($discoveredTags[$tagKey])) { continue; } $discoveredTags[$tagKey] = true; $channelCode = self::buildChannelCode($tag['source_tag_id'], $tag['source_tag_name']); $channelName = $tag['source_tag_name'] !== '' ? $tag['source_tag_name'] : $tag['source_tag_id']; $rowData = [ 'channel_code' => $channelCode, 'channel_name' => $channelName, 'source_tag_id' => $tag['source_tag_id'], 'source_tag_name' => $tag['source_tag_name'], 'source_group_name' => $tag['source_group_name'], 'tag_uniq_key' => $tagKey, 'status' => 1, 'last_seen_time' => $now, 'create_time' => $now, 'update_time' => $now, ]; Db::name('qywx_media_channel') ->duplicate(self::SCAN_DUPLICATE_UPDATE_FIELDS) ->insert($rowData); $upserted++; } } } self::$activeChannelRowsCache = null; self::$activeChannelRowsCachedAt = 0.0; self::$currentTagChannelRowsCache = null; self::$currentTagChannelRowsCachedAt = 0.0; return [ 'scanned_contacts' => $scannedContacts, 'discovered_tags' => count($discoveredTags), 'inserted_or_updated' => $upserted, ]; } /** * @return array> */ private static function getCurrentTagChannelRows(): array { $now = microtime(true); if (self::$currentTagChannelRowsCache !== null && ($now - self::$currentTagChannelRowsCachedAt) < self::CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS) { return self::$currentTagChannelRowsCache; } $catalog = self::getCurrentTagCatalog(); $tagIds = array_values(array_filter(array_map( static fn (array $tag): string => trim((string) ($tag['tag_id'] ?? '')), $catalog ), static fn (string $tagId): bool => $tagId !== '')); $configuredRows = []; if ($tagIds !== []) { $configuredRows = QywxMediaChannel::whereIn('source_tag_id', $tagIds) ->field( 'id, channel_code, channel_name, source_tag_id, source_tag_name, source_group_name, ' . 'tag_uniq_key, status, last_seen_time, create_time, update_time' ) ->order('id asc') ->select() ->toArray(); } self::$currentTagChannelRowsCache = self::mergeCurrentTagsWithConfiguredChannels($catalog, $configuredRows); self::$currentTagChannelRowsCachedAt = microtime(true); return self::$currentTagChannelRowsCache; } /** * 当前企微标签决定展示名称和可见集合;注册表只提供稳定 code 及历史名称兼容。 * 历史 name-only 行不会进入结果,注册表 status 也不会隐藏仍在使用的企微标签。 * * @param array> $catalog * @param array> $configuredRows * @return array> */ private static function mergeCurrentTagsWithConfiguredChannels(array $catalog, array $configuredRows): array { $configuredByTagId = []; foreach ($configuredRows as $configuredRow) { $tagId = trim((string) ($configuredRow['source_tag_id'] ?? '')); if ($tagId !== '' && !isset($configuredByTagId[$tagId])) { $configuredByTagId[$tagId] = $configuredRow; } } $rows = []; foreach ($catalog as $tag) { $tagId = trim((string) ($tag['tag_id'] ?? $tag['source_tag_id'] ?? '')); if ($tagId === '') { continue; } $tagName = trim((string) ($tag['tag_name'] ?? $tag['source_tag_name'] ?? '')); $groupName = trim((string) ($tag['group_name'] ?? $tag['source_group_name'] ?? '')); $configured = $configuredByTagId[$tagId] ?? []; $channelCode = trim((string) ($configured['channel_code'] ?? '')); if ($channelCode === '') { $channelCode = self::buildChannelCode($tagId, $tagName); } if ($channelCode === '') { continue; } $row = $configured; $oldChannelName = trim((string) ($configured['channel_name'] ?? '')); $oldTagName = trim((string) ($configured['source_tag_name'] ?? '')); if ($oldChannelName !== '' && $oldChannelName !== $tagName) { $row['legacy_channel_name'] = $oldChannelName; } if ($oldTagName !== '' && $oldTagName !== $tagName) { $row['legacy_source_tag_name'] = $oldTagName; } $row['id'] = (int) ($configured['id'] ?? 0); $row['channel_code'] = $channelCode; $row['channel_name'] = $tagName !== '' ? $tagName : $tagId; $row['source_tag_id'] = $tagId; $row['source_tag_name'] = $tagName; $row['source_group_name'] = $groupName; $row['tag_uniq_key'] = self::buildTagUniqKey($tagId, $tagName); $row['status'] = 1; $row['customer_count'] = (int) ($tag['customer_count'] ?? 0); $row['last_seen_time'] = (int) ($configured['last_seen_time'] ?? 0); $row['create_time'] = (int) ($configured['create_time'] ?? 0); $row['update_time'] = (int) ($configured['update_time'] ?? 0); $rows[] = $row; } return $rows; } /** * Combine the persistent channel registry with the latest tag snapshots in * the normalized relation table. The registry keeps stable channel codes * and historical name-only channels; relation rows supply newly discovered * tags and names for tags that were renamed in WeCom. * * @return array> */ private static function getActiveChannelRows(): array { $now = microtime(true); if (self::$activeChannelRowsCache !== null && ($now - self::$activeChannelRowsCachedAt) < self::ACTIVE_ROWS_CACHE_TTL_SECONDS) { return self::$activeChannelRowsCache; } $configuredRows = QywxMediaChannel::field( 'id, channel_code, channel_name, source_tag_id, source_tag_name, source_group_name, ' . 'tag_uniq_key, status, last_seen_time, create_time, update_time' ) ->order('id asc') ->select() ->toArray(); $rows = self::mergeConfiguredChannelsWithTags($configuredRows, self::loadCurrentTagRows()); self::$activeChannelRowsCache = $rows; self::$activeChannelRowsCachedAt = microtime(true); return $rows; } /** * @param string[]|null $tagIds null loads all current tags * @return array */ private static function loadCurrentTagRows(?array $tagIds = null): array { $bindings = []; $tagFilter = ''; if ($tagIds !== null) { $tagIds = array_values(array_unique(array_filter(array_map( static fn ($tagId): string => trim((string) $tagId), $tagIds ), static fn (string $tagId): bool => $tagId !== ''))); if ($tagIds === []) { return []; } $tagFilter = ' AND newest_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')'; $bindings = $tagIds; } $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $sql = << '' {$tagFilter} GROUP BY newest_tag.tag_id ) latest_time INNER JOIN {$tagTable} tag_at_time ON tag_at_time.tag_id = latest_time.tag_id AND tag_at_time.update_time = latest_time.latest_update_time GROUP BY latest_time.tag_id ) selected_tag ON selected_tag.latest_id = latest_tag.id ORDER BY latest_tag.tag_id ASC SQL; return array_map(static fn (array $row): array => [ 'source_tag_id' => trim((string) ($row['source_tag_id'] ?? '')), 'source_tag_name' => trim((string) ($row['source_tag_name'] ?? '')), 'source_group_name' => trim((string) ($row['source_group_name'] ?? '')), ], Db::query($sql, $bindings)); } /** * Disabled configured tags stay disabled. Active configured rows keep their * stable codes, while automatic display names follow the newest tag name. * Tags not yet present in the registry receive the same deterministic code * that the scanner would create. * * @param array> $configuredRows * @param array> $tagRows * @return array> */ private static function mergeConfiguredChannelsWithTags(array $configuredRows, array $tagRows): array { $tagMap = []; foreach ($tagRows as $tagRow) { $tagId = trim((string) ($tagRow['source_tag_id'] ?? $tagRow['tag_id'] ?? '')); if ($tagId === '') { continue; } $tagMap[$tagId] = [ 'source_tag_id' => $tagId, 'source_tag_name' => trim((string) ($tagRow['source_tag_name'] ?? $tagRow['tag_name'] ?? '')), 'source_group_name' => trim((string) ($tagRow['source_group_name'] ?? $tagRow['group_name'] ?? '')), ]; } $result = []; $configuredTagIds = []; $configuredCodes = []; foreach ($configuredRows as $configuredRow) { $channelCode = trim((string) ($configuredRow['channel_code'] ?? '')); $tagId = trim((string) ($configuredRow['source_tag_id'] ?? '')); if ($channelCode !== '') { $configuredCodes[$channelCode] = true; } if ($tagId !== '') { // A disabled registry row is an explicit opt-out and must not be // reintroduced as a dynamically discovered channel. $configuredTagIds[$tagId] = true; } if ($channelCode === '' || (int) ($configuredRow['status'] ?? 0) !== 1) { continue; } $row = $configuredRow; $currentTag = $tagId !== '' ? ($tagMap[$tagId] ?? null) : null; if ($currentTag !== null) { $oldTagName = trim((string) ($row['source_tag_name'] ?? '')); $oldChannelName = trim((string) ($row['channel_name'] ?? '')); $currentTagName = (string) $currentTag['source_tag_name']; if ($currentTagName !== '') { if ($oldTagName !== '' && $oldTagName !== $currentTagName) { $row['legacy_source_tag_name'] = $oldTagName; } $isAutomaticName = $oldChannelName === '' || $oldChannelName === $oldTagName || $oldChannelName === $tagId; if ($isAutomaticName) { if ($oldChannelName !== '' && $oldChannelName !== $currentTagName) { $row['legacy_channel_name'] = $oldChannelName; } $row['channel_name'] = $currentTagName; } $row['source_tag_name'] = $currentTagName; } $row['source_group_name'] = (string) $currentTag['source_group_name']; } $result[] = $row; } foreach ($tagMap as $tagId => $tagRow) { if (isset($configuredTagIds[$tagId])) { continue; } $channelName = (string) ($tagRow['source_tag_name'] ?? ''); $channelCode = self::buildChannelCode($tagId, $channelName); if ($channelCode === '' || isset($configuredCodes[$channelCode])) { continue; } $configuredCodes[$channelCode] = true; $result[] = [ 'id' => 0, 'channel_code' => $channelCode, 'channel_name' => $channelName !== '' ? $channelName : $tagId, 'source_tag_id' => $tagId, 'source_tag_name' => $channelName, 'source_group_name' => (string) ($tagRow['source_group_name'] ?? ''), 'tag_uniq_key' => self::buildTagUniqKey($tagId, $channelName), 'status' => 1, 'last_seen_time' => 0, 'create_time' => 0, 'update_time' => 0, ]; } return $result; } /** * @param array $channel * @return string[] */ private static function buildLikePatterns(array $channel): array { $patterns = []; $tagId = trim((string) ($channel['source_tag_id'] ?? '')); $tagName = trim((string) ($channel['source_tag_name'] ?? '')); if ($tagId !== '') { $escapedTagId = addcslashes($tagId, '%_\\'); $patterns[] = '%"tag_id":"' . $escapedTagId . '"%'; $patterns[] = '%"id":"' . $escapedTagId . '"%'; } if ($tagName !== '') { $escapedTagName = addcslashes($tagName, '%_\\'); $patterns[] = '%"name":"' . $escapedTagName . '"%'; $patterns[] = '%"tag_name":"' . $escapedTagName . '"%'; } return array_values(array_unique($patterns)); } private static function tableWithPrefix(string $table): string { $prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_'); return $prefix . $table; } /** * @param array $followUsers * @return array */ private static function extractTagsFromFollowUsers(array $followUsers): array { $tags = []; foreach ($followUsers as $followUser) { if (!is_array($followUser)) { continue; } $rawTags = $followUser['tags'] ?? []; if (!is_array($rawTags)) { continue; } foreach ($rawTags as $tag) { if (!is_array($tag)) { continue; } $tagId = trim((string) ($tag['tag_id'] ?? $tag['id'] ?? '')); $tagName = trim((string) ($tag['name'] ?? $tag['tag_name'] ?? '')); $groupName = trim((string) ($tag['group_name'] ?? '')); $uniqKey = self::buildTagUniqKey($tagId, $tagName); if ($uniqKey === '') { continue; } $tags[$uniqKey] = [ 'source_tag_id' => $tagId, 'source_tag_name' => $tagName, 'source_group_name' => $groupName, ]; } } return array_values($tags); } private static function buildTagUniqKey(string $tagId, string $tagName): string { $tagId = trim($tagId); $tagName = trim($tagName); if ($tagId !== '') { return 'tag_id:' . $tagId; } if ($tagName !== '') { return 'tag_name:' . md5(mb_strtolower($tagName, 'UTF-8')); } return ''; } private static function buildChannelCode(string $tagId, string $tagName): string { $tagId = trim($tagId); if ($tagId !== '') { return 'tag_' . preg_replace('/[^A-Za-z0-9_\-]/', '_', $tagId); } $normalizedName = trim(mb_strtolower($tagName, 'UTF-8')); return 'tagname_' . substr(md5($normalizedName), 0, 16); } }