1192 lines
43 KiB
PHP
Executable File
1192 lines
43 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\Cache;
|
|
use think\facade\Db;
|
|
use think\db\Query;
|
|
|
|
class MediaChannelService
|
|
{
|
|
private const ACTIVE_ROWS_CACHE_TTL_SECONDS = 1.0;
|
|
private const CURRENT_TAG_CATALOG_CACHE_KEY = 'qywx:current_tag_catalog:v1';
|
|
private const CURRENT_TAG_CATALOG_CACHE_TTL_SECONDS = 30;
|
|
private const SCAN_DUPLICATE_UPDATE_FIELDS = [
|
|
'source_group_name',
|
|
'last_seen_time',
|
|
'update_time',
|
|
];
|
|
|
|
public const GROUP_CODE_PREFIX = 'group:';
|
|
|
|
/** @var array<int, array<string, mixed>>|null */
|
|
private static ?array $activeChannelRowsCache = null;
|
|
|
|
private static float $activeChannelRowsCachedAt = 0.0;
|
|
|
|
/** @var array<int, array{tag_id: string, tag_name: string, group_name: string, customer_count: int}>|null */
|
|
private static ?array $currentTagCatalogCache = null;
|
|
|
|
private static float $currentTagCatalogCachedAt = 0.0;
|
|
|
|
/** @var array<int, array<string, mixed>>|null */
|
|
private static ?array $currentTagChannelRowsCache = null;
|
|
|
|
private static float $currentTagChannelRowsCachedAt = 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);
|
|
}
|
|
|
|
/**
|
|
* 企微客户页与一诊渠道共用的当前标签目录。
|
|
*
|
|
* 只统计仍关联未删除客户的标签;同一个 tag_id 只保留更新时间最新、
|
|
* 同时间 id 最大的一份名称和分组,避免标签改名后同时展示新旧快照。
|
|
*
|
|
* @return array<int, array{tag_id: string, tag_name: string, group_name: string, customer_count: int}>
|
|
*/
|
|
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 = <<<SQL
|
|
SELECT latest_tag.tag_id,
|
|
COALESCE(latest_tag.tag_name, '') AS tag_name,
|
|
COALESCE(latest_tag.group_name, '') AS group_name,
|
|
current_tag.customer_count
|
|
FROM {$tagTable} latest_tag
|
|
INNER JOIN (
|
|
SELECT tagged.tag_id,
|
|
MAX(CONCAT(LPAD(tagged.update_time, 10, '0'), LPAD(tagged.id, 10, '0'))) AS latest_sort_key,
|
|
COUNT(DISTINCT tagged.external_userid) AS customer_count
|
|
FROM {$tagTable} tagged
|
|
INNER JOIN {$contactTable} active_contact
|
|
ON active_contact.external_userid = tagged.external_userid
|
|
AND active_contact.delete_time IS NULL
|
|
WHERE tagged.tag_id <> ''
|
|
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<int, array{code: string, name: string, tag_id: string, group_name: string, customer_count: int}>
|
|
*/
|
|
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' => trim((string) ($row['source_group_name'] ?? '')),
|
|
'customer_count' => (int) ($row['customer_count'] ?? 0),
|
|
'kind' => 'channel',
|
|
], self::getCurrentTagChannelRows());
|
|
}
|
|
|
|
/**
|
|
* 一诊专用解析器。当前标签即使在历史渠道注册表中被停用,也仍按企微当前标签生效;
|
|
* 全局财务渠道的启停语义继续由 getChannelByCode() 维护。
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function getCurrentTagChannelByCode(string $channelCode): ?array
|
|
{
|
|
$channelCode = trim($channelCode);
|
|
if ($channelCode === '') {
|
|
return null;
|
|
}
|
|
|
|
$groupName = self::parseGroupName($channelCode);
|
|
if ($groupName !== '') {
|
|
return self::buildCurrentTagGroupChannel($groupName);
|
|
}
|
|
|
|
foreach (self::getCurrentTagChannelRows() as $row) {
|
|
if ((string) ($row['channel_code'] ?? '') === $channelCode) {
|
|
return $row;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static function isGroupCode(string $channelCode): bool
|
|
{
|
|
return self::parseGroupName($channelCode) !== '';
|
|
}
|
|
|
|
public static function buildGroupCode(string $groupName): string
|
|
{
|
|
$groupName = trim($groupName);
|
|
|
|
return $groupName === '' ? '' : self::GROUP_CODE_PREFIX . $groupName;
|
|
}
|
|
|
|
public static function parseGroupName(string $channelCode): string
|
|
{
|
|
$channelCode = trim($channelCode);
|
|
if (!str_starts_with($channelCode, self::GROUP_CODE_PREFIX)) {
|
|
return '';
|
|
}
|
|
|
|
return trim(substr($channelCode, strlen(self::GROUP_CODE_PREFIX)));
|
|
}
|
|
|
|
/**
|
|
* 账户消耗等事实表使用的真实渠道 code;分组筛选会展开为组内全部叶子渠道。
|
|
*
|
|
* @param array<string, mixed>|null $channel
|
|
* @return string[]
|
|
*/
|
|
public static function getChannelCodesForStats(?array $channel): array
|
|
{
|
|
if ($channel === null) {
|
|
return [];
|
|
}
|
|
|
|
$codes = [];
|
|
if (isset($channel['channel_codes']) && is_array($channel['channel_codes'])) {
|
|
foreach ($channel['channel_codes'] as $code) {
|
|
$code = trim((string) $code);
|
|
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
|
|
$codes[$code] = $code;
|
|
}
|
|
}
|
|
}
|
|
$code = trim((string) ($channel['channel_code'] ?? ''));
|
|
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
|
|
$codes[$code] = $code;
|
|
}
|
|
|
|
return array_values($codes);
|
|
}
|
|
|
|
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 = [
|
|
trim((string) ($channel['channel_name'] ?? '')),
|
|
trim((string) ($channel['source_tag_name'] ?? '')),
|
|
trim((string) ($channel['legacy_channel_name'] ?? '')),
|
|
trim((string) ($channel['legacy_source_tag_name'] ?? '')),
|
|
];
|
|
foreach (['channel_names', 'source_tag_names'] as $listKey) {
|
|
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
|
|
continue;
|
|
}
|
|
foreach ($channel[$listKey] as $name) {
|
|
$names[] = trim((string) $name);
|
|
}
|
|
}
|
|
$names = array_values(array_unique(array_filter($names, static fn (string $name): bool => $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.
|
|
*
|
|
* When the fact has an employee dimension, pass $followUserField so a tag
|
|
* applied by employee A cannot make employee B's event match the channel.
|
|
*
|
|
* @param array<string, mixed>|null $channel
|
|
*/
|
|
public static function applyExternalUserChannelFilter(
|
|
Query $query,
|
|
string $field,
|
|
?array $channel,
|
|
?string $followUserField = null
|
|
): void
|
|
{
|
|
if ($channel === null) {
|
|
return;
|
|
}
|
|
|
|
$tagIds = self::channelTagIds($channel);
|
|
if ($tagIds !== []) {
|
|
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
|
|
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
|
$tagPredicate = count($tagIds) === 1
|
|
? 'channel_tag.tag_id = ?'
|
|
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
|
|
$followUserPredicate = $followUserField === null
|
|
? ''
|
|
: "AND channel_tag.follow_user_id = {$followUserField} ";
|
|
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
|
|
$query->whereRaw(
|
|
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
|
|
. "WHERE channel_tag.external_userid = {$field} "
|
|
. $followUserPredicate
|
|
. "AND {$tagPredicate} "
|
|
. "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))',
|
|
$tagIds
|
|
);
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Filter an add-event fact by its append-only channel snapshot. Events that
|
|
* pre-date the snapshot migration explicitly fall back to the old projection,
|
|
* but the fallback is constrained to the event's exact employee.
|
|
*
|
|
* @param array<string, mixed>|null $channel
|
|
*/
|
|
public static function applyExternalUserEventChannelFilter(
|
|
Query $query,
|
|
string $eventIdField,
|
|
string $externalUserField,
|
|
string $followUserField,
|
|
?array $channel,
|
|
bool $historicalContact = false
|
|
): void
|
|
{
|
|
if ($channel === null) {
|
|
return;
|
|
}
|
|
|
|
$tagIds = self::channelTagIds($channel);
|
|
if ($tagIds === [] || !QywxExternalContactEventTagSnapshotService::installed()) {
|
|
if ($historicalContact) {
|
|
self::applyHistoricalExternalUserChannelFilter(
|
|
$query,
|
|
$externalUserField,
|
|
$channel,
|
|
$followUserField
|
|
);
|
|
} else {
|
|
self::applyExternalUserChannelFilter($query, $externalUserField, $channel, $followUserField);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$snapshotTable = self::tableWithPrefix('qywx_external_contact_event_tag');
|
|
$snapshotPredicate = count($tagIds) === 1
|
|
? 'event_channel_tag.tag_id = ?'
|
|
: 'event_channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
|
|
$snapshotMatch = "EXISTS (SELECT 1 FROM {$snapshotTable} event_channel_tag"
|
|
. " WHERE event_channel_tag.event_id = {$eventIdField}"
|
|
. " AND event_channel_tag.follow_user_id = {$followUserField}"
|
|
. " AND {$snapshotPredicate})";
|
|
$snapshotMissing = "NOT EXISTS (SELECT 1 FROM {$snapshotTable} captured_event_channel"
|
|
. " WHERE captured_event_channel.event_id = {$eventIdField}"
|
|
. " AND captured_event_channel.follow_user_id = {$followUserField}"
|
|
. " AND captured_event_channel.tag_id = '')";
|
|
|
|
$query->where(function ($channelQuery) use (
|
|
$snapshotMatch,
|
|
$snapshotMissing,
|
|
$tagIds,
|
|
$historicalContact,
|
|
$externalUserField,
|
|
$followUserField,
|
|
$channel
|
|
): void {
|
|
$channelQuery->whereRaw($snapshotMatch, $tagIds)
|
|
->whereOr(function ($legacyQuery) use (
|
|
$snapshotMissing,
|
|
$historicalContact,
|
|
$externalUserField,
|
|
$followUserField,
|
|
$channel
|
|
): void {
|
|
$legacyQuery->whereRaw($snapshotMissing);
|
|
if ($historicalContact) {
|
|
self::applyHistoricalExternalUserChannelFilter(
|
|
$legacyQuery,
|
|
$externalUserField,
|
|
$channel,
|
|
$followUserField
|
|
);
|
|
} else {
|
|
self::applyExternalUserChannelFilter(
|
|
$legacyQuery,
|
|
$externalUserField,
|
|
$channel,
|
|
$followUserField
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter an external_userid fact by the channel snapshot retained in
|
|
* qywx_external_contact.follow_users, including soft-deleted contacts.
|
|
*
|
|
* del_external_contact removes normalized tag relations, so historical
|
|
* deleted-fan attribution cannot use applyExternalUserChannelFilter(). The
|
|
* contact row itself retains follow_users and is the best available channel
|
|
* snapshot for this specific historical statistic.
|
|
*
|
|
* When $followUserField is provided, JSON_SEARCH first locates that exact
|
|
* employee object and JSON_CONTAINS checks only its tag array.
|
|
*
|
|
* @param array<string, mixed>|null $channel
|
|
*/
|
|
public static function applyHistoricalExternalUserChannelFilter(
|
|
Query $query,
|
|
string $field,
|
|
?array $channel,
|
|
?string $followUserField = null
|
|
): void
|
|
{
|
|
if ($channel === null) {
|
|
return;
|
|
}
|
|
|
|
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
|
$tagIds = self::channelTagIds($channel);
|
|
if ($followUserField !== null && $tagIds !== []) {
|
|
$safeFollowUsers = "IF(JSON_VALID(historical_channel_contact.follow_users),"
|
|
. ' historical_channel_contact.follow_users, JSON_ARRAY())';
|
|
$userPath = "JSON_UNQUOTE(JSON_SEARCH({$safeFollowUsers}, 'one', {$followUserField},"
|
|
. " NULL, '$[*].userid'))";
|
|
$tagsPath = "IFNULL(REPLACE({$userPath}, '.userid', '.tags'), '$.__missing__')";
|
|
$tagsJson = "JSON_EXTRACT({$safeFollowUsers}, {$tagsPath})";
|
|
$tagPredicates = [];
|
|
$bindings = [];
|
|
foreach ($tagIds as $tagId) {
|
|
$tagPredicates[] = "JSON_CONTAINS({$tagsJson}, JSON_OBJECT('tag_id', ?))";
|
|
$bindings[] = $tagId;
|
|
}
|
|
$query->whereRaw(
|
|
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
|
|
. " WHERE historical_channel_contact.external_userid = {$field}"
|
|
. " AND {$userPath} IS NOT NULL"
|
|
. ' AND (' . implode(' OR ', $tagPredicates) . '))',
|
|
$bindings
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$patterns = self::buildLikePatterns($channel);
|
|
if ($patterns === []) {
|
|
$query->whereRaw('1 = 0');
|
|
|
|
return;
|
|
}
|
|
|
|
$segments = [];
|
|
$bindings = [];
|
|
foreach ($patterns as $pattern) {
|
|
$segments[] = 'historical_channel_contact.follow_users LIKE ?';
|
|
$bindings[] = $pattern;
|
|
}
|
|
$query->whereRaw(
|
|
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
|
|
. " WHERE historical_channel_contact.external_userid = {$field}"
|
|
. ' 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<int, array<string, mixed>>
|
|
*/
|
|
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<int, array<string, mixed>> $catalog
|
|
* @param array<int, array<string, mixed>> $configuredRows
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
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<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 = [];
|
|
foreach (self::channelTagIds($channel) as $tagId) {
|
|
$escapedTagId = addcslashes($tagId, '%_\\');
|
|
$patterns[] = '%"tag_id":"' . $escapedTagId . '"%';
|
|
$patterns[] = '%"id":"' . $escapedTagId . '"%';
|
|
}
|
|
|
|
$tagNames = [trim((string) ($channel['source_tag_name'] ?? ''))];
|
|
if (isset($channel['channel_names']) && is_array($channel['channel_names'])) {
|
|
foreach ($channel['channel_names'] as $name) {
|
|
$tagNames[] = trim((string) $name);
|
|
}
|
|
}
|
|
if (isset($channel['source_tag_names']) && is_array($channel['source_tag_names'])) {
|
|
foreach ($channel['source_tag_names'] as $name) {
|
|
$tagNames[] = trim((string) $name);
|
|
}
|
|
}
|
|
foreach (array_unique(array_filter($tagNames, static fn (string $name): bool => $name !== '')) as $tagName) {
|
|
$escapedTagName = addcslashes($tagName, '%_\\');
|
|
$patterns[] = '%"name":"' . $escapedTagName . '"%';
|
|
$patterns[] = '%"tag_name":"' . $escapedTagName . '"%';
|
|
}
|
|
|
|
return array_values(array_unique($patterns));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $channel
|
|
* @return string[]
|
|
*/
|
|
private static function channelTagIds(array $channel): array
|
|
{
|
|
$tagIds = [];
|
|
if (isset($channel['source_tag_ids']) && is_array($channel['source_tag_ids'])) {
|
|
foreach ($channel['source_tag_ids'] as $tagId) {
|
|
$tagId = trim((string) $tagId);
|
|
if ($tagId !== '') {
|
|
$tagIds[$tagId] = $tagId;
|
|
}
|
|
}
|
|
}
|
|
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
|
|
if ($tagId !== '') {
|
|
$tagIds[$tagId] = $tagId;
|
|
}
|
|
|
|
return array_values($tagIds);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private static function buildCurrentTagGroupChannel(string $groupName): ?array
|
|
{
|
|
$groupName = trim($groupName);
|
|
if ($groupName === '') {
|
|
return null;
|
|
}
|
|
|
|
$rows = [];
|
|
foreach (self::getCurrentTagChannelRows() as $row) {
|
|
if (trim((string) ($row['source_group_name'] ?? '')) === $groupName) {
|
|
$rows[] = $row;
|
|
}
|
|
}
|
|
if ($rows === []) {
|
|
return null;
|
|
}
|
|
|
|
$tagIds = [];
|
|
$codes = [];
|
|
$names = [];
|
|
$customerCount = 0;
|
|
foreach ($rows as $row) {
|
|
$tagId = trim((string) ($row['source_tag_id'] ?? ''));
|
|
if ($tagId !== '') {
|
|
$tagIds[$tagId] = $tagId;
|
|
}
|
|
$code = trim((string) ($row['channel_code'] ?? ''));
|
|
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
|
|
$codes[$code] = $code;
|
|
}
|
|
foreach (['channel_name', 'source_tag_name', 'legacy_channel_name', 'legacy_source_tag_name'] as $nameKey) {
|
|
$name = trim((string) ($row[$nameKey] ?? ''));
|
|
if ($name !== '') {
|
|
$names[$name] = $name;
|
|
}
|
|
}
|
|
$customerCount = max($customerCount, (int) ($row['customer_count'] ?? 0));
|
|
}
|
|
|
|
return [
|
|
'channel_code' => self::buildGroupCode($groupName),
|
|
'channel_name' => $groupName,
|
|
'source_group_name' => $groupName,
|
|
'source_tag_id' => '',
|
|
'source_tag_name' => $groupName,
|
|
'source_tag_ids' => array_values($tagIds),
|
|
'channel_codes' => array_values($codes),
|
|
'channel_names' => array_values($names),
|
|
'customer_count' => $customerCount,
|
|
'is_group' => true,
|
|
'status' => 1,
|
|
];
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|