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