193 lines
6.8 KiB
PHP
Executable File
193 lines
6.8 KiB
PHP
Executable File
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service\wechat;
|
||
|
||
use think\facade\Cache;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 企业微信自建应用:向成员发送应用文本消息
|
||
*
|
||
* 依赖配置(与登录/绑定可共用 corp_id;发消息必须用「同一自建应用」的 AgentId + Secret):
|
||
* - WECHAT_WORK_CORP_ID 或 work_wechat.corp_id
|
||
* - WECHAT_WORK_AGENT_ID 或 work_wechat.agent_id
|
||
* - WECHAT_WORK_AGENT_SECRET 或 work_wechat.agent_secret(推荐;勿与仅用于网页授权的 secret 混用)
|
||
* - 若未配 agent_secret,会回退 work_wechat.secret(若仍无法发消息,请在管理后台复制该应用的 Secret 填到 agent_secret)
|
||
*
|
||
* @see https://developer.work.weixin.qq.com/document/path/90236
|
||
*/
|
||
class WechatWorkAppMessageService
|
||
{
|
||
/**
|
||
* @return array{0: string, 1: int, 2: string} [corpId, agentId, secret]
|
||
*/
|
||
private static function resolveMessagingCredentials(): array
|
||
{
|
||
$corpId = (string) env('WECHAT_WORK_CORP_ID', '');
|
||
if ($corpId === '') {
|
||
$corpId = (string) env('work_wechat.corp_id', '');
|
||
}
|
||
|
||
$agentIdStr = (string) env('WECHAT_WORK_AGENT_ID', '');
|
||
if ($agentIdStr === '') {
|
||
$agentIdStr = (string) env('work_wechat.agent_id', '');
|
||
}
|
||
$agentId = (int) $agentIdStr;
|
||
|
||
$secret = (string) env('WECHAT_WORK_AGENT_SECRET', '');
|
||
if ($secret === '') {
|
||
$secret = (string) env('work_wechat.agent_secret', '');
|
||
}
|
||
if ($secret === '') {
|
||
$secret = (string) env('work_wechat.secret', '');
|
||
}
|
||
|
||
return [$corpId, $agentId, $secret];
|
||
}
|
||
|
||
private static function getAccessToken(string $corpId, string $secret): ?string
|
||
{
|
||
if ($corpId === '' || $secret === '') {
|
||
return null;
|
||
}
|
||
$cacheKey = 'work_wechat_app_msg_token_' . md5($corpId . $secret);
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached) {
|
||
return (string) $cached;
|
||
}
|
||
|
||
$url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={$corpId}&corpsecret={$secret}";
|
||
$res = self::httpGetJson($url);
|
||
if (!$res || (int) ($res['errcode'] ?? -1) !== 0) {
|
||
Log::error('企业微信应用 access_token 失败: ' . ($res['errmsg'] ?? '无响应'));
|
||
|
||
return null;
|
||
}
|
||
$token = (string) ($res['access_token'] ?? '');
|
||
if ($token === '') {
|
||
return null;
|
||
}
|
||
$ttl = (int) ($res['expires_in'] ?? 7200) - 200;
|
||
if ($ttl > 0) {
|
||
Cache::set($cacheKey, $token, $ttl);
|
||
}
|
||
|
||
return $token;
|
||
}
|
||
|
||
/**
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
private static function httpGetJson(string $url): ?array
|
||
{
|
||
$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, 15);
|
||
$result = curl_exec($ch);
|
||
curl_close($ch);
|
||
if ($result === false || $result === '') {
|
||
return null;
|
||
}
|
||
$decoded = json_decode($result, true);
|
||
|
||
return is_array($decoded) ? $decoded : null;
|
||
}
|
||
|
||
/**
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
private static 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_HTTPHEADER, ['Content-Type: application/json; charset=utf-8']);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||
$result = curl_exec($ch);
|
||
curl_close($ch);
|
||
if ($result === false || $result === '') {
|
||
return null;
|
||
}
|
||
$decoded = json_decode($result, true);
|
||
|
||
return is_array($decoded) ? $decoded : null;
|
||
}
|
||
|
||
/**
|
||
* 向指定成员发送文本消息(userid 为企业微信成员账号,与后台 work_wechat_userid 一致)
|
||
*
|
||
* @return array{ok: bool, message?: string, errcode?: int}
|
||
*/
|
||
public static function sendTextToUser(string $toUser, string $content): array
|
||
{
|
||
$toUser = trim($toUser);
|
||
$content = trim($content);
|
||
if ($toUser === '' || $content === '') {
|
||
return ['ok' => false, 'message' => '接收人或消息内容为空'];
|
||
}
|
||
|
||
[$corpId, $agentId, $secret] = self::resolveMessagingCredentials();
|
||
|
||
if ($corpId === '' || $agentId <= 0 || $secret === '') {
|
||
Log::warning('企业微信应用消息未配置完整(corp_id/agent_id/secret),跳过发消息');
|
||
|
||
return [
|
||
'ok' => false,
|
||
'message' => '服务端未配置企业微信应用消息(需 corp_id、agent_id 及该应用的 Secret,建议配置 WECHAT_WORK_AGENT_SECRET)',
|
||
];
|
||
}
|
||
|
||
$token = self::getAccessToken($corpId, $secret);
|
||
if (!$token) {
|
||
return ['ok' => false, 'message' => '获取企业微信 access_token 失败,请查看服务端日志'];
|
||
}
|
||
|
||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=' . urlencode($token);
|
||
$body = [
|
||
'touser' => $toUser,
|
||
'msgtype' => 'text',
|
||
'agentid' => $agentId,
|
||
'text' => ['content' => $content],
|
||
'safe' => 0,
|
||
];
|
||
|
||
$res = self::httpPostJson($url, $body);
|
||
if (!$res) {
|
||
Log::error('企业微信发消息无响应');
|
||
|
||
return ['ok' => false, 'message' => '企业微信接口无响应'];
|
||
}
|
||
$err = (int) ($res['errcode'] ?? -1);
|
||
if ($err !== 0) {
|
||
$errmsg = (string) ($res['errmsg'] ?? '');
|
||
Log::error('企业微信发消息失败: ' . $errmsg . ' errcode=' . $err);
|
||
|
||
$hint = '';
|
||
if ($err === 81013 || $err === 60111) {
|
||
$hint = '请核对开方人绑定的 userid 与企业微信成员账号一致,且成员已安装/可见该应用。';
|
||
} elseif ($err === 60020) {
|
||
$hint = '请将服务器出口 IP 加入企业微信应用可信 IP。';
|
||
} elseif ($err === 60011) {
|
||
$hint = '请检查应用是否具备发消息能力及可见范围是否包含该成员。';
|
||
}
|
||
|
||
return [
|
||
'ok' => false,
|
||
'message' => '企业微信返回:' . $errmsg . '(errcode=' . $err . ')' . ($hint !== '' ? ' ' . $hint : ''),
|
||
'errcode' => $err,
|
||
];
|
||
}
|
||
|
||
return ['ok' => true];
|
||
}
|
||
}
|