更新
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user