corpId = config('pay.wechat_work.corp_id'); $customerSecret = (string) config('pay.wechat_work.customer_contact_secret', ''); $payContactSecret = (string) config('pay.wechat_work.external_pay_secret', ''); if ($secretType === 'external_pay') { $this->secret = $payContactSecret; $this->secretLabel = 'external_pay'; } elseif ($secretType === 'customer_contact') { if ($customerSecret !== '') { $this->secret = $customerSecret; $this->secretLabel = 'customer_contact'; } else { $this->secret = $payContactSecret; $this->secretLabel = 'external_pay(fallback)'; } } else { // 历史调用:本类绝大多数方法走的是「客户联系」API,因此默认优先取客户联系 Secret, // 未配置时回退到对外收款 Secret,保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。 if ($customerSecret !== '') { $this->secret = $customerSecret; $this->secretLabel = 'customer_contact'; } else { $this->secret = $payContactSecret; $this->secretLabel = 'external_pay(fallback)'; } } } /** 当前选用的 secret 语义标签 */ public function getSecretLabel(): string { return $this->secretLabel; } /** 对 secret 做脱敏展示,用于错误日志定位 */ private function maskSecret(): string { $s = (string) $this->secret; if ($s === '') { return '(empty)'; } $len = strlen($s); if ($len <= 8) { return str_repeat('*', $len); } return substr($s, 0, 4) . str_repeat('*', $len - 8) . substr($s, -4) . '(len=' . $len . ')'; } /** * @notes 获取access_token * @see https://developer.work.weixin.qq.com/document/path/91039 */ private function getAccessToken(): string { if ($this->accessToken) { return $this->accessToken; } // 缓存 key 必须按 corp+secret 分桶,否则多应用(customer_contact / external_pay)之间会相互覆盖, // 读到别家应用的 token 去调本家接口 → 偶发 40001 / 40014 / 60011。 $cacheKey = 'qywx_access_token:' . md5($this->corpId . '|' . $this->secret); $token = Cache::get($cacheKey); if ($token) { $this->accessToken = $token; return $token; } // 请求新token $url = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken'; $params = [ 'corpid' => $this->corpId, 'corpsecret' => $this->secret, ]; $response = $this->httpGet($url, $params); if (isset($response['access_token'])) { $token = $response['access_token']; $expiresIn = $response['expires_in'] ?? 7200; // 缓存token,提前5分钟过期 Cache::set($cacheKey, $token, $expiresIn - 300); $this->accessToken = $token; return $token; } $errcode = $response['errcode'] ?? '?'; $errmsg = $response['errmsg'] ?? '未知错误'; Log::error('企业微信 gettoken 失败', [ 'errcode' => $errcode, 'errmsg' => $errmsg, 'secret_label' => $this->secretLabel, 'secret_masked' => $this->maskSecret(), 'corp_id' => $this->corpId, ]); throw new \Exception(sprintf( '获取access_token失败[%s]: %s (secret_label=%s, secret=%s, corp_id=%s)', (string) $errcode, (string) $errmsg, $this->secretLabel, $this->maskSecret(), (string) $this->corpId )); } /** * @notes 获取部门成员列表 * @see https://developer.work.weixin.qq.com/document/path/90200 */ public function getDepartmentUserList(int $departmentId = 1, bool $fetchChild = true): array { $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/user/list'; $params = [ 'access_token' => $accessToken, 'department_id' => $departmentId, 'fetch_child' => $fetchChild ? 1 : 0, ]; $response = $this->httpGet($url, $params); Log::info('企业微信-获取部门成员列表响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); if (isset($response['userlist'])) { return $response['userlist']; } Log::error('获取部门成员列表失败: ' . json_encode($response)); return []; } /** * @notes 获取成员的客户列表 * @see https://developer.work.weixin.qq.com/document/path/92113 * @param string $userId 成员ID * @return array|false 返回客户列表数组,权限错误返回false */ public function getExternalContactList(string $userId) { $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list'; $params = [ 'access_token' => $accessToken, 'userid' => $userId, ]; $response = $this->httpGet($url, $params); Log::info('企业微信-获取成员客户列表响应 userId=' . $userId . ': ' . json_encode($response, JSON_UNESCAPED_UNICODE)); // 检查是否是权限错误 if (isset($response['errcode']) && $response['errcode'] == 48002) { Log::error('API权限不足(48002): 请在企业微信管理后台开启"客户联系"权限'); return false; } if (isset($response['external_userid'])) { return $response['external_userid']; } // 如果没有客户,返回空数组(不记录错误) if (isset($response['errcode']) && $response['errcode'] == 0) { return []; } Log::error('获取客户列表失败: ' . json_encode($response)); return []; } /** * 批量获取指定成员添加的客户详情(分页,单次最多 100 条) * * @see https://developer.work.weixin.qq.com/document/path/92994 * @return array 含 errcode、external_contact_list、next_cursor 等 */ public function batchGetExternalContactByUser(string $userId, string $cursor = '', int $limit = 100): array { $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/batch/get_by_user?access_token=' . urlencode($accessToken); $body = [ 'userid_list' => [$userId], 'cursor' => $cursor, 'limit' => min(100, max(1, $limit)), ]; return $this->httpPostJson($url, $body); } /** * @notes 获取客户详情 * @see https://developer.work.weixin.qq.com/document/path/92114 * * @param string $externalUserId * @param array|null $errorOut 引用出参:当返回空数组时携带 errcode/errmsg/raw,便于上游分流处理 * @return array */ public function getExternalContactDetail(string $externalUserId, ?array &$errorOut = null): array { $errorOut = null; $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get'; $params = [ 'access_token' => $accessToken, 'external_userid' => $externalUserId, ]; $response = $this->httpGet($url, $params); if (isset($response['external_contact'])) { return $response; } $errcode = isset($response['errcode']) ? (int) $response['errcode'] : null; $errmsg = (string) ($response['errmsg'] ?? ''); $errorOut = [ 'errcode' => $errcode, 'errmsg' => $errmsg, 'raw' => $response, ]; // 84061: 客户尚未通过验证 / 41068: 不合法的成员/外部联系人 / 60011/60111: 没有权限访问 // 这几类是「业务上预期会发生」的,降级为 warning,避免污染 error 日志。 // 关键字段直接拼到消息文本里,便于在 ThinkPHP 默认 file 日志格式下 grep 统计 errcode 分布。 $line = sprintf( '企业微信-获取客户详情失败 errcode=%s errmsg=%s ext=%s', $errcode === null ? '?' : (string) $errcode, $errmsg !== '' ? $errmsg : '-', $externalUserId ); if (in_array($errcode, [84061, 41068, 60011, 60111], true)) { Log::warning($line); } else { Log::error($line); } return []; } /** * 企业群发:员工代发消息给已添加客户 / 客户群 * * @see https://developer.work.weixin.qq.com/document/path/92135 创建企业群发 * * 注意:这里只是「下发提醒」,员工手机端会在企微 App 看到"请发送"的提示,点击后才真正发给客户; * 不点击则消息不会触达。发送结果需通过 getGroupMsgSendResult 查询。 * * @param array $payload 标准字段: * chat_type string 'single' 或 'group',必填 * external_userid string[] 客户 external_userid 列表(single 必填,最多 10000;group 不传) * chat_type_external_userid_list ... 预留 * sender string 员工 userid(不传则使用创建者自己;推荐显式传) * allow_select bool 为 true 时企微会让员工在企微 App 里勾选客户;默认 false * text ['content' => string] * attachments array[] 单条内可含 image / video / link / miniprogram / file(最多 9 个) * * @return array 原样返回 errcode / errmsg / msgid / fail_list */ public function addMsgTemplate(array $payload): array { $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_msg_template?access_token=' . urlencode($accessToken); $response = $this->httpPostJson($url, $payload); $errcode = isset($response['errcode']) ? (int) $response['errcode'] : null; if ($errcode !== 0) { $errmsg = (string) ($response['errmsg'] ?? ''); Log::warning(sprintf( '企业微信-企业群发失败 errcode=%s errmsg=%s sender=%s chat_type=%s', $errcode === null ? '?' : (string) $errcode, $errmsg !== '' ? $errmsg : '-', (string) ($payload['sender'] ?? '-'), (string) ($payload['chat_type'] ?? '-') )); } return $response; } /** * 查询企业群发结果 * * @see https://developer.work.weixin.qq.com/document/path/93338 * 群发任务创建后,消息会进入发送池,员工手机端需点击确认;此接口返回员工确认/送达/读状态。 * * @return array */ public function getGroupMsgSendResult(string $msgid, string $userid = '', string $cursor = '', int $limit = 500): array { $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get_group_msg_send_result?access_token=' . urlencode($accessToken); $body = [ 'msgid' => $msgid, 'limit' => min(1000, max(1, $limit)), 'cursor' => $cursor, ]; if ($userid !== '') { $body['userid'] = $userid; } return $this->httpPostJson($url, $body); } /** * 上传临时素材(企微 media_id,3 天有效)。用于企业群发的 image/video/file 类型 attachment。 * * @see https://developer.work.weixin.qq.com/document/path/90253 * * @param string $type image|voice|video|file * @param string $path 本地可读文件绝对路径 * @param string $filename 上传字段里的 filename,默认取 basename * @return array 标准响应(包含 media_id) */ public function uploadMedia(string $type, string $path, string $filename = ''): array { if (!is_file($path) || !is_readable($path)) { Log::error(sprintf('企业微信-上传临时素材失败:文件不存在或不可读 path=%s', $path)); return ['errcode' => -1, 'errmsg' => 'file not found: ' . $path]; } $type = in_array($type, ['image', 'voice', 'video', 'file'], true) ? $type : 'file'; $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=' . urlencode($accessToken) . '&type=' . $type; if ($filename === '') { $filename = basename($path); } $cfile = new \CURLFile($path, mime_content_type($path) ?: 'application/octet-stream', $filename); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, ['media' => $cfile]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 120); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($httpCode !== 200) { Log::error(sprintf('企业微信-上传临时素材 HTTP=%d type=%s err=%s', $httpCode, $type, $curlErr)); return ['errcode' => -1, 'errmsg' => 'http ' . $httpCode . ' ' . $curlErr]; } $result = json_decode((string) $response, true); if (!is_array($result)) { Log::error('企业微信-上传临时素材 响应解析失败:' . (string) $response); return ['errcode' => -1, 'errmsg' => 'bad json']; } return $result; } /** * 上传图文/图片的永久图片素材,获取企微 CDN 图片 URL(用于 link/image 附件的 pic_url) * * @see https://developer.work.weixin.qq.com/document/path/90256 */ public function uploadImageToCdn(string $path, string $filename = ''): array { if (!is_file($path) || !is_readable($path)) { return ['errcode' => -1, 'errmsg' => 'file not found']; } $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/media/uploadimg?access_token=' . urlencode($accessToken); if ($filename === '') { $filename = basename($path); } $cfile = new \CURLFile($path, mime_content_type($path) ?: 'image/jpeg', $filename); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, ['media' => $cfile]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 60); $response = curl_exec($ch); curl_close($ch); $result = json_decode((string) $response, true); return is_array($result) ? $result : ['errcode' => -1, 'errmsg' => 'bad json']; } /** * 获取企业微信部门下所有成员(含字段 userid/name/mobile/department/...),供 admin 选择发送员工 * * @see https://developer.work.weixin.qq.com/document/path/90201 */ public function getDepartmentUserSimpleList(int $departmentId = 1, bool $fetchChild = true): array { $accessToken = $this->getAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/user/simplelist'; $params = [ 'access_token' => $accessToken, 'department_id' => $departmentId, 'fetch_child' => $fetchChild ? 1 : 0, ]; $response = $this->httpGet($url, $params); return isset($response['userlist']) && is_array($response['userlist']) ? $response['userlist'] : []; } /** * @notes 检查应用权限(用于诊断) * @return array 返回应用的权限信息 */ public function checkAppPermissions(): array { try { $accessToken = $this->getAccessToken(); // 尝试获取应用详情 $url = 'https://qyapi.weixin.qq.com/cgi-bin/agent/get'; $params = [ 'access_token' => $accessToken, 'agentid' => config('pay.wechat_work.agent_id', 1000002), ]; $response = $this->httpGet($url, $params); return [ 'success' => isset($response['agentid']), 'data' => $response, 'message' => isset($response['errmsg']) ? $response['errmsg'] : 'OK', ]; } catch (\Throwable $e) { return [ 'success' => false, 'data' => [], 'message' => $e->getMessage(), ]; } } /** * @notes HTTP GET请求 */ private function httpGet(string $url, array $params = []): array { if (!empty($params)) { $url .= '?' . http_build_query($params); } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { Log::error('HTTP请求失败: ' . $url . ', HTTP Code: ' . $httpCode); return []; } $result = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::error('JSON解析失败: ' . $response); return []; } return $result; } /** * POST JSON */ private function httpPostJson(string $url, array $body): array { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 60); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json; charset=UTF-8', ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { Log::error('HTTP POST 失败: ' . $url . ', HTTP Code: ' . $httpCode); return []; } $result = json_decode((string) $response, true); if (!is_array($result)) { Log::error('JSON 解析失败: ' . $response); return []; } return $result; } }