企业微信外部联系人同步

This commit is contained in:
Guoxianpeng
2026-04-22 12:19:24 +08:00
parent 906684c1ed
commit 6e37c6ec05
25 changed files with 3362 additions and 325 deletions
@@ -15,12 +15,21 @@ class WechatWorkService
{
private $corpId;
private $secret;
private $contactSecret;
private $accessToken;
private $contactAccessToken;
public function __construct()
{
$this->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) . '...'
]));
}
/**
@@ -63,12 +72,80 @@ class WechatWorkService
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 = [
@@ -77,6 +154,8 @@ class WechatWorkService
'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));
@@ -93,17 +172,37 @@ class WechatWorkService
* @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)
public function getExternalContactList(string $userId, string $cursor = '', int $limit = 50)
{
$accessToken = $this->getAccessToken();
// 生成缓存键
$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));
@@ -115,25 +214,102 @@ class WechatWorkService
}
if (isset($response['external_userid'])) {
return $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) {
return [];
$result = [
'external_userid' => [],
'next_cursor' => ''
];
// 缓存结果,有效期5分钟
Cache::set($cacheKey, $result, 300);
return $result;
}
Log::error('获取客户列表失败: ' . json_encode($response));
return [];
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->getAccessToken();
$accessToken = $this->getContactAccessToken();
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get';
$params = [
'access_token' => $accessToken,
@@ -150,6 +326,240 @@ class WechatWorkService
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 返回应用的权限信息
@@ -181,7 +591,85 @@ class WechatWorkService
];
}
}
/**
* @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请求
*/