Files
zyt/server/app/common/service/wechat/WechatWorkService.php
T

707 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service\wechat;
use think\facade\Cache;
use think\facade\Log;
/**
* 企业微信服务类
* @see https://developer.work.weixin.qq.com/document/
*/
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) . '...'
]));
}
/**
* @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;
}
}