first commit
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user