550 lines
20 KiB
PHP
Executable File
550 lines
20 KiB
PHP
Executable File
<?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;
|
||
/** 实际选用的 secret 语义标签:customer_contact / external_pay / unknown,便于排错 */
|
||
private string $secretLabel = 'unknown';
|
||
private $accessToken;
|
||
|
||
/**
|
||
* @param string|null $secretType 'customer_contact'(客户联系 API)或 'external_pay'(对外收款 API);
|
||
* 缺省按调用方法的语义自动选择,未指定时优先使用客户联系 Secret,回退到对外收款。
|
||
*/
|
||
public function __construct(?string $secretType = null)
|
||
{
|
||
$this->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<string, mixed> 含 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<string, mixed>|null $errorOut 引用出参:当返回空数组时携带 errcode/errmsg/raw,便于上游分流处理
|
||
* @return array<string, mixed>
|
||
*/
|
||
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<string, mixed> $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<string, mixed> 原样返回 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<string, mixed>
|
||
*/
|
||
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<string, mixed> 标准响应(包含 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;
|
||
}
|
||
}
|