257 lines
9.4 KiB
PHP
257 lines
9.4 KiB
PHP
<?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);
|
||
}
|
||
$errorMessage = trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误';
|
||
if ($errcode === 60111) {
|
||
throw new RuntimeException(
|
||
'所选医助的企业微信 userid 不存在,或不在获客助手可调用应用的可见范围;'
|
||
. '请检查后台账号绑定和企业微信应用可见范围。企业微信返回:' . $errorMessage
|
||
);
|
||
}
|
||
|
||
throw new RuntimeException(sprintf(
|
||
'企业微信获客助手接口失败[%d]:%s',
|
||
$errcode,
|
||
$errorMessage
|
||
));
|
||
}
|
||
|
||
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 不正确');
|
||
}
|
||
}
|
||
}
|