Files
nl-admin-api/app/Service/common/wechat/WechatApiClient.php
李琦 6e2769c528
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
优化部分代码,增加微信公众号模板
2026-08-13 19:02:05 +08:00

276 lines
11 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
namespace App\Service\common\wechat;
use App\BaseApp\BaseClient;
use App\Service\common\RedisService;
use GuzzleHttp\Exception\GuzzleException;
/**
* 微信公众号 API 客户端(继承 BaseClient禁止业务代码直接 new Guzzle
*
* 职责:
* 1. stable_token 获取 + Redis 缓存key 前缀 config('nl.redis.wechat_token')TTL 随微信返回的 expires_in
* 2. 正文外链图搬家media/uploadimg公众号正文只认微信图床 URL
* 3. 封面永久素材material/add_material 取 thumb_media_id
* 4. 草稿 draft/add、发布 freepublish/submit、发布状态 freepublish/get
*
* 为什么单独封 postWxJson / postWxMultipart
* - BaseClient::postJson 用 Guzzle 的 json 选项,中文会被 unicode 转义,微信标题/摘要偶发显示 \uXXXX
* 这里手动 JSON_UNESCAPED_UNICODE 编码后按 body 发送
* - 素材上传要 multipart 文件流BaseClient 没有该能力,本类内部补齐(复用父类 init 的 Guzzle 实例)
*/
class WechatApiClient extends BaseClient
{
protected string $baseUrl = 'https://api.weixin.qq.com';
/**
* 常见错误码 → 人话(其余透出 errcode + errmsg 原文)
*/
private const ERROR_MAP = [
40001 => 'AppSecret 错误或 access_token 已失效,请检查账号配置',
40013 => 'AppID 无效,请检查账号配置',
40125 => 'AppSecret 无效,请检查账号配置',
41004 => '缺少 AppSecret 参数',
40164 => '服务器出口 IP 不在公众号后台的 IP 白名单中,请到公众平台「设置与开发-安全中心」添加',
48001 => '该公众号无此接口权限(个人订阅号无发布能力,需认证服务号/订阅号)',
45009 => '接口调用次数超过当日限额,请明天再试',
45028 => '该公众号无留言/相关高级能力权限',
40007 => '无效的 media_id素材可能已过期请重新发布',
53503 => '该草稿未通过发布检查',
53504 => '需前往公众平台官网使用草稿',
53505 => '请手动保存成功后再发布',
];
/**
* 获取稳定版 access_token优先走 Redis 缓存)
*
* 为什么用 stable_token 而不是旧 token 接口stable 模式重复获取不会互踢,
* 多实例/双后端Laravel+GF共用同一个 Redis key 也不会导致 token 失效竞争
*
* @param string $appid 公众号 AppID
* @param string $secret AppSecret 明文(调用方负责解密)
* @param bool $forceRefresh true 时绕过缓存强刷(如 token 失效重试场景)
*/
public function getStableToken(string $appid, string $secret, bool $forceRefresh = false): string
{
$redis = RedisService::getInstance()->init((string) config('nl.redis.wechat_token', 'nl_wechat_at_'));
if (!$forceRefresh) {
$cached = $redis->get($appid);
if (!empty($cached)) {
return (string) $cached;
}
}
$body = $this->postWxJson('/cgi-bin/stable_token', [
'grant_type' => 'client_credential',
'appid' => $appid,
'secret' => $secret,
'force_refresh' => $forceRefresh,
], '获取access_token');
$token = (string) ($body['access_token'] ?? '');
if ($token === '') {
$this->utils->errorThrow('获取 access_token 失败:微信未返回 token');
}
// 提前 5 分钟过期,避免边界时刻拿到将失效的 token
$expire = max(60, (int) ($body['expires_in'] ?? 7200) - 300);
$redis->set($appid, $token, $expire);
return $token;
}
/**
* 校验 appid/secret 连通性(不落缓存,直接强刷验证凭据有效)
* 返回微信侧 expires_in便于前端提示
*/
public function testCredential(string $appid, string $secret): array
{
$body = $this->postWxJson('/cgi-bin/stable_token', [
'grant_type' => 'client_credential',
'appid' => $appid,
'secret' => $secret,
], '测试连通');
return [
'ok' => true,
'expires_in' => (int) ($body['expires_in'] ?? 0),
];
}
/**
* 正文图片搬家:下载外链图后传微信图床,返回微信侧 URL
* 微信限制:仅 jpg/png、单张 ≤1MB该 URL 不占素材库额度,仅可用于图文正文
*/
public function uploadContentImage(string $token, string $imageUrl): string
{
[$content, $ext] = $this->downloadImage($imageUrl);
if (!in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
$this->utils->errorThrow("正文图片仅支持 jpg/png{$imageUrl}");
}
if (strlen($content) > 1024 * 1024) {
$this->utils->errorThrow("正文图片超过 1MB无法上传微信图床{$imageUrl}");
}
$body = $this->postWxMultipart(
'/cgi-bin/media/uploadimg?access_token=' . $token,
'media', $content, 'content_' . substr(md5($imageUrl), 0, 8) . '.' . $ext,
'正文图片上传'
);
$url = (string) ($body['url'] ?? '');
if ($url === '') {
$this->utils->errorThrow('正文图片上传微信图床失败:未返回 URL');
}
return $url;
}
/**
* 封面图上传为永久图片素材,返回 media_id草稿 thumb_media_id 用)
* 微信限制:图片素材 ≤10MB支持 bmp/png/jpeg/jpg/gif
*/
public function addImageMaterial(string $token, string $imageUrl): string
{
[$content, $ext] = $this->downloadImage($imageUrl);
if (strlen($content) > 10 * 1024 * 1024) {
$this->utils->errorThrow("封面图超过 10MB无法上传素材库{$imageUrl}");
}
$body = $this->postWxMultipart(
'/cgi-bin/material/add_material?access_token=' . $token . '&type=image',
'media', $content, 'cover_' . substr(md5($imageUrl), 0, 8) . '.' . $ext,
'封面素材上传'
);
$mediaId = (string) ($body['media_id'] ?? '');
if ($mediaId === '') {
$this->utils->errorThrow('封面上传素材库失败:未返回 media_id');
}
return $mediaId;
}
/**
* 新建草稿,返回草稿 media_id
*
* @param array $article 单篇图文title/author/digest/content/thumb_media_id 等
*/
public function addDraft(string $token, array $article): string
{
$body = $this->postWxJson('/cgi-bin/draft/add?access_token=' . $token, [
'articles' => [$article],
], '新建草稿');
$mediaId = (string) ($body['media_id'] ?? '');
if ($mediaId === '') {
$this->utils->errorThrow('新建草稿失败:未返回 media_id');
}
return $mediaId;
}
/**
* 提交发布任务freepublish 为异步审核,返回 publish_id 供轮询)
*/
public function submitPublish(string $token, string $mediaId): string
{
$body = $this->postWxJson('/cgi-bin/freepublish/submit?access_token=' . $token, [
'media_id' => $mediaId,
], '提交发布');
$publishId = (string) ($body['publish_id'] ?? '');
if ($publishId === '') {
$this->utils->errorThrow('提交发布失败:未返回 publish_id');
}
return $publishId;
}
/**
* 查询发布任务状态
* publish_status0成功 1发布中 2原创审核不通过 3常规失败 4平台审核不通过 5成功后用户删除 6成功后系统封禁
*/
public function getPublishStatus(string $token, string $publishId): array
{
return $this->postWxJson('/cgi-bin/freepublish/get?access_token=' . $token, [
'publish_id' => $publishId,
], '查询发布状态');
}
/**
* 微信 JSON 接口统一出口JSON_UNESCAPED_UNICODE 编码 + errcode 校验
*
* @param string $scene 场景名(拼错误提示用)
*/
private function postWxJson(string $uri, array $data, string $scene): array
{
$this->init();
try {
$response = $this->client->request('POST', $uri, [
'body' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'headers' => ['Content-Type' => 'application/json'],
]);
$body = json_decode((string) $response->getBody(), true) ?: [];
} catch (GuzzleException $e) {
$this->utils->errorThrow("微信接口请求失败({$scene}" . $e->getMessage());
return [];
}
$this->assertWxOk($body, $scene);
return $body;
}
/**
* 微信 multipart 文件上传统一出口
*/
private function postWxMultipart(string $uri, string $field, string $content, string $filename, string $scene): array
{
$this->init();
try {
$response = $this->client->request('POST', $uri, [
'multipart' => [[
'name' => $field,
'contents' => $content,
'filename' => $filename,
]],
]);
$body = json_decode((string) $response->getBody(), true) ?: [];
} catch (GuzzleException $e) {
$this->utils->errorThrow("微信接口请求失败({$scene}" . $e->getMessage());
return [];
}
$this->assertWxOk($body, $scene);
return $body;
}
/**
* errcode 非 0 即失败,映射常见错误码后抛业务异常
*/
private function assertWxOk(array $body, string $scene): void
{
$errcode = (int) ($body['errcode'] ?? 0);
if ($errcode === 0) {
return;
}
$friendly = self::ERROR_MAP[$errcode] ?? ('errmsg: ' . (string) ($body['errmsg'] ?? '未知错误'));
$this->utils->errorThrow("微信{$scene}失败({$errcode}{$friendly}");
}
/**
* 下载图片(本地/OSS/任意外链),返回 [二进制内容, 扩展名]
* 为什么按 Content-Type 兜底扩展名OSS 直链可能不带扩展名,微信上传要求文件名有效
*/
private function downloadImage(string $url): array
{
$this->init();
try {
$response = $this->client->request('GET', $url, ['timeout' => 30]);
} catch (GuzzleException $e) {
$this->utils->errorThrow("下载图片失败:{$url}" . $e->getMessage() . '');
return ['', ''];
}
$content = (string) $response->getBody();
if ($content === '') {
$this->utils->errorThrow("下载图片失败:{$url}(内容为空)");
}
// 优先从 URL 扩展名判断,取不到再从 Content-Type 兜底
$ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp'], true)) {
$contentType = strtolower($response->getHeaderLine('Content-Type'));
$ext = match (true) {
str_contains($contentType, 'png') => 'png',
str_contains($contentType, 'gif') => 'gif',
str_contains($contentType, 'bmp') => 'bmp',
default => 'jpg',
};
}
return [$content, $ext];
}
}