This commit is contained in:
Your Name
2026-08-06 10:57:35 +08:00
parent 2c0b9c5afa
commit 079e50006d
400 changed files with 3046 additions and 714 deletions
@@ -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';
}
}
@@ -6,7 +6,7 @@ namespace app\common\service\qywx;
use think\facade\Db;
/** 公开推广链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
class QywxPromotionRedirectService
{
/** @return array{url:string,link_id:int}|null */
@@ -30,11 +30,9 @@ class QywxPromotionRedirectService
$now = time();
$today = date('Y-m-d', $now);
$links = Db::name('qywx_promotion_link')->alias('l')
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
->where('l.pool_id', (int) $pool['id'])
->where('l.status', 1)
->whereNull('l.delete_time')
->whereRaw('(l.account_id = 0 OR a.auth_status = 1)')
->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)")
@@ -43,10 +41,16 @@ class QywxPromotionRedirectService
->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 (QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true) && $fallback !== '') {
if (QywxCustomerAcquisitionLinkService::isAllowed($fallback, true) && $fallback !== '') {
return ['url' => $fallback, 'link_id' => 0];
}