diff --git a/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php b/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php index 555a47691..6f2a3180e 100644 --- a/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php +++ b/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php @@ -512,6 +512,8 @@ class FirstVisitConversionLogic $channelCode, $channel['channel_name'] ?? '', $channel['source_tag_name'] ?? '', + $channel['legacy_channel_name'] ?? '', + $channel['legacy_source_tag_name'] ?? '', ] ), static fn (string $value): bool => $value !== ''))); } diff --git a/server/app/adminapi/logic/stats/ConversionLogic.php b/server/app/adminapi/logic/stats/ConversionLogic.php index 1dd37560b..cd06b571a 100755 --- a/server/app/adminapi/logic/stats/ConversionLogic.php +++ b/server/app/adminapi/logic/stats/ConversionLogic.php @@ -353,7 +353,7 @@ class ConversionLogic 'departments' => self::buildDepartmentOptions($visibleAdminIds, $eligibleDeptIds, $allowedDeptIds), 'assistants' => DiagnosisLogic::getAssistants($adminId, $adminInfo), 'doctors' => self::buildDoctorOptions($visibleAdminIds), - 'media_channels' => self::buildMediaChannelOptions($visibleAdminIds, $eligibleDeptIds, $allowedDeptIds), + 'media_channels' => self::buildMediaChannelOptions(), ]; } @@ -411,40 +411,16 @@ class ConversionLogic } /** - * 媒体渠道下拉:按可见部门绑定的渠道收窄。 + * 媒体渠道下拉:返回全局启用渠道;事实数据仍由 DataScope 单独约束。 * * @return array> */ - private static function buildMediaChannelOptions(?array $visibleAdminIds, array $eligibleDeptIds, ?array $allowedDeptIds = null): array + private static function buildMediaChannelOptions(): array { - $allOptions = MediaChannelService::getOptions(); - if ($visibleAdminIds === null) { - return $allOptions; - } - if (!AccountCost::supportsDeptBinding()) { - return $allOptions; - } - - $allowedDeptIds ??= self::resolveFilterAllowedDeptIds($visibleAdminIds, $eligibleDeptIds); - if ($allowedDeptIds === []) { - return []; - } - - $allowedCodes = Db::name('account_cost') - ->whereIn('dept_id', $allowedDeptIds) - ->where('media_channel_code', '<>', '') - ->distinct(true) - ->column('media_channel_code'); - $allowedCodeSet = array_fill_keys(array_filter(array_map('strval', $allowedCodes)), true); - if ($allowedCodeSet === []) { - return []; - } - - return array_values(array_filter($allOptions, static function ($option) use ($allowedCodeSet): bool { - $code = is_array($option) ? (string)($option['code'] ?? '') : ''; - - return $code !== '' && isset($allowedCodeSet[$code]); - })); + // Channels are dimension values, while account_cost rows are optional + // facts. Using cost bindings as an option whitelist hid valid labels and + // made the dropdown change when department/assistant filters changed. + return MediaChannelService::getOptions(); } /** diff --git a/server/app/common/service/qywx/MediaChannelService.php b/server/app/common/service/qywx/MediaChannelService.php index 56da863c6..9e157e906 100755 --- a/server/app/common/service/qywx/MediaChannelService.php +++ b/server/app/common/service/qywx/MediaChannelService.php @@ -11,6 +11,18 @@ use think\db\Query; class MediaChannelService { + private const ACTIVE_ROWS_CACHE_TTL_SECONDS = 1.0; + private const SCAN_DUPLICATE_UPDATE_FIELDS = [ + 'source_group_name', + 'last_seen_time', + 'update_time', + ]; + + /** @var array>|null */ + private static ?array $activeChannelRowsCache = null; + + private static float $activeChannelRowsCachedAt = 0.0; + /** * 与业绩看板「渠道来源」相同的分组(按 source_group_name),不含客户数统计。 * @@ -21,11 +33,7 @@ class MediaChannelService */ public static function getOptionGroups(): array { - $rows = QywxMediaChannel::where('status', 1) - ->field('channel_code, channel_name, source_group_name') - ->order('source_group_name asc, id asc') - ->select() - ->toArray(); + $rows = self::getActiveChannelRows(); if ($rows === []) { return []; @@ -60,11 +68,17 @@ class MediaChannelService */ public static function getOptions(): array { - $rows = QywxMediaChannel::where('status', 1) - ->field('channel_code, channel_name') - ->order('channel_name asc, id asc') - ->select() - ->toArray(); + $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'] ?? ''), @@ -74,9 +88,9 @@ class MediaChannelService public static function getDefaultCode(): string { - return (string) QywxMediaChannel::where('status', 1) - ->order('id asc') - ->value('channel_code'); + $rows = self::getActiveChannelRows(); + + return (string) ($rows[0]['channel_code'] ?? ''); } public static function isValidCode(string $channelCode): bool @@ -86,9 +100,7 @@ class MediaChannelService return false; } - return QywxMediaChannel::where('status', 1) - ->where('channel_code', $channelCode) - ->count() > 0; + return self::getChannelByCode($channelCode) !== null; } public static function normalizeStatsCode(string $channelCode): string @@ -106,11 +118,31 @@ class MediaChannelService return null; } - $row = QywxMediaChannel::where('status', 1) - ->where('channel_code', $channelCode) - ->find(); + $model = QywxMediaChannel::where('channel_code', $channelCode)->find(); - return $row ? $row->toArray() : null; + 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 @@ -134,6 +166,8 @@ class MediaChannelService $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 === []) { @@ -233,6 +267,7 @@ class MediaChannelService while (true) { $rows = QywxExternalContact::where('id', '>', $lastId) + ->whereNull('delete_time') ->field('id, follow_users') ->order('id asc') ->limit($batchSize) @@ -276,20 +311,17 @@ class MediaChannelService 'update_time' => $now, ]; - Db::name('qywx_media_channel')->duplicate([ - 'channel_name', - 'source_tag_id', - 'source_tag_name', - 'source_group_name', - 'status', - 'last_seen_time', - 'update_time', - ])->insert($rowData); + Db::name('qywx_media_channel') + ->duplicate(self::SCAN_DUPLICATE_UPDATE_FIELDS) + ->insert($rowData); $upserted++; } } } + self::$activeChannelRowsCache = null; + self::$activeChannelRowsCachedAt = 0.0; + return [ 'scanned_contacts' => $scannedContacts, 'discovered_tags' => count($discoveredTags), @@ -297,6 +329,191 @@ class MediaChannelService ]; } + /** + * 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[] diff --git a/server/sql/1.9.20260808/add_media_channel_stats_index.sql b/server/sql/1.9.20260808/add_media_channel_stats_index.sql index 2d9b88489..1f139c350 100644 --- a/server/sql/1.9.20260808/add_media_channel_stats_index.sql +++ b/server/sql/1.9.20260808/add_media_channel_stats_index.sql @@ -17,3 +17,22 @@ SET @add_idx_tag_ext_sql := IF( PREPARE add_idx_tag_ext_stmt FROM @add_idx_tag_ext_sql; EXECUTE add_idx_tag_ext_stmt; DEALLOCATE PREPARE add_idx_tag_ext_stmt; + +-- 渠道选项按标签读取最新名称,覆盖 tag_id + update_time 可避免重复扫描关系表。 +SET @idx_tag_update_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'zyt_qywx_external_contact_tag' + AND INDEX_NAME = 'idx_tag_update' +); + +SET @add_idx_tag_update_sql := IF( + @idx_tag_update_exists = 0, + 'ALTER TABLE `zyt_qywx_external_contact_tag` ADD INDEX `idx_tag_update` (`tag_id`, `update_time`)', + 'SELECT 1' +); + +PREPARE add_idx_tag_update_stmt FROM @add_idx_tag_update_sql; +EXECUTE add_idx_tag_update_stmt; +DEALLOCATE PREPARE add_idx_tag_update_stmt; diff --git a/server/tests/MediaChannelOptionsMergeTest.php b/server/tests/MediaChannelOptionsMergeTest.php new file mode 100644 index 000000000..cc4911526 --- /dev/null +++ b/server/tests/MediaChannelOptionsMergeTest.php @@ -0,0 +1,188 @@ +setAccessible(true); +$scanUpdateFields = (new ReflectionClass(MediaChannelService::class)) + ->getReflectionConstant('SCAN_DUPLICATE_UPDATE_FIELDS') + ?->getValue(); +if (!is_array($scanUpdateFields) + || array_intersect(['channel_name', 'source_tag_name', 'status'], $scanUpdateFields) !== []) { + throw new RuntimeException('Channel scan would overwrite rename aliases, manual labels, or disabled status'); +} + +$configuredRows = [ + [ + 'id' => 1, + 'channel_code' => 'stable-a', + 'channel_name' => 'Old tag name', + 'source_tag_id' => 'tag-a', + 'source_tag_name' => 'Old tag name', + 'source_group_name' => 'Old group', + 'status' => 1, + ], + [ + 'id' => 2, + 'channel_code' => 'manual-name', + 'channel_name' => 'Manual campaign label', + 'source_tag_id' => 'tag-manual', + 'source_tag_name' => 'Old manual tag', + 'source_group_name' => 'Old group', + 'status' => 1, + ], + [ + 'id' => 3, + 'channel_code' => 'disabled-tag', + 'channel_name' => 'Disabled tag', + 'source_tag_id' => 'tag-disabled', + 'source_tag_name' => 'Disabled tag', + 'source_group_name' => 'Group', + 'status' => 0, + ], + [ + 'id' => 4, + 'channel_code' => 'legacy-name-only', + 'channel_name' => 'Legacy name-only channel', + 'source_tag_id' => '', + 'source_tag_name' => 'Legacy name-only channel', + 'source_group_name' => 'Legacy', + 'status' => 1, + ], +]; + +$tagRows = [ + [ + 'source_tag_id' => 'tag-a', + 'source_tag_name' => 'Renamed tag', + 'source_group_name' => 'New group', + ], + [ + 'source_tag_id' => 'tag-manual', + 'source_tag_name' => 'Renamed manual tag', + 'source_group_name' => 'New group', + ], + [ + 'source_tag_id' => 'tag-disabled', + 'source_tag_name' => 'Disabled tag returned by relation table', + 'source_group_name' => 'Group', + ], + [ + 'source_tag_id' => 'tag-new', + 'source_tag_name' => 'Newly discovered tag', + 'source_group_name' => 'New group', + ], +]; + +/** @var array> $mergedRows */ +$mergedRows = $mergeMethod->invoke(null, $configuredRows, $tagRows); +$byCode = []; +foreach ($mergedRows as $row) { + $byCode[(string) ($row['channel_code'] ?? '')] = $row; +} + +$renamed = $byCode['stable-a'] ?? null; +if (!is_array($renamed) + || ($renamed['channel_name'] ?? '') !== 'Renamed tag' + || ($renamed['source_tag_name'] ?? '') !== 'Renamed tag' + || ($renamed['source_group_name'] ?? '') !== 'New group' + || ($renamed['legacy_channel_name'] ?? '') !== 'Old tag name' + || ($renamed['legacy_source_tag_name'] ?? '') !== 'Old tag name') { + throw new RuntimeException('Automatic channel name was not refreshed with backward-compatible aliases'); +} + +$manual = $byCode['manual-name'] ?? null; +if (!is_array($manual) + || ($manual['channel_name'] ?? '') !== 'Manual campaign label' + || ($manual['source_tag_name'] ?? '') !== 'Renamed manual tag' + || ($manual['legacy_source_tag_name'] ?? '') !== 'Old manual tag') { + throw new RuntimeException('Manual display name or refreshed tag metadata was not preserved'); +} + +if (isset($byCode['disabled-tag']) || isset($byCode['tag_tag-disabled'])) { + throw new RuntimeException('Explicitly disabled tag was reintroduced'); +} + +$newTag = $byCode['tag_tag-new'] ?? null; +if (!is_array($newTag) + || ($newTag['channel_name'] ?? '') !== 'Newly discovered tag' + || ($newTag['source_tag_id'] ?? '') !== 'tag-new') { + throw new RuntimeException('New relation-table tag was not added with a deterministic channel code'); +} + +if (!isset($byCode['legacy-name-only'])) { + throw new RuntimeException('Historical name-only channel was removed'); +} + +if (in_array('--integration', $argv, true)) { + $app = new think\App(); + $app->initialize(); + + $loadTagsMethod = new ReflectionMethod(MediaChannelService::class, 'loadCurrentTagRows'); + $loadTagsMethod->setAccessible(true); + $activeRowsMethod = new ReflectionMethod(MediaChannelService::class, 'getActiveChannelRows'); + $activeRowsMethod->setAccessible(true); + + $startedAt = microtime(true); + /** @var array> $currentTags */ + $currentTags = $loadTagsMethod->invoke(null, null); + /** @var array> $activeRows */ + $activeRows = $activeRowsMethod->invoke(null); + $options = MediaChannelService::getOptions(); + $elapsedMs = round((microtime(true) - $startedAt) * 1000, 1); + + $activeByTagId = []; + foreach ($activeRows as $row) { + $tagId = trim((string) ($row['source_tag_id'] ?? '')); + if ($tagId !== '') { + $activeByTagId[$tagId] = $row; + } + } + $disabledTagIds = array_fill_keys(array_map( + 'strval', + Db::name('qywx_media_channel') + ->where('status', 0) + ->where('source_tag_id', '<>', '') + ->column('source_tag_id') + ), true); + + foreach ($currentTags as $tag) { + $tagId = (string) ($tag['source_tag_id'] ?? ''); + if ($tagId === '' || isset($disabledTagIds[$tagId])) { + continue; + } + $channel = $activeByTagId[$tagId] ?? null; + if (!is_array($channel)) { + throw new RuntimeException("Current relation-table tag {$tagId} is absent from channel options"); + } + $currentName = (string) ($tag['source_tag_name'] ?? ''); + if ($currentName !== '' && ($channel['source_tag_name'] ?? '') !== $currentName) { + throw new RuntimeException("Current tag name for {$tagId} was not refreshed"); + } + } + + if (count($options) !== count($activeRows)) { + throw new RuntimeException('Public option count differs from merged active channel count'); + } + + $matchingNames = array_values(array_map( + static fn (array $option): string => (string) ($option['name'] ?? ''), + array_filter( + $options, + static fn (array $option): bool => mb_strpos((string) ($option['name'] ?? ''), '4') !== false + ) + )); + echo json_encode([ + 'current_tag_count' => count($currentTags), + 'channel_option_count' => count($options), + 'elapsed_ms' => $elapsedMs, + 'matching_4' => $matchingNames, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n"; +} + +echo "MEDIA_CHANNEL_OPTIONS_MERGE_OK\n";