Files
zyt/server/app/common/service/wechat/WechatWorkService.php
2026-05-11 17:49:38 +08:00

550 lines
20 KiB
PHP
Executable File
Raw Permalink 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;
/** 实际选用的 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 必填,最多 10000group 不传)
* 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;
}
}