Files
zyt/server/app/common/service/qywx/MediaChannelService.php
T
Your Name 2199887c07 更新
:i# Please enter the commit message for your changes. Lines starting
2026-08-08 18:08:23 +08:00

615 lines
21 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use app\common\model\QywxExternalContact;
use app\common\model\QywxMediaChannel;
use think\facade\Db;
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<int, array<string, mixed>>|null */
private static ?array $activeChannelRowsCache = null;
private static float $activeChannelRowsCachedAt = 0.0;
/**
* 与业绩看板「渠道来源」相同的分组(按 source_group_name),不含客户数统计。
*
* @return array<int, array{
* group_name: string,
* channels: array<int, array{channel_code: string, channel_name: string}>
* }>
*/
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<int, array{code: string, name: string}>
*/
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);
}
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<string, mixed>|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<string, mixed>|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<string, mixed>|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<string, mixed>|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');
$query->whereRaw(
"{$field} IN (SELECT channel_tag.external_userid FROM {$tagTable} channel_tag WHERE channel_tag.tag_id = ?)",
[$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;
return [
'scanned_contacts' => $scannedContacts,
'discovered_tags' => count($discoveredTags),
'inserted_or_updated' => $upserted,
];
}
/**
* 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<int, array<string, mixed>>
*/
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<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
*/
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 = <<<SQL
SELECT latest_tag.tag_id AS source_tag_id,
COALESCE(latest_tag.tag_name, '') AS source_tag_name,
COALESCE(latest_tag.group_name, '') AS source_group_name
FROM {$tagTable} latest_tag
INNER JOIN (
SELECT latest_time.tag_id, MAX(tag_at_time.id) AS latest_id
FROM (
SELECT newest_tag.tag_id, MAX(newest_tag.update_time) AS latest_update_time
FROM {$tagTable} newest_tag
WHERE newest_tag.tag_id <> ''
{$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<int, array<string, mixed>> $configuredRows
* @param array<int, array<string, mixed>> $tagRows
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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<int, mixed> $followUsers
* @return array<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
*/
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);
}
}