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