first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
@@ -0,0 +1,861 @@
<?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',
];
/** @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' => (string) ($row['source_group_name'] ?? ''),
'customer_count' => (int) ($row['customer_count'] ?? 0),
], self::getCurrentTagChannelRows());
}
/**
* 一诊专用解析器。当前标签即使在历史渠道注册表中被停用,也仍按企微当前标签生效;
* 全局财务渠道的启停语义继续由 getChannelByCode() 维护。
*
* @return array<string, mixed>|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<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');
$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<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 = [];
$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);
}
}
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use RuntimeException;
use think\facade\Cache;
/**
* 企业微信内部应用获客链接 API。
*
* @see https://developer.work.weixin.qq.com/document/path/97297
*/
class QywxCustomerAcquisitionApiService
{
private const TOKEN_INVALID_CODES = [40001, 40014, 42001];
private string $corpId;
private string $secret;
private Client $client;
/** @var null|callable():string */
private $accessTokenResolver;
/** @param null|callable():string $accessTokenResolver 仅用于测试或托管 token 场景。 */
public function __construct(?Client $client = null, ?callable $accessTokenResolver = null)
{
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
$this->secret = trim((string) config('qywx_customer_acquisition.secret', ''));
$this->client = $client ?? new Client([
'base_uri' => rtrim((string) config('qywx_customer_acquisition.base_uri', 'https://qyapi.weixin.qq.com'), '/') . '/',
'timeout' => max(5, (int) config('qywx_customer_acquisition.timeout', 20)),
'connect_timeout' => 8,
'http_errors' => false,
'verify' => config('qywx_customer_acquisition.verify', true),
'headers' => ['Accept' => 'application/json'],
]);
$this->accessTokenResolver = $accessTokenResolver;
}
/** @return array{configured:bool,missing:list<string>} */
public static function configurationStatus(): array
{
$missing = [];
if (trim((string) config('qywx_customer_acquisition.corp_id', '')) === '') {
$missing[] = 'work_wechat.corp_id';
}
if (trim((string) config('qywx_customer_acquisition.secret', '')) === '') {
$missing[] = 'work_wechat.customer_acquisition_secret / secret';
}
return ['configured' => $missing === [], 'missing' => $missing];
}
/** @return array{link_id_list:list<string>,next_cursor:string} */
public function listLinks(string $cursor = '', int $limit = 100): array
{
$body = ['limit' => min(100, max(1, $limit))];
if ($cursor !== '') {
$body['cursor'] = $cursor;
}
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/list_link', $body);
return [
'link_id_list' => array_values(array_filter(array_map('strval', (array) ($response['link_id_list'] ?? [])))),
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
];
}
/** @return array<string,mixed> */
public function getLink(string $linkId): array
{
$this->assertLinkId($linkId);
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/get', ['link_id' => $linkId]);
}
/** @return array<string,mixed> */
public function createLink(array $payload): array
{
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/create_link', $payload);
}
/** @return array<string,mixed> */
public function updateLink(array $payload): array
{
$this->assertLinkId((string) ($payload['link_id'] ?? ''));
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/update_link', $payload);
}
public function deleteLink(string $linkId): void
{
$this->assertLinkId($linkId);
$this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/delete_link', ['link_id' => $linkId]);
}
/**
* 获取指定获客链接添加的客户。单页最多 1000 条。
*
* @return array{customer_list:list<array<string,mixed>>,next_cursor:string}
*/
public function listCustomers(string $linkId, string $cursor = '', int $limit = 1000): array
{
$this->assertLinkId($linkId);
$body = [
'link_id' => $linkId,
'limit' => min(1000, max(1, $limit)),
];
if ($cursor !== '') {
$body['cursor'] = $cursor;
}
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/customer', $body);
$customers = array_values(array_filter(
(array) ($response['customer_list'] ?? []),
static fn (mixed $row): bool => is_array($row)
));
return [
'customer_list' => $customers,
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
];
}
/** @return array<string,mixed> */
public function getChatInfo(string $chatKey): array
{
$chatKey = trim($chatKey);
if ($chatKey === '' || strlen($chatKey) > 512) {
throw new RuntimeException('获客会话 ChatKey 不正确');
}
return $this->request(
'POST',
'cgi-bin/externalcontact/customer_acquisition/get_chat_info',
['chat_key' => $chatKey]
);
}
/** 通过只读列表接口验证 token、可信 IP、获客助手开通状态与应用权限。 */
public function checkPermission(): array
{
$result = $this->listLinks('', 1);
$hasLink = $result['link_id_list'] !== [];
return [
'ok' => true,
'message' => $hasLink
? '获客助手 API 权限验证通过,当前应用已有官方获客链接'
: '获客助手 API 权限验证通过,但当前应用尚未通过 API 创建官方获客链接',
'has_link' => $hasLink,
];
}
/** @return array<string,mixed> */
private function request(string $method, string $path, array $body = [], bool $retried = false): array
{
$this->assertConfigured();
$cacheKey = $this->tokenCacheKey();
$token = $this->accessToken();
try {
$options = ['query' => ['access_token' => $token]];
if (strtoupper($method) === 'POST') {
$options['json'] = $body;
}
$response = $this->client->request($method, ltrim($path, '/'), $options);
} catch (GuzzleException $e) {
throw new RuntimeException('企业微信获客助手接口连接失败,请检查服务器网络与可信 IP 配置', 0, $e);
}
$decoded = json_decode((string) $response->getBody(), true);
if (!is_array($decoded)) {
throw new RuntimeException('企业微信获客助手接口返回了无法解析的数据');
}
$errcode = (int) ($decoded['errcode'] ?? 0);
if ($errcode === 0) {
return $decoded;
}
if (!$retried && in_array($errcode, self::TOKEN_INVALID_CODES, true)) {
Cache::delete($cacheKey);
return $this->request($method, $path, $body, true);
}
throw new RuntimeException(sprintf(
'企业微信获客助手接口失败[%d]%s',
$errcode,
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
));
}
private function accessToken(): string
{
if ($this->accessTokenResolver !== null) {
$token = trim((string) call_user_func($this->accessTokenResolver));
if ($token === '') {
throw new RuntimeException('托管 access_token 为空');
}
return $token;
}
$cacheKey = $this->tokenCacheKey();
$cached = trim((string) Cache::get($cacheKey, ''));
if ($cached !== '') {
return $cached;
}
try {
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
]);
} catch (GuzzleException $e) {
throw new RuntimeException('获取企业微信 access_token 失败,请检查服务器网络', 0, $e);
}
$decoded = json_decode((string) $response->getBody(), true);
if (!is_array($decoded) || (int) ($decoded['errcode'] ?? 0) !== 0 || empty($decoded['access_token'])) {
throw new RuntimeException(sprintf(
'获取企业微信 access_token 失败[%d]%s',
(int) ($decoded['errcode'] ?? -1),
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
));
}
$token = (string) $decoded['access_token'];
Cache::set($cacheKey, $token, max(60, (int) ($decoded['expires_in'] ?? 7200) - 300));
return $token;
}
private function tokenCacheKey(): string
{
return 'qywx_customer_acquisition_token:' . hash('sha256', $this->corpId . '|' . $this->secret);
}
private function assertConfigured(): void
{
$status = self::configurationStatus();
if (!$status['configured']) {
throw new RuntimeException('获客助手应用配置不完整:缺少 ' . implode('、', $status['missing']));
}
}
private function assertLinkId(string $linkId): void
{
if ($linkId === '' || strlen($linkId) > 128) {
throw new RuntimeException('获客链接 ID 不正确');
}
}
}
@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
use think\facade\Db;
/** 获客客户归因、会话统计与回调幂等落库。 */
class QywxCustomerAcquisitionCustomerService
{
private QywxCustomerAcquisitionApiService $api;
public function __construct(?QywxCustomerAcquisitionApiService $api = null)
{
$this->api = $api ?? new QywxCustomerAcquisitionApiService();
}
/**
* 同步一个远端获客链接的全部客户,远端列表字段采用覆盖语义。
* recv_msg_cnt 不在列表接口中返回,因此同步时保留本地值。
*
* @return array{scanned:int,created:int,updated:int,pages:int,truncated:bool}
*/
public function syncLink(string $remoteLinkId, int $maxCustomers = 20000): array
{
$remoteLinkId = trim($remoteLinkId);
if ($remoteLinkId === '') {
throw new RuntimeException('获客链接 ID 不能为空');
}
$cursor = '';
$scanned = 0;
$created = 0;
$updated = 0;
$pages = 0;
do {
$page = $this->api->listCustomers($remoteLinkId, $cursor, 1000);
$pages++;
foreach ($page['customer_list'] as $customer) {
if ($scanned >= $maxCustomers) {
break 2;
}
$scanned++;
$result = self::upsertCustomer($remoteLinkId, $customer, false);
$result === 'created' ? $created++ : $updated++;
}
$cursor = (string) ($page['next_cursor'] ?? '');
} while ($cursor !== '');
return compact('scanned', 'created', 'updated', 'pages') + ['truncated' => $cursor !== ''];
}
/**
* 处理 customer_acquisition 回调。相同事件只成功处理一次;失败事件保留审计并允许企微重试。
*
* @return array{duplicate:bool,status:string}
*/
public function handleCallback(array $message): array
{
$changeType = trim((string) ($message['ChangeType'] ?? $message['change_type'] ?? ''));
if (!in_array($changeType, ['customer_start_chat', 'message_from_customer'], true)) {
return ['duplicate' => false, 'status' => 'ignored'];
}
$chatKey = trim((string) ($message['ChatKey'] ?? $message['Chatkey'] ?? $message['chat_key'] ?? ''));
$eventTime = (int) ($message['CreateTime'] ?? $message['create_time'] ?? 0);
$eventKey = self::eventKey($message, $changeType, $chatKey, $eventTime);
$event = self::beginEvent($eventKey, $changeType, $chatKey, $eventTime, $message);
if (($event['duplicate'] ?? false) === true) {
return ['duplicate' => true, 'status' => 'success'];
}
$eventId = (int) ($event['id'] ?? 0);
try {
// customer_start_chat 仅能确认“客户已发起会话”,企业微信不保证该事件携带 ChatKey。
// 此时先落归因与聊天状态,精确消息数等待 message_from_customer 回调补齐。
if ($changeType === 'customer_start_chat' && $chatKey === '') {
$remoteLinkId = trim((string) (
$message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
));
$externalUserId = trim((string) (
$message['ExternalUserID'] ?? $message['ExternalUserId'] ?? $message['external_userid'] ?? ''
));
$userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? $message['userid'] ?? ''));
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
throw new RuntimeException('customer_start_chat 回调缺少 link_id / external_userid / userid');
}
self::upsertCustomer($remoteLinkId, [
'external_userid' => $externalUserId,
'userid' => $userId,
'chat_status' => 1,
'state' => (string) ($message['State'] ?? $message['state'] ?? ''),
'event_time' => $eventTime,
'snapshot' => $message,
], false);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
}
if ($chatKey === '') {
self::finishEvent($eventId, 3, 'failed_invalid: message_from_customer 回调缺少 ChatKey');
throw new RuntimeException('message_from_customer 回调缺少 ChatKey');
}
$now = time();
if ($eventTime > 0 && ($now - $eventTime) >= 1800) {
throw new RuntimeException('获客回调 ChatKey 已超过 30 分钟有效期');
}
$chat = $this->api->getChatInfo($chatKey);
$chatInfo = is_array($chat['chat_info'] ?? null) ? $chat['chat_info'] : [];
$remoteLinkId = trim((string) (
$chatInfo['link_id'] ?? $message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
));
$externalUserId = trim((string) (
$chat['external_userid'] ?? $message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''
));
$userId = trim((string) ($chat['userid'] ?? $message['UserID'] ?? $message['UserId'] ?? ''));
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
throw new RuntimeException('get_chat_info 未返回完整的 link_id / external_userid / userid');
}
self::upsertCustomer($remoteLinkId, [
'external_userid' => $externalUserId,
'userid' => $userId,
'chat_status' => max(1, (int) ($message['ChatStatus'] ?? 1)),
'recv_msg_cnt' => max(0, (int) ($chatInfo['recv_msg_cnt'] ?? 0)),
'state' => (string) ($chatInfo['state'] ?? $message['State'] ?? ''),
'event_time' => $eventTime,
'snapshot' => $chat,
], true);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
} catch (\Throwable $e) {
if ($eventId > 0 && str_contains($e->getMessage(), 'message_from_customer 回调缺少 ChatKey')) {
throw $e;
}
self::scheduleRetryOrExpire($eventId, $eventTime, $e->getMessage());
throw $e;
}
}
/**
* 重试仍在 ChatKey 30 分钟有效期内的失败回调,并把到期记录明确标记 failed_expired。
*
* @return array{selected:int,success:int,failed:int,expired:int}
*/
public function retryPending(int $limit = 100): array
{
$now = time();
// 进程在 beginEvent 后异常退出时,处理中事件会卡在 status=0;一分钟后自动回收再试。
Db::name('qywx_customer_acquisition_event')
->where('status', 0)
->where('update_time', '<=', $now - 60)
->where('expire_time', '>', $now)
->update([
'status' => 2,
'next_retry' => $now,
'error_message' => 'watchdog_recovered: 上次处理未正常结束',
'update_time' => $now,
]);
$expired = (int) Db::name('qywx_customer_acquisition_event')
->whereIn('status', [0, 2])
->where('expire_time', '>', 0)
->where('expire_time', '<=', $now)
->update([
'status' => 3,
'next_retry' => 0,
'error_message' => 'failed_expired: ChatKey 已超过 30 分钟有效期',
'chat_key' => '',
'raw_json' => null,
'update_time' => $now,
]);
$rows = Db::name('qywx_customer_acquisition_event')
->where('status', 2)
->where('next_retry', '<=', $now)
->where('expire_time', '>', $now)
->order('next_retry', 'asc')
->limit(min(500, max(1, $limit)))
->select()->toArray();
$success = 0;
$failed = 0;
foreach ($rows as $row) {
$message = json_decode((string) ($row['raw_json'] ?? ''), true);
if (!is_array($message)) {
self::scheduleRetryOrExpire(
(int) $row['id'],
(int) ($row['event_time'] ?? 0),
'回调原始数据无法解析'
);
$failed++;
continue;
}
try {
$this->handleCallback($message);
$success++;
} catch (\Throwable) {
$failed++;
}
}
return ['selected' => count($rows), 'success' => $success, 'failed' => $failed, 'expired' => $expired];
}
public static function eventKey(array $message, string $changeType, string $chatKey, int $eventTime): string
{
$parts = [
(string) ($message['MsgId'] ?? $message['MsgID'] ?? ''),
$changeType,
$chatKey,
(string) $eventTime,
(string) ($message['LinkID'] ?? $message['LinkId'] ?? ''),
(string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''),
(string) ($message['UserID'] ?? $message['UserId'] ?? ''),
];
return hash('sha256', implode('|', $parts));
}
/** @return array{expire_time:int,next_retry:int,expired:bool} */
public static function retryDecision(int $eventTime, int $now, int $storedExpireTime = 0): array
{
$expireTime = $storedExpireTime > 0
? $storedExpireTime
: ($eventTime > 0 ? $eventTime + 1800 : $now + 1800);
$expired = $expireTime <= $now;
return [
'expire_time' => $expireTime,
'next_retry' => $expired ? 0 : min($expireTime - 1, $now + 30),
'expired' => $expired,
];
}
/** @return array{id:int,duplicate:bool} */
private static function beginEvent(
string $eventKey,
string $changeType,
string $chatKey,
int $eventTime,
array $message
): array {
$now = time();
$raw = self::encodeJson($message);
$expireTime = self::retryDecision($eventTime, $now)['expire_time'];
try {
$id = (int) Db::name('qywx_customer_acquisition_event')->insertGetId([
'event_key' => $eventKey,
'change_type' => $changeType,
'chat_key' => $chatKey,
'status' => 0,
'attempts' => 1,
'event_time' => max(0, $eventTime),
'expire_time' => $expireTime,
'next_retry' => 0,
'error_message' => '',
'raw_json' => $raw,
'create_time' => $now,
'update_time' => $now,
]);
return ['id' => $id, 'duplicate' => false];
} catch (\Throwable $e) {
$existing = Db::name('qywx_customer_acquisition_event')->where('event_key', $eventKey)->find();
if (!$existing) {
throw $e;
}
if ((int) ($existing['status'] ?? 0) === 1) {
return ['id' => (int) $existing['id'], 'duplicate' => true];
}
if ((int) ($existing['status'] ?? 0) !== 2) {
return ['id' => (int) $existing['id'], 'duplicate' => true];
}
$claimed = Db::name('qywx_customer_acquisition_event')
->where('id', (int) $existing['id'])
->where('status', 2)
->update([
'status' => 0,
'attempts' => (int) ($existing['attempts'] ?? 0) + 1,
'error_message' => '',
'raw_json' => $raw,
'update_time' => $now,
]);
if ($claimed <= 0) {
return ['id' => (int) $existing['id'], 'duplicate' => true];
}
return ['id' => (int) $existing['id'], 'duplicate' => false];
}
}
private static function finishEvent(
int $id,
int $status,
string $error = '',
string $remoteLinkId = '',
string $externalUserId = '',
string $userId = ''
): void {
if ($id <= 0) {
return;
}
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
'status' => $status,
'link_id' => $remoteLinkId,
'external_userid' => $externalUserId,
'userid' => $userId,
'error_message' => mb_substr($error, 0, 1000),
'next_retry' => 0,
// ChatKey 是短时敏感凭证,终态后不再保留;原始回调也随之清理。
'chat_key' => '',
'raw_json' => null,
'update_time' => time(),
]);
}
private static function scheduleRetryOrExpire(int $id, int $eventTime, string $error): void
{
if ($id <= 0) {
return;
}
$now = time();
$expireTime = (int) (Db::name('qywx_customer_acquisition_event')
->where('id', $id)->value('expire_time') ?? 0);
$decision = self::retryDecision($eventTime, $now, $expireTime);
$expireTime = $decision['expire_time'];
$expired = $decision['expired'];
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
'status' => $expired ? 3 : 2,
'expire_time' => $expireTime,
'next_retry' => $decision['next_retry'],
'error_message' => mb_substr(
$expired ? 'failed_expired: ' . $error : $error,
0,
1000
),
'chat_key' => $expired ? '' : Db::raw('chat_key'),
'raw_json' => $expired ? null : Db::raw('raw_json'),
'update_time' => $now,
]);
}
/** @return 'created'|'updated' */
private static function upsertCustomer(string $remoteLinkId, array $customer, bool $messageCountKnown): string
{
$externalUserId = trim((string) ($customer['external_userid'] ?? ''));
$userId = trim((string) ($customer['userid'] ?? ''));
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
throw new RuntimeException('获客客户数据缺少 link_id / external_userid / userid');
}
[$ownerAdminId, $deptId] = self::resolveOwner($userId);
$now = time();
$existing = Db::name('qywx_customer_acquisition_customer')
->where('link_id', $remoteLinkId)
->where('external_userid', $externalUserId)
->where('userid', $userId)
->find();
$snapshot = $customer['snapshot'] ?? $customer;
$incomingChatStatus = max(0, min(2, (int) ($customer['chat_status'] ?? 0)));
$data = [
'promotion_link_id' => (int) (Db::name('qywx_promotion_link')
->where('remote_link_id', $remoteLinkId)->value('id') ?? 0),
'owner_admin_id' => $ownerAdminId,
'dept_id' => $deptId,
'state' => mb_substr((string) ($customer['state'] ?? ''), 0, 255),
// 已确认发过消息后,列表同步返回的“未发/未知”不得把状态回退。
'chat_status' => $existing
? Db::raw('CASE WHEN chat_status = 1 OR ' . $incomingChatStatus . ' = 1 THEN 1 ELSE ' . $incomingChatStatus . ' END')
: $incomingChatStatus,
'last_sync_time' => $now,
'raw_snapshot' => self::encodeJson($snapshot),
'update_time' => $now,
];
if ($messageCountKnown) {
$remoteCount = max(0, (int) ($customer['recv_msg_cnt'] ?? 0));
// get_chat_info 返回累计值,必须 max/覆盖,绝不按回调次数累加。
$data['recv_msg_cnt'] = $existing
? Db::raw('GREATEST(recv_msg_cnt,' . $remoteCount . ')')
: $remoteCount;
$data['message_count_known'] = 1;
}
if ($incomingChatStatus === 1 || $messageCountKnown) {
$eventTime = max(0, (int) ($customer['event_time'] ?? $now));
$data['last_chat_time'] = $existing
? Db::raw('GREATEST(last_chat_time,' . $eventTime . ')')
: $eventTime;
}
if ($existing) {
Db::name('qywx_customer_acquisition_customer')->where('id', (int) $existing['id'])->update($data);
return 'updated';
}
$data += [
'link_id' => $remoteLinkId,
'external_userid' => $externalUserId,
'userid' => $userId,
'recv_msg_cnt' => $messageCountKnown ? max(0, (int) ($customer['recv_msg_cnt'] ?? 0)) : 0,
'message_count_known' => $messageCountKnown ? 1 : 0,
'first_acquired_time' => max(0, (int) ($customer['create_time'] ?? $customer['event_time'] ?? $now)),
'last_chat_time' => ($incomingChatStatus === 1 || $messageCountKnown)
? max(0, (int) ($customer['event_time'] ?? $now))
: 0,
'create_time' => $now,
];
Db::name('qywx_customer_acquisition_customer')->insert($data);
return 'created';
}
/** @return array{0:int,1:int} */
private static function resolveOwner(string $userId): array
{
$adminId = (int) (Db::name('admin')->where('work_wechat_userid', $userId)
->whereNull('delete_time')->value('id') ?? 0);
if ($adminId <= 0) {
return [0, 0];
}
$deptId = (int) (Db::name('admin_dept')->where('admin_id', $adminId)
->order('dept_id', 'asc')->value('dept_id') ?? 0);
return [$adminId, $deptId];
}
private static function encodeJson(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $json === false ? '{}' : $json;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
/** 企业微信获客助手链接校验。 */
class QywxCustomerAcquisitionLinkService
{
private const HOST = 'work.weixin.qq.com';
/**
* 只接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。
*/
public static function isAllowed(string $url, bool $allowEmpty = false): bool
{
$url = trim($url);
if ($url === '') {
return $allowEmpty;
}
$parts = parse_url($url);
if (!is_array($parts)
|| strtolower((string) ($parts['scheme'] ?? '')) !== 'https'
|| strtolower((string) ($parts['host'] ?? '')) !== self::HOST
|| isset($parts['user'])
|| isset($parts['pass'])
|| (isset($parts['port']) && (int) $parts['port'] !== 443)
) {
return false;
}
$path = (string) ($parts['path'] ?? '');
return preg_match('#^/ca/[A-Za-z0-9_-]+/?$#', $path) === 1;
}
public static function example(): string
{
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 企业微信推广凭证加密器:密钥仅来自服务器配置,密文可安全落库。 */
class QywxPromotionCredentialCipher
{
private const CIPHER = 'aes-256-gcm';
public static function encrypt(string $plain): string
{
if ($plain === '') {
return '';
}
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt($plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
if ($cipher === false) {
throw new RuntimeException('企业微信授权凭证加密失败');
}
return base64_encode(json_encode([
'v' => 1,
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'data' => base64_encode($cipher),
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}
public static function decrypt(string $payload): string
{
if ($payload === '') {
return '';
}
$json = base64_decode($payload, true);
$data = is_string($json) ? json_decode($json, true) : null;
if (!is_array($data)) {
throw new RuntimeException('企业微信授权凭证格式无效');
}
$iv = base64_decode((string) ($data['iv'] ?? ''), true);
$tag = base64_decode((string) ($data['tag'] ?? ''), true);
$cipher = base64_decode((string) ($data['data'] ?? ''), true);
if (!is_string($iv) || !is_string($tag) || !is_string($cipher)) {
throw new RuntimeException('企业微信授权凭证格式无效');
}
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false) {
throw new RuntimeException('企业微信授权凭证解密失败,请检查 CREDENTIAL_KEY 是否发生变更');
}
return $plain;
}
private static function key(): string
{
$material = trim((string) config('qywx_promotion.credential_key', ''));
if ($material === '') {
$material = trim((string) config('qywx_promotion.suite_secret', ''));
}
if ($material === '') {
throw new RuntimeException('未配置企业微信推广凭证加密密钥');
}
return hash('sha256', $material, true);
}
}
@@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use EasyWeChat\OpenWork\Application;
use EasyWeChat\OpenWork\Message;
use RuntimeException;
use think\facade\Db;
use think\facade\Log;
/** 企业微信服务商授权流程及授权企业凭证管理。 */
class QywxPromotionOpenWorkService
{
public static function configurationStatus(): array
{
$suiteId = self::configString('suite_id');
$required = ['provider_corp_id', 'suite_id', 'suite_secret', 'token', 'aes_key'];
$missing = [];
foreach ($required as $key) {
if (self::configString($key) === '') {
$missing[] = $key;
}
}
if (self::credentialMaterial() === '') {
$missing[] = 'credential_key';
}
$ticketAt = 0;
if ($suiteId !== '' && self::tableExists('qywx_promotion_provider_state')) {
$ticketAt = (int) (Db::name('qywx_promotion_provider_state')
->where('suite_id', $suiteId)
->value('ticket_received_at') ?? 0);
}
return [
'enabled' => (bool) config('qywx_promotion.enabled', false),
'configured' => $missing === [],
'ready' => (bool) config('qywx_promotion.enabled', false) && $missing === [] && $ticketAt > 0,
'missing' => $missing,
'suite_id_masked' => self::mask($suiteId),
'ticket_received_at' => $ticketAt,
];
}
public static function authorizationUrl(int $adminId, string $redirectUri): string
{
self::assertReady();
$redirectUri = self::configuredRedirectUri($redirectUri);
if ($redirectUri === '') {
throw new RuntimeException('无法生成企业微信授权回调地址');
}
$app = self::application();
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
$response = $app->getHttpClient()->request('GET', 'cgi-bin/service/get_pre_auth_code', [
'query' => ['suite_access_token' => $suiteAccessToken],
])->toArray(false);
$preAuthCode = trim((string) ($response['pre_auth_code'] ?? ''));
if ($preAuthCode === '') {
throw new RuntimeException('获取企业微信预授权码失败:' . (string) ($response['errmsg'] ?? '未知错误'));
}
return 'https://open.work.weixin.qq.com/3rdapp/install?' . http_build_query([
'suite_id' => self::configString('suite_id'),
'pre_auth_code' => $preAuthCode,
'redirect_uri' => $redirectUri,
'state' => self::makeState($adminId),
], '', '&', PHP_QUERY_RFC3986);
}
public static function consumeAuthorizationCallback(string $authCode, string $state): array
{
$adminId = self::verifyState($state);
if ($authCode === '') {
throw new RuntimeException('企业微信未返回临时授权码');
}
return self::exchangePermanentCode($authCode, $adminId);
}
public static function exchangePermanentCode(string $authCode, int $adminId = 0): array
{
self::assertConfigured();
$app = self::application();
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
$response = $app->getHttpClient()->request('POST', 'cgi-bin/service/get_permanent_code', [
'query' => ['suite_access_token' => $suiteAccessToken],
'json' => ['auth_code' => $authCode],
])->toArray(false);
$permanentCode = trim((string) ($response['permanent_code'] ?? ''));
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
$corpId = trim((string) ($corpInfo['corpid'] ?? ''));
if ($permanentCode === '' || $corpId === '') {
throw new RuntimeException('换取企业永久授权码失败:' . (string) ($response['errmsg'] ?? '返回信息不完整'));
}
return self::saveAuthorization($corpId, $permanentCode, $response, $adminId);
}
public static function verifyAccount(int $accountId): array
{
self::assertConfigured();
$row = Db::name('qywx_promotion_account')->where('id', $accountId)->whereNull('delete_time')->find();
if (!$row) {
throw new RuntimeException('授权企业不存在');
}
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) ($row['permanent_code_cipher'] ?? ''));
$authorization = self::application()->getAuthorization((string) $row['corp_id'], $permanentCode)->toArray();
self::saveAuthorization((string) $row['corp_id'], $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
return ['id' => $accountId, 'verified_at' => time()];
}
public static function serveProviderCallback()
{
self::assertConfigured();
$app = self::application();
$server = $app->getServer();
$server->handleAuthCreated(function (Message $message, \Closure $next) {
$authCode = trim((string) ($message['AuthCode'] ?? ''));
if ($authCode !== '') {
try {
self::exchangePermanentCode($authCode, 0);
} catch (\Throwable $e) {
Log::error('企微推广 create_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
}
}
return $next($message);
});
$server->handleAuthChanged(function (Message $message, \Closure $next) {
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
if ($corpId !== '') {
try {
self::refreshByCorpId($corpId);
} catch (\Throwable $e) {
Log::error('企微推广 change_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
}
}
return $next($message);
});
$server->handleAuthCancelled(function (Message $message, \Closure $next) {
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
if ($corpId !== '') {
Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->update([
'auth_status' => 0,
'update_time' => time(),
]);
}
return $next($message);
});
return $server->serve();
}
public static function configuredRedirectUri(string $fallback): string
{
return self::configString('redirect_uri') ?: trim($fallback);
}
public static function configuredAdminReturnUrl(string $fallback): string
{
return self::configString('admin_return_url') ?: trim($fallback);
}
public static function isAllowedPromotionUrl(string $url, bool $allowEmpty = false): bool
{
$url = trim($url);
if ($url === '') {
return $allowEmpty;
}
$parts = parse_url($url);
if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') {
return false;
}
$host = strtolower(trim((string) ($parts['host'] ?? '')));
if ($host === '') {
return false;
}
foreach ((array) config('qywx_promotion.allowed_link_hosts', []) as $allowed) {
$allowed = strtolower(trim((string) $allowed));
if ($allowed !== '' && ($host === $allowed || str_ends_with($host, '.' . $allowed))) {
return true;
}
}
return false;
}
private static function application(): Application
{
$app = new Application([
'corp_id' => self::configString('provider_corp_id'),
'provider_secret' => '',
'suite_id' => self::configString('suite_id'),
'suite_secret' => self::configString('suite_secret'),
'token' => self::configString('token'),
'aes_key' => self::configString('aes_key'),
]);
$app->setSuiteTicket(new QywxPromotionSuiteTicket(self::configString('suite_id')));
return $app;
}
private static function refreshByCorpId(string $corpId): void
{
$row = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->find();
if (!$row) {
return;
}
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) $row['permanent_code_cipher']);
$authorization = self::application()->getAuthorization($corpId, $permanentCode)->toArray();
self::saveAuthorization($corpId, $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
}
private static function saveAuthorization(string $corpId, string $permanentCode, array $response, int $adminId): array
{
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
$authInfo = is_array($response['auth_info'] ?? null) ? $response['auth_info'] : [];
$agents = is_array($authInfo['agent'] ?? null) ? $authInfo['agent'] : [];
$agent = is_array($agents[0] ?? null) ? $agents[0] : [];
$now = time();
$existing = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->find();
$ownerId = $adminId > 0 ? $adminId : (int) ($existing['owner_admin_id'] ?? 0);
$deptId = $ownerId > 0 ? self::primaryDeptId($ownerId) : (int) ($existing['dept_id'] ?? 0);
$data = [
'corp_name' => trim((string) ($corpInfo['corp_name'] ?? $existing['corp_name'] ?? $corpId)),
'permanent_code_cipher' => QywxPromotionCredentialCipher::encrypt($permanentCode),
'agent_id' => trim((string) ($agent['agentid'] ?? $existing['agent_id'] ?? '')),
// 授权响应可能包含 permanent_code;数据库元数据中只保留脱敏后的授权信息。
'auth_info_json' => json_encode(self::sanitizeAuthInfo($response), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'auth_status' => 1,
'owner_admin_id' => $ownerId,
'dept_id' => $deptId,
'authorized_at' => (int) ($existing['authorized_at'] ?? 0) ?: $now,
'last_refresh_at' => $now,
'update_time' => $now,
'delete_time' => null,
];
if ($existing) {
Db::name('qywx_promotion_account')->where('id', (int) $existing['id'])->update($data);
$id = (int) $existing['id'];
} else {
$data['corp_id'] = $corpId;
$data['create_time'] = $now;
$id = (int) Db::name('qywx_promotion_account')->insertGetId($data);
}
return ['id' => $id, 'corp_id' => $corpId, 'corp_name' => $data['corp_name']];
}
private static function makeState(int $adminId): string
{
$payload = self::base64UrlEncode(json_encode([
'a' => $adminId,
't' => time(),
'n' => bin2hex(random_bytes(8)),
], JSON_THROW_ON_ERROR));
$signature = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
return $payload . '.' . $signature;
}
private static function sanitizeAuthInfo(array $data): array
{
$sensitiveKeys = ['permanent_code', 'access_token', 'suite_ticket', 'suite_secret', 'provider_secret'];
foreach ($data as $key => $value) {
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
unset($data[$key]);
continue;
}
if (is_array($value)) {
$data[$key] = self::sanitizeAuthInfo($value);
}
}
return $data;
}
private static function verifyState(string $state): int
{
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
throw new RuntimeException('企业微信授权 state 无效');
}
[$payload, $signature] = $parts;
$expected = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
if (!hash_equals($expected, $signature)) {
throw new RuntimeException('企业微信授权 state 验证失败');
}
$data = json_decode(self::base64UrlDecode($payload), true);
if (!is_array($data) || time() - (int) ($data['t'] ?? 0) > 1800) {
throw new RuntimeException('企业微信授权请求已过期,请重新发起');
}
return max(0, (int) ($data['a'] ?? 0));
}
private static function assertReady(): void
{
$status = self::configurationStatus();
if (!$status['enabled']) {
throw new RuntimeException('企业微信推广授权尚未启用');
}
self::assertConfigured();
if (!$status['ticket_received_at']) {
throw new RuntimeException('尚未收到 suite_ticket,请先配置企业微信应用指令回调');
}
}
private static function assertConfigured(): void
{
$status = self::configurationStatus();
if (!$status['configured']) {
throw new RuntimeException('企业微信服务商配置不完整:' . implode(', ', $status['missing']));
}
}
private static function stateKey(): string
{
$key = self::credentialMaterial();
if ($key === '') {
throw new RuntimeException('未配置企业微信推广授权签名密钥');
}
return hash('sha256', 'qywx-promotion-state|' . $key);
}
private static function credentialMaterial(): string
{
return self::configString('credential_key') ?: self::configString('suite_secret');
}
private static function configString(string $key): string
{
return trim((string) config('qywx_promotion.' . $key, ''));
}
private static function primaryDeptId(int $adminId): int
{
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
}
private static function mask(string $value): string
{
$length = strlen($value);
if ($length <= 8) {
return $value === '' ? '' : str_repeat('*', $length);
}
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
}
private static function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $value): string
{
$value = strtr($value, '-_', '+/');
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
return (string) base64_decode($value, true);
}
private static function tableExists(string $table): bool
{
try {
return Db::query("SHOW TABLES LIKE '" . config('database.connections.mysql.prefix', '') . $table . "'") !== [];
} catch (\Throwable) {
return false;
}
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use think\facade\Db;
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
class QywxPromotionRedirectService
{
/** @return array{status:int,widget_config_json:?string}|null */
public static function publicPoolConfig(string $publicKey): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
return null;
}
$row = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->whereNull('delete_time')
->field('status,widget_config_json')
->find();
if (!$row) {
return null;
}
return [
'status' => (int) ($row['status'] ?? 0),
'widget_config_json' => isset($row['widget_config_json'])
? (string) $row['widget_config_json']
: null,
];
}
/** @return array{url:string,link_id:int}|null */
public static function pick(string $publicKey, array $context = []): ?array
{
if (!preg_match('/^[a-f0-9]{32}$/', $publicKey)) {
return null;
}
return Db::transaction(function () use ($publicKey, $context): ?array {
$pool = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->where('status', 1)
->whereNull('delete_time')
->lock(true)
->find();
if (!$pool) {
return null;
}
$now = time();
$today = date('Y-m-d', $now);
$links = Db::name('qywx_promotion_link')->alias('l')
->where('l.pool_id', (int) $pool['id'])
->where('l.status', 1)
->whereNull('l.delete_time')
->whereRaw('(l.active_start = 0 OR l.active_start <= ' . $now . ')')
->whereRaw('(l.active_end = 0 OR l.active_end >= ' . $now . ')')
->whereRaw("(l.daily_limit = 0 OR l.today_date IS NULL OR l.today_date <> '" . addslashes($today) . "' OR l.today_count < l.daily_limit)")
->field('l.*')
->lock(true)
->select()
->toArray();
// 兼容历史数据:旧的普通外链或客户群链接即使仍在库中,也不能参与分流。
$links = array_values(array_filter(
$links,
static fn (array $link): bool => QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''))
));
$selected = self::weightedRandom($links);
if (!$selected) {
$fallback = trim((string) ($pool['fallback_url'] ?? ''));
if (QywxCustomerAcquisitionLinkService::isAllowed($fallback, true) && $fallback !== '') {
return ['url' => $fallback, 'link_id' => 0];
}
return null;
}
$sameDay = (string) ($selected['today_date'] ?? '') === $today;
Db::name('qywx_promotion_link')->where('id', (int) $selected['id'])->update([
'click_count' => (int) ($selected['click_count'] ?? 0) + 1,
'today_count' => $sameDay ? (int) ($selected['today_count'] ?? 0) + 1 : 1,
'today_date' => $today,
'last_click_time' => $now,
'update_time' => $now,
]);
Db::name('qywx_promotion_pool')->where('id', (int) $pool['id'])->inc('click_count')->update([
'update_time' => $now,
]);
self::recordClick((int) $pool['id'], (int) $selected['id'], $context, $now);
return ['url' => (string) $selected['wecom_url'], 'link_id' => (int) $selected['id']];
});
}
public static function poolExists(string $publicKey): bool
{
return self::publicPoolConfig($publicKey) !== null;
}
/** @param array<int,array<string,mixed>> $links */
private static function weightedRandom(array $links): ?array
{
if ($links === []) {
return null;
}
$total = array_sum(array_map(static fn (array $row): int => max(1, (int) ($row['weight'] ?? 1)), $links));
$needle = random_int(1, max(1, $total));
foreach ($links as $link) {
$needle -= max(1, (int) ($link['weight'] ?? 1));
if ($needle <= 0) {
return $link;
}
}
return $links[array_key_last($links)];
}
private static function recordClick(int $poolId, int $linkId, array $context, int $now): void
{
$source = self::safeSource((string) ($context['source_url'] ?? ''));
$ip = trim((string) ($context['ip'] ?? ''));
$salt = (string) config('qywx_promotion.credential_key', '') ?: (string) config('qywx_promotion.suite_secret', '');
Db::name('qywx_promotion_click_log')->insert([
'pool_id' => $poolId,
'link_id' => $linkId,
'source_url' => $source,
'referer' => self::safeSource((string) ($context['referer'] ?? '')),
'user_agent' => mb_substr((string) ($context['user_agent'] ?? ''), 0, 500),
// 未配置服务端密钥时不落 IP,避免使用公开固定盐形成可枚举标识。
'ip_hash' => $ip === '' || $salt === '' ? '' : hash_hmac('sha256', $ip, $salt),
'click_date' => date('Y-m-d', $now),
'create_time' => $now,
]);
}
private static function safeSource(string $url): string
{
$parts = parse_url(trim($url));
if (!is_array($parts)) {
return '';
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
return '';
}
return mb_substr($scheme . '://' . $host . (string) ($parts['path'] ?? ''), 0, 500);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use EasyWeChat\Kernel\Exceptions\RuntimeException;
use EasyWeChat\OpenWork\Contracts\SuiteTicket;
use think\facade\Db;
/** 将企业微信每十分钟推送的 suite_ticket 加密持久化,避免进程/缓存重启后丢失。 */
class QywxPromotionSuiteTicket implements SuiteTicket
{
public function __construct(private readonly string $suiteId)
{
}
public function getTicket(): string
{
$cipher = (string) (Db::name('qywx_promotion_provider_state')
->where('suite_id', $this->suiteId)
->value('suite_ticket_cipher') ?? '');
if ($cipher === '') {
throw new RuntimeException('No suite_ticket found. 请先在企业微信服务商后台配置并验证应用指令回调。');
}
return QywxPromotionCredentialCipher::decrypt($cipher);
}
public function setTicket(string $ticket): static
{
$now = time();
$cipher = QywxPromotionCredentialCipher::encrypt($ticket);
$exists = Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->find();
if ($exists) {
Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->update([
'suite_ticket_cipher' => $cipher,
'ticket_received_at' => $now,
'update_time' => $now,
]);
} else {
Db::name('qywx_promotion_provider_state')->insert([
'suite_id' => $this->suiteId,
'suite_ticket_cipher' => $cipher,
'ticket_received_at' => $now,
'create_time' => $now,
'update_time' => $now,
]);
}
return $this;
}
}
@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use InvalidArgumentException;
/**
* 获客助手公开浮窗配置与脚本。
*
* 管理端输入严格校验;数据库中的未知版本或损坏配置一律回退为关闭状态。
*/
class QywxPromotionWidgetService
{
private const VERSION = 1;
private const TEMPLATES = ['bubble', 'pill', 'card', 'message', 'edge', 'bar'];
private const POSITIONS = ['bottom-right', 'bottom-left'];
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'v' => self::VERSION,
'enabled' => false,
'template' => 'bubble',
'position' => 'bottom-right',
'title' => '专属顾问在线',
'subtitle' => '点击添加企业微信,获取一对一服务',
'button_text' => '立即咨询',
'primary_color' => '#139A8C',
'bottom_offset' => 28,
'show_mobile' => true,
];
}
/**
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
public static function fromInput(mixed $input): array
{
if (!is_array($input)) {
throw new InvalidArgumentException('浮窗配置格式无效');
}
$defaults = self::defaults();
$version = self::integerValue(self::inputValue($input, 'v', self::VERSION), '配置版本');
if ($version !== self::VERSION) {
throw new InvalidArgumentException('不支持的浮窗配置版本');
}
$template = self::textValue(self::inputValue($input, 'template', $defaults['template']), '模板', 1, 20);
if (!in_array($template, self::TEMPLATES, true)) {
throw new InvalidArgumentException('浮窗模板无效');
}
$position = self::textValue(self::inputValue($input, 'position', $defaults['position']), '位置', 1, 20);
if (!in_array($position, self::POSITIONS, true)) {
throw new InvalidArgumentException('浮窗位置无效');
}
$color = strtoupper(trim(self::stringValue(
self::inputValue($input, 'primary_color', $defaults['primary_color']),
'主题色'
)));
if (preg_match('/^#[0-9A-F]{6}$/D', $color) !== 1) {
throw new InvalidArgumentException('主题色必须是 #RRGGBB 格式');
}
$bottomOffset = self::integerValue(
self::inputValue($input, 'bottom_offset', $defaults['bottom_offset']),
'底部距离'
);
if ($bottomOffset < 16 || $bottomOffset > 160) {
throw new InvalidArgumentException('底部距离必须在 16-160 之间');
}
return [
'v' => self::VERSION,
'enabled' => self::booleanValue(self::inputValue($input, 'enabled', $defaults['enabled']), '启用状态'),
'template' => $template,
'position' => $position,
'title' => self::textValue(self::inputValue($input, 'title', $defaults['title']), '标题', 1, 24),
'subtitle' => self::textValue(self::inputValue($input, 'subtitle', $defaults['subtitle']), '副标题', 0, 48),
'button_text' => self::textValue(
self::inputValue($input, 'button_text', $defaults['button_text']),
'按钮文案',
1,
12
),
'primary_color' => $color,
'bottom_offset' => $bottomOffset,
'show_mobile' => self::booleanValue(
self::inputValue($input, 'show_mobile', $defaults['show_mobile']),
'移动端展示状态'
),
];
}
/** @return array<string, mixed> */
public static function decode(mixed $stored): array
{
if (!is_string($stored) || trim($stored) === '') {
return self::defaults();
}
try {
$decoded = json_decode($stored, true, 16, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
return self::defaults();
}
foreach (array_keys(self::defaults()) as $key) {
if (!array_key_exists($key, $decoded)) {
return self::defaults();
}
}
return self::fromInput($decoded);
} catch (\Throwable) {
return self::defaults();
}
}
/** @param array<string, mixed> $config */
public static function encode(array $config): string
{
return self::jsonForScript(self::fromInput($config));
}
/**
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择。
*
* @param array<string, mixed> $config
*/
public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
{
$config = self::fromInput($config);
if (!$poolEnabled) {
$config['enabled'] = false;
}
$jsonKey = self::jsonForScript($key);
$jsonGo = self::jsonForScript($goUrl);
$jsonConfig = self::jsonForScript($config);
return <<<JS
(function(w,d){
'use strict';
var key={$jsonKey},goPath={$jsonGo},config={$jsonConfig},scriptNode=d.currentScript||null;
var go=resolveGoUrl(goPath);
var registry=w.WecomPromotion=w.WecomPromotion||{};
var previous=registry[key];
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
previous.destroy();
}
var root=null,mediaQuery=null,readyHandler=null,destroyed=false,manuallyHidden=false,api=null;
var rootId='wecom-promotion-widget-'+key;
var selector='[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]';
function findScriptNode(){
if(scriptNode&&scriptNode.src){return scriptNode;}
var scripts=d.getElementsByTagName('script');
var marker='/api/qywx-promotion/js/'+key;
for(var index=scripts.length-1;index>=0;index--){
if((scripts[index].src||'').indexOf(marker)!==-1){scriptNode=scripts[index];return scriptNode;}
}
return null;
}
function resolveGoUrl(value){
if(/^https?:\/\//i.test(value)){return value;}
var node=findScriptNode();
if(node&&node.src&&typeof w.URL==='function'){
try{return new w.URL(value,node.src).href;}catch(error){}
}
return value;
}
function sourceUrl(){
var location=w.location||{};
var origin=location.origin||((location.protocol&&location.host)?location.protocol+'//'+location.host:'');
return origin+(location.pathname||'/');
}
function openPromotion(){
w.location.assign(go+'?from='+encodeURIComponent(sourceUrl()));
}
function handleDocumentClick(event){
var path=typeof event.composedPath==='function'?event.composedPath():[];
var node=null;
for(var index=0;index<path.length;index++){
var candidate=path[index];
if(candidate&&candidate.nodeType===1&&candidate.matches&&candidate.matches(selector)){node=candidate;break;}
}
var target=event.target;
if(!node){node=target&&target.closest?target.closest(selector):null;}
if(!node){return;}
event.preventDefault();
event.stopPropagation();
openPromotion();
}
function isMobileHidden(){
return config.show_mobile===false&&mediaQuery&&mediaQuery.matches;
}
function applyVisibility(){
if(root){root.hidden=manuallyHidden||isMobileHidden();}
}
function handleViewportChange(){
applyVisibility();
}
function appendText(parent,tag,className,value){
var node=d.createElement(tag);
node.className=className;
node.textContent=value;
parent.appendChild(node);
return node;
}
function mount(){
if(destroyed||root||!config.enabled||!d.body){return;}
var stale=d.getElementById(rootId);
if(stale&&stale.parentNode){stale.parentNode.removeChild(stale);}
root=d.createElement('div');
root.id=rootId;
root.className='wcp-host wcp-host-'+config.position+' wcp-host-'+config.template;
root.setAttribute('data-wecom-promotion-widget',key);
var surface=root.attachShadow?root.attachShadow({mode:'open'}):root;
var style=d.createElement('style');
var nonceNode=findScriptNode();
var nonce=nonceNode?(nonceNode.nonce||nonceNode.getAttribute('nonce')||''):'';
if(nonce){style.setAttribute('nonce',nonce);}
var hostRules='position:fixed;z-index:2147483000;right:20px;bottom:calc('+config.bottom_offset+'px + env(safe-area-inset-bottom, 0px));max-width:calc(100vw - 32px);pointer-events:none;--wcp-primary:'+config.primary_color+';font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#fff;line-height:1.4;-webkit-font-smoothing:antialiased';
style.textContent=':host{'+hostRules+'}.wcp-host{'+hostRules+'}' +
':host(.wcp-host-bottom-left){right:auto;left:20px}.wcp-host-bottom-left{right:auto;left:20px}' +
':host(.wcp-host-edge.wcp-host-bottom-right){right:0}.wcp-host-edge.wcp-host-bottom-right{right:0}' +
':host(.wcp-host-edge.wcp-host-bottom-left){right:auto;left:0}.wcp-host-edge.wcp-host-bottom-left{right:auto;left:0}' +
':host([hidden]){display:none!important}.wcp-host[hidden]{display:none!important}.wcp-root,.wcp-root *{box-sizing:border-box}.wcp-root{pointer-events:none}' +
'.wcp-button{pointer-events:auto;position:relative;display:flex;align-items:center;gap:12px;margin:0;border:0;cursor:pointer;color:#fff;background:var(--wcp-primary);font:inherit;text-align:left;box-shadow:0 14px 38px rgba(18,48,46,.24);transition:transform .2s ease,box-shadow .2s ease;appearance:none;-webkit-appearance:none}' +
'.wcp-button:hover{transform:translateY(-2px);box-shadow:0 18px 44px rgba(18,48,46,.3)}.wcp-button:active{transform:translateY(0)}.wcp-button:focus-visible{outline:3px solid rgba(255,255,255,.96);outline-offset:3px}' +
'.wcp-icon{display:flex;flex:0 0 auto;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.18);font-size:17px;font-weight:800}' +
'.wcp-copy{display:flex;min-width:0;flex-direction:column}.wcp-title{font-size:15px;font-weight:750;line-height:1.25}.wcp-subtitle{margin-top:2px;max-width:240px;font-size:12px;line-height:1.4;opacity:.86}' +
'.wcp-cta{flex:0 0 auto;padding:7px 11px;border-radius:999px;background:#fff;color:var(--wcp-primary);font-size:12px;font-weight:750;white-space:nowrap}' +
'.wcp-bubble .wcp-button{width:66px;height:66px;justify-content:center;padding:0;border-radius:50%}.wcp-bubble .wcp-icon{width:46px;height:46px;font-size:19px}.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{position:absolute;right:76px;visibility:hidden;opacity:0;transform:translateX(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:none}' +
'.wcp-bottom-left.wcp-bubble .wcp-copy,.wcp-bottom-left.wcp-bubble .wcp-cta{right:auto;left:76px}.wcp-bubble .wcp-copy{bottom:27px;width:220px;padding:11px 13px;border-radius:12px;background:#173f3b;box-shadow:0 12px 30px rgba(0,0,0,.2)}.wcp-bubble .wcp-cta{bottom:-1px;padding:5px 10px}' +
'.wcp-bubble .wcp-button:hover .wcp-copy,.wcp-bubble .wcp-button:hover .wcp-cta,.wcp-bubble .wcp-button:focus-visible .wcp-copy,.wcp-bubble .wcp-button:focus-visible .wcp-cta{visibility:visible;opacity:1;transform:translateX(0)}' +
'.wcp-pill .wcp-button{min-height:58px;padding:9px 12px;border-radius:999px}.wcp-pill .wcp-subtitle{display:none}' +
'.wcp-card .wcp-button{width:min(340px,calc(100vw - 40px));padding:15px;border-radius:18px}.wcp-card .wcp-icon{width:46px;height:46px}.wcp-card .wcp-copy{flex:1}.wcp-card .wcp-cta{border-radius:10px}' +
'.wcp-message .wcp-button{width:min(330px,calc(100vw - 40px));padding:13px 14px;border-radius:18px 18px 4px 18px}.wcp-bottom-left.wcp-message .wcp-button{border-radius:18px 18px 18px 4px}.wcp-message .wcp-copy{flex:1}.wcp-message .wcp-cta{padding:6px 9px}' +
'.wcp-edge .wcp-button{min-height:62px;max-width:270px;padding:10px 15px;border-radius:16px 0 0 16px}.wcp-bottom-left.wcp-edge .wcp-button{border-radius:0 16px 16px 0}.wcp-edge .wcp-subtitle{display:none}.wcp-edge .wcp-cta{padding:6px 9px}' +
'.wcp-bar .wcp-button{width:min(420px,calc(100vw - 40px));padding:11px 14px;border-radius:12px}.wcp-bar .wcp-copy{flex:1}.wcp-bar .wcp-icon{width:34px;height:34px}.wcp-bar .wcp-subtitle{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'@media(max-width:767px){.wcp-host{max-width:calc(100vw - 24px)}.wcp-card .wcp-button,.wcp-message .wcp-button,.wcp-bar .wcp-button{width:calc(100vw - 40px)}.wcp-subtitle{max-width:180px}.wcp-card .wcp-cta,.wcp-message .wcp-cta{display:none}}' +
'@media(prefers-reduced-motion:reduce){.wcp-button,.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{transition:none!important}}';
surface.appendChild(style);
var container=d.createElement('div');
container.className='wcp-root wcp-'+config.template+' wcp-'+config.position;
var button=d.createElement('button');
button.type='button';
button.className='wcp-button';
button.setAttribute('aria-label',config.title+''+config.button_text);
appendText(button,'span','wcp-icon','企');
var copy=d.createElement('span');
copy.className='wcp-copy';
appendText(copy,'strong','wcp-title',config.title);
if(config.subtitle!==''){appendText(copy,'span','wcp-subtitle',config.subtitle);}
button.appendChild(copy);
appendText(button,'span','wcp-cta',config.button_text);
button.addEventListener('click',function(event){
event.preventDefault();
event.stopPropagation();
openPromotion();
});
container.appendChild(button);
surface.appendChild(container);
d.body.appendChild(root);
if(config.show_mobile===false&&typeof w.matchMedia==='function'){
mediaQuery=w.matchMedia('(max-width: 767px)');
if(mediaQuery.addEventListener){mediaQuery.addEventListener('change',handleViewportChange);}
else if(mediaQuery.addListener){mediaQuery.addListener(handleViewportChange);}
}
applyVisibility();
}
function show(){
if(destroyed||!config.enabled){return;}
manuallyHidden=false;
if(root){applyVisibility();return;}
if(d.body){mount();}
else if(!readyHandler){
readyHandler=function(){readyHandler=null;mount();};
d.addEventListener('DOMContentLoaded',readyHandler,{once:true});
}
}
function hide(){
manuallyHidden=true;
applyVisibility();
}
function destroy(){
if(destroyed){return;}
destroyed=true;
d.removeEventListener('click',handleDocumentClick,true);
if(readyHandler){d.removeEventListener('DOMContentLoaded',readyHandler);readyHandler=null;}
if(mediaQuery){
if(mediaQuery.removeEventListener){mediaQuery.removeEventListener('change',handleViewportChange);}
else if(mediaQuery.removeListener){mediaQuery.removeListener(handleViewportChange);}
mediaQuery=null;
}
if(root&&root.parentNode){root.parentNode.removeChild(root);}
root=null;
if(registry[key]===api){delete registry[key];}
}
d.addEventListener('click',handleDocumentClick,true);
api={open:openPromotion,show:show,hide:hide,destroy:destroy,config:config,__widgetVersion:1};
registry[key]=api;
if(config.enabled){show();}
})(window,document);
JS;
}
private static function booleanValue(mixed $value, string $label): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === '1') {
return true;
}
if ($value === 0 || $value === '0') {
return false;
}
throw new InvalidArgumentException($label . '必须是布尔值');
}
private static function inputValue(array $input, string $key, mixed $default): mixed
{
return array_key_exists($key, $input) ? $input[$key] : $default;
}
private static function integerValue(mixed $value, string $label): int
{
if (is_int($value)) {
return $value;
}
if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
return (int) $value;
}
throw new InvalidArgumentException($label . '必须是整数');
}
private static function stringValue(mixed $value, string $label): string
{
if (!is_string($value)) {
throw new InvalidArgumentException($label . '必须是字符串');
}
return $value;
}
private static function textValue(mixed $value, string $label, int $min, int $max): string
{
$value = self::stringValue($value, $label);
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? '';
$length = mb_strlen($value);
if ($length < $min || $length > $max) {
throw new InvalidArgumentException(sprintf('%s长度必须在 %d-%d 个字符之间', $label, $min, $max));
}
return $value;
}
private static function jsonForScript(mixed $value): string
{
return json_encode(
$value,
JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_HEX_QUOT
| JSON_THROW_ON_ERROR
);
}
}