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} */ 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,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 */ public function getLink(string $linkId): array { $this->assertLinkId($linkId); return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/get', ['link_id' => $linkId]); } /** @return array */ public function createLink(array $payload): array { return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/create_link', $payload); } /** @return array */ 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>,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 */ 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 */ 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); } throw new RuntimeException(sprintf( '企业微信获客助手接口失败[%d]:%s', $errcode, trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误' )); } 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 不正确'); } } }