Files
zyt/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php
T
2026-08-06 10:57:35 +08:00

250 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 不正确');
}
}
}