276 lines
11 KiB
PHP
276 lines
11 KiB
PHP
<?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_status:0成功 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];
|
||
}
|
||
}
|