Files
zyt/server/app/common/service/qywx/QywxPromotionContactApiService.php
2026-08-31 15:17:34 +08:00

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;
}
}