Files
lgp-admin-plus-api/app/Service/common/wechat/WechatApiClient.php

276 lines
11 KiB
PHP
Raw Normal View History

<?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];
}
}