285 lines
13 KiB
PHP
285 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\qywx;
|
|
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\GuzzleException;
|
|
use GuzzleHttp\Psr7\Utils;
|
|
use RuntimeException;
|
|
use think\facade\Cache;
|
|
|
|
/** 客户联系可调用自建应用;不使用对外收款应用 Secret。 */
|
|
class QywxPromotionContactApiService
|
|
{
|
|
private const PROMOTION_TAG_GROUP = '推广渠道';
|
|
|
|
private Client $client;
|
|
private string $corpId;
|
|
private string $secret;
|
|
private $tokenResolver;
|
|
|
|
public function __construct(?Client $client = null, ?callable $tokenResolver = null)
|
|
{
|
|
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''))
|
|
?: trim((string) config('pay.wechat_work.corp_id', ''));
|
|
// 获客回调的 WelcomeCode 应交由相同的可调用应用发送。专用覆盖仅用于明确配置的同应用。
|
|
$this->secret = trim((string) config('qywx_promotion_automation.contact_secret', ''))
|
|
?: (trim((string) config('qywx_customer_acquisition.secret', ''))
|
|
?: trim((string) config('pay.wechat_work.customer_contact_secret', '')));
|
|
$caPath = dirname(__DIR__, 4) . '/cacert.pem';
|
|
$this->client = $client ?? new Client([
|
|
'base_uri' => 'https://qyapi.weixin.qq.com/',
|
|
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
|
|
'verify' => is_file($caPath) ? $caPath : true, 'allow_redirects' => false,
|
|
'headers' => ['Accept' => 'application/json'],
|
|
]);
|
|
$this->tokenResolver = $tokenResolver;
|
|
}
|
|
|
|
public function credentialFingerprint(): string
|
|
{
|
|
return hash('sha256', $this->corpId . '|' . $this->secret);
|
|
}
|
|
|
|
public function tagOptions(): array
|
|
{
|
|
$result = $this->request('POST', 'externalcontact/get_corp_tag_list', []);
|
|
$groups = [];
|
|
foreach ((array) ($result['tag_group'] ?? []) as $group) {
|
|
if (!is_array($group) || !empty($group['deleted'])) {
|
|
continue;
|
|
}
|
|
$tags = [];
|
|
foreach ((array) ($group['tag'] ?? []) as $tag) {
|
|
if (is_array($tag) && empty($tag['deleted']) && !empty($tag['id'])) {
|
|
$tags[] = ['id' => (string) $tag['id'], 'name' => (string) ($tag['name'] ?? '')];
|
|
}
|
|
}
|
|
$groups[] = ['group_id' => (string) ($group['group_id'] ?? ''),
|
|
'group_name' => (string) ($group['group_name'] ?? ''), 'tag' => $tags];
|
|
}
|
|
return ['tag_groups' => $groups];
|
|
}
|
|
|
|
/**
|
|
* 自定义企业客户标签:只写固定分组,先查重;创建结果不确定时只读回,不再次创建。
|
|
* @return array{tag:array{id:string,name:string},group_id:string,group_name:string,reused:bool}
|
|
* @see https://developer.work.weixin.qq.com/document/path/92117
|
|
*/
|
|
public function createTag(string $name): array
|
|
{
|
|
if (!mb_check_encoding($name, 'UTF-8') || preg_match('/[\p{C}\x{2028}\x{2029}]/u', $name)) {
|
|
throw new RuntimeException('标签名称不能包含控制字符或不可见格式字符');
|
|
}
|
|
$name = preg_replace('/^\p{Z}+|\p{Z}+$/u', '', trim($name)) ?? '';
|
|
if ($name === '' || mb_strlen($name, 'UTF-8') > 30) {
|
|
throw new RuntimeException('标签名称须为 1-30 个字符');
|
|
}
|
|
$groups = $this->tagOptions()['tag_groups'];
|
|
$existing = $this->findPromotionTag($groups, $name, true);
|
|
if ($existing !== null) {
|
|
return $existing;
|
|
}
|
|
$body = ['tag' => [['name' => $name]]];
|
|
foreach ($groups as $group) {
|
|
if (($group['group_name'] ?? '') === self::PROMOTION_TAG_GROUP && ($group['group_id'] ?? '') !== '') {
|
|
$body['group_id'] = $group['group_id'];
|
|
break;
|
|
}
|
|
}
|
|
if (!isset($body['group_id'])) {
|
|
// 官方保证同名分组存在时向该组添加,不额外创建同名分组;空分组不受支持。
|
|
$body['group_name'] = self::PROMOTION_TAG_GROUP;
|
|
}
|
|
$failure = null;
|
|
try {
|
|
$response = $this->request('POST', 'externalcontact/add_corp_tag', $body, true);
|
|
$created = $this->findPromotionTag([(array) ($response['tag_group'] ?? [])], $name, false);
|
|
if ($created !== null) {
|
|
return $created;
|
|
}
|
|
} catch (QywxPromotionContactApiException $error) {
|
|
$failure = $error;
|
|
}
|
|
// 同名并发、上游缺失返回ID或网络中断,均只读回一次。永不构造本地伪标签ID。
|
|
try {
|
|
$confirmed = $this->findPromotionTag($this->tagOptions()['tag_groups'], $name, true);
|
|
if ($confirmed !== null) {
|
|
return $confirmed;
|
|
}
|
|
} catch (\Throwable) {
|
|
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
|
|
}
|
|
if ($failure !== null && !$failure->uncertain) {
|
|
throw new RuntimeException('企业微信标签创建失败[' . $failure->getCode() . '],请检查客户联系应用权限或标签额度', $failure->getCode());
|
|
}
|
|
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
|
|
}
|
|
|
|
private function findPromotionTag(array $groups, string $name, bool $reused): ?array
|
|
{
|
|
foreach ($groups as $group) {
|
|
if (!is_array($group) || !empty($group['deleted'])
|
|
|| ($group['group_name'] ?? '') !== self::PROMOTION_TAG_GROUP
|
|
|| !is_string($group['group_id'] ?? null) || $group['group_id'] === '') {
|
|
continue;
|
|
}
|
|
foreach ((array) ($group['tag'] ?? []) as $tag) {
|
|
if (is_array($tag) && empty($tag['deleted']) && ($tag['name'] ?? '') === $name
|
|
&& is_string($tag['id'] ?? null) && $tag['id'] !== '') {
|
|
return ['tag' => ['id' => $tag['id'], 'name' => $name],
|
|
'group_id' => $group['group_id'], 'group_name' => self::PROMOTION_TAG_GROUP, 'reused' => $reused];
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function getExternalContact(string $externalUserId, string $cursor = ''): array
|
|
{
|
|
$query = ['external_userid' => $externalUserId];
|
|
if ($cursor !== '') {
|
|
$query['cursor'] = $cursor;
|
|
}
|
|
return $this->request('GET', 'externalcontact/get', $query);
|
|
}
|
|
|
|
public function getUser(string $userId): array
|
|
{
|
|
return $this->request('GET', 'user/get', ['userid' => $userId]);
|
|
}
|
|
|
|
public function markTags(string $userId, string $externalUserId, array $tagIds): void
|
|
{
|
|
if ($tagIds === []) {
|
|
throw new RuntimeException('企业标签不能为空');
|
|
}
|
|
$this->request('POST', 'externalcontact/mark_tag', [
|
|
'userid' => $userId, 'external_userid' => $externalUserId,
|
|
'add_tag' => array_values(array_unique($tagIds)),
|
|
]);
|
|
}
|
|
|
|
public function remark(string $userId, string $externalUserId, array $fields): void
|
|
{
|
|
$body = ['userid' => $userId, 'external_userid' => $externalUserId];
|
|
foreach (['remark' => 20, 'description' => 150] as $field => $limit) {
|
|
if (isset($fields[$field]) && $fields[$field] !== '') {
|
|
if (!is_string($fields[$field]) || mb_strlen($fields[$field]) > $limit) {
|
|
throw new RuntimeException('客户备注或描述长度不正确');
|
|
}
|
|
$body[$field] = $fields[$field];
|
|
}
|
|
}
|
|
if (count($body) === 2) {
|
|
throw new RuntimeException('没有启用需要修改的备注字段');
|
|
}
|
|
$this->request('POST', 'externalcontact/remark', $body);
|
|
}
|
|
|
|
public function sendWelcome(string $code, string $text, array $attachments): void
|
|
{
|
|
if ($code === '' || strlen($code) > 1024 || strlen($text) > 4000
|
|
|| count($attachments) > 9 || ($text === '' && $attachments === [])) {
|
|
throw new RuntimeException('欢迎语内容或欢迎码格式不正确');
|
|
}
|
|
$body = ['welcome_code' => $code];
|
|
if ($text !== '') {
|
|
$body['text'] = ['content' => $text];
|
|
}
|
|
if ($attachments !== []) {
|
|
$body['attachments'] = array_values($attachments);
|
|
}
|
|
$this->request('POST', 'externalcontact/send_welcome_msg', $body, true);
|
|
}
|
|
|
|
/** 仅由私有素材服务传入受控文件流,不接受 URL 或请求提供的任意路径。 */
|
|
public function uploadMedia($stream, string $type, string $filename): array
|
|
{
|
|
if (!is_resource($stream) || !in_array($type, ['image', 'video', 'file'], true)) {
|
|
throw new RuntimeException('临时素材类型或文件流不正确');
|
|
}
|
|
return $this->request('POST', 'media/upload', ['type' => $type], false, [
|
|
'multipart' => [['name' => 'media', 'contents' => Utils::streamFor($stream), 'filename' => $filename]],
|
|
'timeout' => 45,
|
|
]);
|
|
}
|
|
|
|
/** 仅明确的 token 失效响应允许重取一次;欢迎语/标签创建的网络异常不能直接重发。 */
|
|
private function request(string $method, string $path, array $body, bool $nonIdempotent = false, array $extra = [], bool $retried = false): array
|
|
{
|
|
$token = $this->accessToken();
|
|
$options = $extra + ['query' => ['access_token' => $token]];
|
|
if ($method === 'GET' || isset($extra['multipart'])) {
|
|
$options['query'] += $body;
|
|
} else {
|
|
$options['json'] = $body === [] ? (object) [] : $body;
|
|
}
|
|
try {
|
|
$response = $this->client->request($method, 'cgi-bin/' . $path, $options);
|
|
} catch (GuzzleException) {
|
|
throw new QywxPromotionContactApiException('企业微信客户联系接口网络异常', 0, $nonIdempotent);
|
|
}
|
|
$decoded = json_decode((string) $response->getBody(), true);
|
|
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300
|
|
|| !is_array($decoded) || !array_key_exists('errcode', $decoded)) {
|
|
// media/upload 成功返回可没有 errcode。
|
|
if ($path === 'media/upload' && $response->getStatusCode() === 200 && is_array($decoded) && !empty($decoded['media_id'])) {
|
|
return $decoded;
|
|
}
|
|
throw new QywxPromotionContactApiException('企业微信客户联系接口响应无法确认', 0, $nonIdempotent);
|
|
}
|
|
$code = (int) $decoded['errcode'];
|
|
if ($code === 0) {
|
|
return $decoded;
|
|
}
|
|
if (!$retried && in_array($code, [40001, 40014, 42001], true)) {
|
|
if ($this->tokenResolver === null) {
|
|
Cache::delete('qywx_promotion_contact_token:' . $this->credentialFingerprint());
|
|
}
|
|
if (isset($extra['multipart'])) {
|
|
$extra['multipart'][0]['contents']->rewind();
|
|
}
|
|
return $this->request($method, $path, $body, $nonIdempotent, $extra, true);
|
|
}
|
|
// 不回显上游 errmsg;部分错误会包含请求参数与一次性凭证。
|
|
throw new QywxPromotionContactApiException('企业微信客户联系接口失败[' . $code . ']', $code);
|
|
}
|
|
|
|
private function accessToken(): string
|
|
{
|
|
if ($this->tokenResolver !== null) {
|
|
$token = (string) ($this->tokenResolver)();
|
|
if ($token === '') {
|
|
throw new RuntimeException('客户联系托管 token 为空');
|
|
}
|
|
return $token;
|
|
}
|
|
if ($this->corpId === '' || $this->secret === '') {
|
|
throw new RuntimeException('请配置客户联系可调用自建应用的 corp_id 和 Secret');
|
|
}
|
|
$key = 'qywx_promotion_contact_token:' . $this->credentialFingerprint();
|
|
$token = (string) Cache::get($key, '');
|
|
if ($token !== '') {
|
|
return $token;
|
|
}
|
|
try {
|
|
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
|
|
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
|
|
]);
|
|
} catch (GuzzleException) {
|
|
throw new QywxPromotionContactApiException('获取客户联系 token 网络异常');
|
|
}
|
|
$data = json_decode((string) $response->getBody(), true);
|
|
if ($response->getStatusCode() !== 200 || !is_array($data)
|
|
|| (int) ($data['errcode'] ?? 0) !== 0 || empty($data['access_token'])) {
|
|
throw new QywxPromotionContactApiException('获取客户联系 token 失败', (int) ($data['errcode'] ?? 0));
|
|
}
|
|
$token = (string) $data['access_token'];
|
|
Cache::set($key, $token, max(60, (int) ($data['expires_in'] ?? 7200) - 300));
|
|
return $token;
|
|
}
|
|
}
|