This commit is contained in:
Your Name
2026-04-21 09:49:44 +08:00
parent c9e0419895
commit c15c348b7a
454 changed files with 5391 additions and 25651 deletions
@@ -15,12 +15,64 @@ class WechatWorkService
{
private $corpId;
private $secret;
/** 实际选用的 secret 语义标签:customer_contact / external_pay / unknown,便于排错 */
private string $secretLabel = 'unknown';
private $accessToken;
public function __construct()
/**
* @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');
$this->secret = config('pay.wechat_work.external_pay_secret');
$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 . ')';
}
/**
@@ -33,8 +85,9 @@ class WechatWorkService
return $this->accessToken;
}
// 缓存获取
$cacheKey = 'qywx_access_token';
// 缓存 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;
@@ -52,15 +105,32 @@ class WechatWorkService
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'] ?? '未知错误'));
$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
));
}
/**
@@ -149,9 +219,15 @@ class WechatWorkService
/**
* @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
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 = [
@@ -160,15 +236,214 @@ class WechatWorkService
];
$response = $this->httpGet($url, $params);
if (isset($response['external_contact'])) {
return $response;
}
Log::error('获取客户详情失败: ' . json_encode($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 返回应用的权限信息