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