corpId = config('pay.wechat_work.corp_id'); $this->secret = config('pay.wechat_work.external_pay_secret'); $this->contactSecret = config('pay.wechat_work.contact_secret'); Log::info('企业微信配置: ' . json_encode([ 'corp_id' => $this->corpId, 'secret' => substr($this->secret, 0, 10) . '...', 'contact_secret' => substr($this->contactSecret, 0, 10) . '...' ])); } /** * @notes 获取access_token * @see https://developer.work.weixin.qq.com/document/path/91039 */ private function getAccessToken(): string { if ($this->accessToken) { return $this->accessToken; } // 从缓存获取 $cacheKey = 'qywx_access_token'; $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; } throw new \Exception('获取access_token失败: ' . ($response['errmsg'] ?? '未知错误')); } /** * @notes 获取客户联系的access_token * @see https://developer.work.weixin.qq.com/document/path/92113 */ private function getContactAccessToken(): string { if ($this->contactAccessToken) { return $this->contactAccessToken; } // 从缓存获取 $cacheKey = 'qywx_contact_access_token'; $token = Cache::get($cacheKey); if ($token) { $this->contactAccessToken = $token; return $token; } // 尝试使用 contactSecret 获取 token $token = $this->requestToken($this->contactSecret, $cacheKey, '客户联系'); if ($token) { $this->contactAccessToken = $token; return $token; } // 如果 contactSecret 失败,尝试使用 secret Log::info('企业微信-使用 contactSecret 获取 token 失败,尝试使用 secret'); $token = $this->requestToken($this->secret, $cacheKey, '普通应用'); if ($token) { $this->contactAccessToken = $token; return $token; } throw new \Exception('获取客户联系access_token失败'); } /** * @notes 请求获取 token */ private function requestToken(string $secret, string $cacheKey, string $type): string { $url = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken'; $params = [ 'corpid' => $this->corpId, 'corpsecret' => $secret, ]; Log::info('企业微信-获取' . $type . 'AccessToken请求: ' . json_encode($params, JSON_UNESCAPED_UNICODE)); $response = $this->httpGet($url, $params); Log::info('企业微信-获取' . $type . 'AccessToken响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); if (isset($response['access_token'])) { $token = $response['access_token']; $expiresIn = $response['expires_in'] ?? 7200; // 缓存token,提前5分钟过期 Cache::set($cacheKey, $token, $expiresIn - 300); return $token; } Log::error('获取' . $type . 'token失败: ' . json_encode($response)); return ''; } /** * @notes 获取部门成员列表 * @see https://developer.work.weixin.qq.com/document/path/90200 */ public function getDepartmentUserList(int $departmentId = 1, bool $fetchChild = true): array { // 获取部门成员列表使用 external_pay_secret(普通应用Secret) $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, ]; Log::info('企业微信-获取部门成员列表请求: ' . json_encode($params, JSON_UNESCAPED_UNICODE)); $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 * @param string $cursor 分页游标,上次返回的 next_cursor * @param int $limit 最大记录数,最大 100,默认 50 * @return array|false 返回客户列表数组,权限错误返回false */ public function getExternalContactList(string $userId, string $cursor = '', int $limit = 50) { // 生成缓存键 $cacheKey = 'qywx_external_contact_list_' . $userId . '_' . $cursor . '_' . $limit; // 尝试从缓存获取 $cachedResult = Cache::get($cacheKey); if ($cachedResult) { Log::info('从缓存获取客户列表: ' . $userId); return $cachedResult; } $accessToken = $this->getContactAccessToken(); $url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list'; $params = [ 'access_token' => $accessToken, 'userid' => $userId, ]; // 添加分页参数 if (!empty($cursor)) { $params['cursor'] = $cursor; } if ($limit > 0) { $params['limit'] = min($limit, 100); // 最大100 } $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'])) { $result = [ 'external_userid' => $response['external_userid'], 'next_cursor' => $response['next_cursor'] ?? '' ]; // 缓存结果,有效期5分钟 Cache::set($cacheKey, $result, 300); return $result; } // 如果没有客户,返回空数组(不记录错误) if (isset($response['errcode']) && $response['errcode'] == 0) { $result = [ 'external_userid' => [], 'next_cursor' => '' ]; // 缓存结果,有效期5分钟 Cache::set($cacheKey, $result, 300); return $result; } Log::error('获取客户列表失败: ' . json_encode($response)); return false; } /** * @notes 获取外部联系人信息 * @see https://developer.work.weixin.qq.com/document/path/92114 * @param string $externalUserId 外部用户ID * @return array|false 返回外部联系人信息,失败返回false */ public function getExternalContactInfo(string $externalUserId) { $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); Log::info('企业微信-获取外部联系人信息响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); if (isset($response['external_contact'])) { return $response['external_contact']; } Log::error('获取外部联系人信息失败: ' . json_encode($response)); return false; } /** * @notes 生成带参数的二维码 * @see https://developer.work.weixin.qq.com/document/path/90239 * @param string $scene 场景值 * @param int $expireSeconds 过期时间(秒),最大2592000 * @return array|false 返回二维码信息,失败返回false */ public function createQrCode(string $scene, int $expireSeconds = 2592000) { $accessToken = $this->getAccessToken(); $url = "https://qyapi.weixin.qq.com/cgi-bin/qrcode/create?access_token={$accessToken}"; $data = [ "action_info" => [ "scene" => [ "scene_str" => $scene ] ], "expire_seconds" => $expireSeconds, "action_name" => "QR_SCENE" ]; Log::info('企业微信-生成二维码请求: ' . json_encode($data, JSON_UNESCAPED_UNICODE)); $response = $this->httpPost($url, json_encode($data)); Log::info('企业微信-生成二维码响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); if (isset($response['ticket'])) { return $response; } Log::error('生成二维码失败: ' . json_encode($response)); return false; } /** * @notes 获取客户详情 * @see https://developer.work.weixin.qq.com/document/path/92114 */ public function getExternalContactDetail(string $externalUserId): array { $accessToken = $this->getContactAccessToken(); $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; } Log::error('获取客户详情失败: ' . json_encode($response)); return []; } /** * @notes 获取所有员工的客户列表 * @return array 所有员工的客户ID列表,格式:[staff_id => [external_userid1, external_userid2]] */ public function getAllStaffCustomers(): array { $result = []; // 获取所有员工 $staffList = $this->getDepartmentUserList(); if (empty($staffList)) { Log::info('企业微信-未获取到员工列表'); return $result; } Log::info('企业微信-开始获取员工客户列表,共 ' . count($staffList) . ' 名员工'); foreach ($staffList as $staff) { $userId = $staff['userid'] ?? ''; if (empty($userId)) { continue; } $customerResult = $this->getExternalContactList($userId); if ($customerResult === false) { Log::error('企业微信-员工 ' . $userId . ' 获取客户列表权限不足'); continue; } $customerIds = $customerResult['external_userid'] ?? []; if (!empty($customerIds)) { $result[$userId] = [ 'name' => $staff['name'] ?? '', 'customers' => $customerIds ]; Log::info('企业微信-员工 ' . $userId . '(' . ($staff['name'] ?? '') . ') 有 ' . count($customerIds) . ' 个客户'); } } Log::info('企业微信-共获取到 ' . count($result) . ' 名员工的客户'); return $result; } /** * @notes 同步所有客户到本地数据库 * @return array 同步结果统计 */ public function syncAllExternalContacts(): array { $stats = [ 'total_staff' => 0, 'total_customers' => 0, 'added' => 0, 'updated' => 0, 'errors' => 0 ]; // 获取所有员工的客户 $allStaffCustomers = $this->getAllStaffCustomers(); if (empty($allStaffCustomers)) { Log::info('企业微信-没有客户需要同步'); return $stats; } $stats['total_staff'] = count($allStaffCustomers); foreach ($allStaffCustomers as $staffId => $staffData) { $staffName = $staffData['name'] ?? ''; $customers = $staffData['customers'] ?? []; foreach ($customers as $externalUserId) { $stats['total_customers']++; try { // 获取客户详情 $customerDetail = $this->getExternalContactDetail($externalUserId); if (empty($customerDetail) || !isset($customerDetail['external_contact'])) { Log::error('企业微信-获取客户详情失败: ' . $externalUserId); $stats['errors']++; continue; } $contactInfo = $customerDetail['external_contact']; // 更新到数据库 $result = $this->saveExternalContact($staffId, $staffName, $contactInfo); if ($result) { $stats[$result]++; } } catch (\Exception $e) { Log::error('企业微信-同步客户异常: ' . $e->getMessage()); $stats['errors']++; } } } Log::info('企业微信-同步完成: ' . json_encode($stats)); return $stats; } /** * @notes 批量获取成员的客户详情(支持分页) * @param string $userId 成员ID * @param string $cursor 分页游标,上次返回的 next_cursor * @param int $limit 最大记录数,最大 100,默认 50 * @return array|false 返回客户详情列表,权限错误返回false */ public function batchGetCustomerDetails(string $userId, string $cursor = '', int $limit = 50) { // 生成缓存键 $cacheKey = 'qywx_batch_customer_details_' . $userId . '_' . $cursor . '_' . $limit; // 尝试从缓存获取 $cachedResult = Cache::get($cacheKey); if ($cachedResult) { Log::info('从缓存获取客户详情: ' . $userId); return $cachedResult; } // 先获取客户ID列表 $listResult = $this->getExternalContactList($userId, $cursor, $limit); if ($listResult === false) { return false; } $externalUserIds = $listResult['external_userid'] ?? []; $nextCursor = $listResult['next_cursor'] ?? ''; $customers = []; // 批量获取客户详情 foreach ($externalUserIds as $externalUserId) { $detail = $this->getExternalContactDetail($externalUserId); if (!empty($detail) && isset($detail['external_contact'])) { $customers[] = $detail; } } $result = [ 'customers' => $customers, 'next_cursor' => $nextCursor, 'count' => count($customers) ]; // 缓存结果,有效期5分钟 Cache::set($cacheKey, $result, 300); return $result; } /** * @notes 保存外部联系人到数据库 * @param string $staffId 员工ID * @param string $staffName 员工姓名 * @param array $contactInfo 联系人信息 * @return string|false 返回added/updated,失败返回false */ private function saveExternalContact(string $staffId, string $staffName, array $contactInfo) { $externalUserId = $contactInfo['external_userid'] ?? ''; if (empty($externalUserId)) { return false; } // 查询是否已存在 $model = new \app\common\model\QywxExternalContact(); $exists = $model->where('external_user_id', $externalUserId)->find(); $data = [ 'external_user_id' => $externalUserId, 'name' => $contactInfo['name'] ?? '', 'avatar' => $contactInfo['avatar'] ?? '', 'type' => $contactInfo['type'] ?? 1, 'gender' => $contactInfo['gender'] ?? 0, 'unionid' => $contactInfo['unionid'] ?? '', 'position' => $contactInfo['position'] ?? '', 'corp_name' => $contactInfo['corp_name'] ?? '', 'corp_full_name' => $contactInfo['corp_full_name'] ?? '', 'external_profile' => json_encode($contactInfo['external_profile'] ?? [], JSON_UNESCAPED_UNICODE), 'update_time' => time() ]; // 构建跟进人信息 $followUser = [ 'userid' => $staffId, 'name' => $staffName, 'add_time' => time() ]; if ($exists) { // 更新已有记录 $existsData = json_decode($exists['follow_users'] ?? '{"followers":[]}', true); if (!isset($existsData['followers'])) { $existsData = ['followers' => []]; } // 检查是否已存在该跟进人 $found = false; foreach ($existsData['followers'] as &$follower) { if ($follower['userid'] === $staffId) { $follower = $followUser; $found = true; break; } } unset($follower); if (!$found) { $existsData['followers'][] = $followUser; } $data['follow_users'] = json_encode($existsData, JSON_UNESCAPED_UNICODE); $data['update_time'] = time(); $exists->save($data); return 'updated'; } else { // 新增记录 $data['follow_users'] = json_encode(['followers' => [$followUser]], JSON_UNESCAPED_UNICODE); $data['create_time'] = time(); $model->save($data); return 'added'; } } /** * @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 创建外部客户(扫码添加) * @param string $staffId 员工ID * @param string $staffName 员工姓名 * @param array $customerInfo 客户信息 * @return array|false 返回创建结果,失败返回false */ public function createExternalContact(string $staffId, string $staffName, array $customerInfo) { try { $accessToken = $this->getContactAccessToken(); $url = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_contact?access_token={$accessToken}"; $data = [ "userid" => $staffId, "external_contact" => [ "name" => $customerInfo['name'] ?? '未知客户', "mobile" => $customerInfo['mobile'] ?? '', "position" => $customerInfo['position'] ?? '', "corp_name" => $customerInfo['corp_name'] ?? '', "avatar" => $customerInfo['avatar'] ?? '' ] ]; Log::info('企业微信-创建外部客户请求: ' . json_encode($data, JSON_UNESCAPED_UNICODE)); $response = $this->httpPost($url, json_encode($data)); Log::info('企业微信-创建外部客户响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE)); if (isset($response['external_userid'])) { return $response; } Log::error('创建外部客户失败: ' . json_encode($response)); return false; } catch (\Exception $e) { Log::error('创建外部客户异常: ' . $e->getMessage()); return false; } } /** * @notes HTTP POST请求 */ private function httpPost(string $url, string $data): array { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $data); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Content-Length: ' . strlen($data) ]); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $output = 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($output, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::error('JSON解析失败: ' . $output); return []; } return $result; } /** * @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; } }