hyperf方案 对接企业微信 实现接口,向指定部门发送图文消息(News),图文包含标题、描述、封面图 和跳转链接,支持多条图文。

张开发
2026/4/14 6:29:39 15 分钟阅读

分享文章

hyperf方案 对接企业微信 实现接口,向指定部门发送图文消息(News),图文包含标题、描述、封面图 和跳转链接,支持多条图文。
企业微信 news 消息的 API 结构是固定的直接基于上一节的 WechatMessageSender 扩展即可。 ───---1.NewsArticle DTO?php// app/DTO/NewsArticle.phpnamespaceApp\DTO;classNewsArticle{publicfunction__construct(publicreadonly string $title,publicreadonly string $description,publicreadonly string $url,publicreadonly string $picurl,){}publicfunctiontoArray():array{returnarray_filter([title$this-title,description$this-description,url$this-url,picurl$this-picurl,],fn($v)$v!);}}---2.扩展 WechatMessageSender增加 sendNews?php// app/Service/WechatMessageSender.phpnamespaceApp\Service;use App\DTO\NewsArticle;use App\Exception\WechatRateLimitException;use App\Exception\WechatSendException;use App\Exception\WechatTokenException;use App\Exception\WechatUserNotFoundException;use GuzzleHttp\Client;use Hyperf\Contract\ConfigInterface;use Hyperf\Logger\LoggerFactory;use Psr\Log\LoggerInterface;classWechatMessageSender{privateconstSEND_URLhttps://qyapi.weixin.qq.com/cgi-bin/message/send;privateconstERR_INVALID_TOKEN42001;privateconstERR_INVALID_TOKEN_240014;privateconstERR_USER_NOT_FOUND60111;privateconstERR_RATE_LIMIT45009;privateconstERR_RATE_LIMIT_245049;privateconstERR_NO_PRIVILEGE48002;privateconstERR_SYSTEM_BUSY-1;// news 限制最多 8 条privateconstNEWS_MAX_ARTICLES8;privateint$agentId;privateLoggerInterface $logger;publicfunction__construct(privatereadonly WechatAccessTokenService $tokenService,privatereadonly Client $http,ConfigInterface $config,LoggerFactory $loggerFactory,){$this-agentId(int)$config-get(wechat.work.default.agent_id);$this-logger$loggerFactory-get(wechat);}// ──────────────────────────────────────────// 文本消息上一节已实现保留// ──────────────────────────────────────────publicfunctionsendText(string $userId,string $content,bool$retrytrue):void{$this-send([touser$userId,msgtypetext,agentid$this-agentId,text[content$content],],$retry);}// ──────────────────────────────────────────// 图文消息发送给指定部门// ──────────────────────────────────────────/** * 向指定部门发送图文消息 * * param int|int[] $partyId 部门 ID支持单个或多个多个用 | 分隔最多 100 个 * param NewsArticle[] $articles 图文列表最多 8 条 */publicfunctionsendNewsToDepartment(int|array $partyId,array $articles,bool$retrytrue):void{if(empty($articles)){thrownew\InvalidArgumentException(图文消息至少需要 1 条);}if(count($articles)self::NEWS_MAX_ARTICLES){thrownew\InvalidArgumentException(图文消息最多 .self::NEWS_MAX_ARTICLES. 条);}// 多部门用 | 拼接$toPartyis_array($partyId)?implode(|,$partyId):(string)$partyId;$payload[toparty$toParty,msgtypenews,agentid$this-agentId,news[articlesarray_map(fn(NewsArticle $a)$a-toArray(),$articles),],];$this-logger-info(发送图文消息到部门,[toparty$toParty,articles_countcount($articles),]);$this-send($payload,$retry);}/** * 向指定员工发送图文消息复用同一方法 * * param NewsArticle[] $articles */publicfunctionsendNewsToUser(string $userId,array $articles,bool$retrytrue):void{if(empty($articles)){thrownew\InvalidArgumentException(图文消息至少需要 1 条);}if(count($articles)self::NEWS_MAX_ARTICLES){thrownew\InvalidArgumentException(图文消息最多 .self::NEWS_MAX_ARTICLES. 条);}$payload[touser$userId,msgtypenews,agentid$this-agentId,news[articlesarray_map(fn(NewsArticle $a)$a-toArray(),$articles),],];$this-send($payload,$retry);}// ──────────────────────────────────────────// 内部统一发送 错误处理// ──────────────────────────────────────────privatefunctionsend(array $payload,bool$retrytrue):void{$token$this-tokenService-get();try{$response$this-http-post(self::SEND_URL.?access_token.$token,[json$payload,]);}catch(\Throwable $e){$this-logger-error(HTTP 请求失败,[error$e-getMessage()]);thrownewWechatSendException(HTTP 请求失败: .$e-getMessage(),0,);}$datajson_decode((string)$response-getBody(),true);$errcode(int)($data[errcode]??-1);if($errcode0){if(!empty($data[invaliduser])){$this-logger-warning(存在无效 userId,[invaliduser$data[invaliduser]]);}if(!empty($data[invalidparty])){$this-logger-warning(存在无效部门 ID,[invalidparty$data[invalidparty]]);}return;}$this-handleError($errcode,$data[errmsg]??,$payload,$retry);}privatefunctionhandleError(int$errcode,string $errmsg,array $payload,bool$retry):void{$this-logger-error(消息发送失败,[errcode$errcode,errmsg$errmsg]);match(true){in_array($errcode,[self::ERR_INVALID_TOKEN,self::ERR_INVALID_TOKEN_2],true)(function()use($payload,$retry,$errcode,$errmsg){if(!$retry){thrownewWechatTokenException(access_token 无效,$errcode,$errmsg);}$this-logger-warning(access_token 过期刷新后重试);$this-tokenService-invalidate();$this-send($payload,false);})(),$errcodeself::ERR_USER_NOT_FOUNDthrownewWechatUserNotFoundException(userId 不存在,$errcode,$errmsg),in_array($errcode,[self::ERR_RATE_LIMIT,self::ERR_RATE_LIMIT_2],true)thrownewWechatRateLimitException(发送频率超限,$errcode,$errmsg),$errcodeself::ERR_NO_PRIVILEGEthrownewWechatSendException(应用无发送权限,$errcode,$errmsg),$errcodeself::ERR_SYSTEM_BUSYthrownewWechatSendException(企业微信系统繁忙请稍后重试,$errcode,$errmsg),defaultthrownewWechatSendException(发送失败: {$errmsg},$errcode,$errmsg),};}}---3.控制器?php// app/Controller/NewsController.phpnamespaceApp\Controller;use App\DTO\NewsArticle;use App\Exception\WechatRateLimitException;use App\Exception\WechatSendException;use App\Service\WechatMessageSender;use Hyperf\HttpServer\Annotation\Controller;use Hyperf\HttpServer\Annotation\PostMapping;use Hyperf\HttpServer\Contract\RequestInterface;use Hyperf\HttpServer\Contract\ResponseInterface;#[Controller(prefix:/api)]classNewsController{publicfunction__construct(privatereadonly WechatMessageSender $sender,privatereadonly RequestInterface $request,privatereadonly ResponseInterface $response,){}/** * POST /api/news/send-to-department * * Body: * { * party_ids: [2, 5], * articles: [ * { * title: 标题, * description: 描述, * url: https://example.com, * picurl: https://example.com/cover.jpg * } * ] * } */#[PostMapping(path:/news/send-to-department)]publicfunctionsendToDepartment():\Psr\Http\Message\ResponseInterface{$partyIds$this-request-input(party_ids,[]);$rawList$this-request-input(articles,[]);if(empty($partyIds)){return$this-fail(400,party_ids 不能为空);}if(empty($rawList)){return$this-fail(400,articles 不能为空);}if(count($rawList)8){return$this-fail(400,图文消息最多 8 条);}// 构建 DTO校验必填字段try{$articles$this-buildArticles($rawList);}catch(\InvalidArgumentException $e){return$this-fail(400,$e-getMessage());}try{$this-sender-sendNewsToDepartment($partyIds,$articles);return$this-response-json([code0,message发送成功]);}catch(WechatRateLimitException $e){return$this-fail(429,发送频率超限请稍后重试,$e-getErrcode());}catch(WechatSendException $e){return$this-fail(500,$e-getMessage(),$e-getErrcode());}}/** * param array[] $rawList * return NewsArticle[] */privatefunctionbuildArticles(array $rawList):array{returnarray_map(function(array $item,int$index){if(empty($item[title])){thrownew\InvalidArgumentException(第 .($index1). 条图文缺少 title);}if(empty($item[url])){thrownew\InvalidArgumentException(第 .($index1). 条图文缺少 url);}returnnewNewsArticle(title:$item[title],description:$item[description]??,url:$item[url],picurl:$item[picurl]??,);},$rawList,array_keys($rawList));}privatefunctionfail(int$status,string $message,int$errcode0):\Psr\Http\Message\ResponseInterface{return$this-response-json(array_filter([code$status,message$message,errcode$errcode?:null,]))-withStatus($status);}}---4.请求/响应示例 请求 POST/api/news/send-to-department{party_ids:[2,5],articles:[{title:Q2 季度公告,description:关于 Q2 绩效考核安排的通知,url:https://example.com/notice/q2,picurl:https://example.com/images/q2-cover.jpg},{title:系统升级通知,description:本周六凌晨 2 点系统维护,url:https://example.com/notice/upgrade}]}企业微信实际发出的 payload{toparty:2|5,msgtype:news,agentid:1000001,news:{articles:[{title:Q2 季度公告,description:关于 Q2 绩效考核安排的通知,url:https://example.com/notice/q2,picurl:https://example.com/images/q2-cover.jpg},{title:系统升级通知,description:本周六凌晨 2 点系统维护,url:https://example.com/notice/upgrade}]}}---关键限制 ┌────────────────┬────────────────────────────────────────────────────┐ │ 限制 │ 说明 │ ├────────────────┼────────────────────────────────────────────────────┤ │ 最多8条图文 │ 超出企业微信直接报错 │ ├────────────────┼────────────────────────────────────────────────────┤ │ toparty 多部门 │ 用|拼接最多100个 │ ├────────────────┼────────────────────────────────────────────────────┤ │ picurl │ 必须是可公网访问的 HTTPS 图片否则不显示封面 │ ├────────────────┼────────────────────────────────────────────────────┤ │ invalidparty │ errcode0但响应中有此字段时说明部分部门 ID 无效 │ ├────────────────┼────────────────────────────────────────────────────┤ │ 发送频率 │ 每个应用每天不超过30万次每分钟不超过1000次 │ └────────────────┴────────────────────────────────────────────────────┘

更多文章