1. 先想清楚:PHP 接飞书机器人到底解决什么问题
几个月前接手一个老项目的告警改造。原来那套东西是往邮箱里塞日志,值班同事半夜爬起来翻邮件,翻到第三条已经分不清哪条是新的了,最后干脆不看。我把推送出口换成飞书群机器人,用 PHP 写了不到两百行代码,告警、定时报表、审批提醒全走同一个出口,红黄绿三级颜色一摆,群里一眼就能看明白哪条要立刻处理。这就是我写下这些文字的直接动机——不搬官方 API 文档,而是把 PHP 对接飞书机器人这条链路上,我实际踩过的、文档里往往一句带过的东西摊开来讲。
先把这件事讲透:飞书自定义机器人本质上就是一个 Webhook 地址,你往这个 HTTPS 地址 POST 一段 JSON,群里就多出一条消息。PHP 在这里的角色是生产者加组装者——把业务数据拼成飞书认识的 JSON 结构、算签名、发出去、处理返回值、失败重试。适合谁参考?我的判断是三类人。第一类,手上有一堆 PHP 老项目,想加即时通知又不想为此引入一套新服务;第二类,做运维监控、定时任务、报表分发的同学,缺一个稳定的消息出口;第三类,刚学完 PHP 语法想找个能立刻看到成果的实战项目的人。第三类我要多说一句,飞书机器人是个特别好的练手对象,它有真实的 HTTP 交互、有签名算法、有错误码、有频率限制、有编码坑,比写一个图书管理系统有意思得多,学完能直接用在实习或者实训项目里。
1.1 三个最典型的落地场景
我做过也见过最多的场景有三个。
第一个是异常告警。PHP-FPM 错误日志、队列积压长度、接口 5xx 数量、数据库连接数,这些指标越过阈值之后,最合适的出口就是群消息。这里的关键从来不是"发得出去",而是"发得让人看得懂、不刷屏"。一个接口报错触发两百条告警,第二天所有人都会把这个群静音,那这套告警就等于废了。所以我后面会花不少篇幅讲分级、聚合和限流。
第二个是定时报表。财务、运营、客服对"每天早上九点把昨天的数据发群里"这件事有刚性需求。PHP 干这个有天然优势:定时任务里跑几个 SQL,把结果集拼成消息推出去,整条链路简单到不需要引入任何中间件。热词里搜"飞书机器人发送表格"的人不少,说明卡在这一步的人很多——飞书机器人其实没有原生的表格消息类型,你必须自己想办法排版,这点我在第 4 章会给出两种能直接抄的方案。
第三个是系统内部的事件通知。订单状态变更、审批流节点流转、库存预警,这些带着明确业务语义的消息,比技术告警更要注意"谁该看到什么"。仓库缺货提醒不该发到研发群,线上 500 也不该发到财务群。这时候就会牵扯到多机器人、多群的拆分,问题也就不只是"发一条消息"那么简单了。
1.2 为什么是机器人 Webhook,而不是邮件、短信或者自建推送
这个选择我做过实打实的对比,不是拍脑袋定的。
邮件的问题在时效性和可读性上都很致命。告警发邮件,很多时候直接被丢进垃圾箱,或者被当成"稍后处理"的待办堆起来,等真出事的时候早就淹没在几十封未读里了。短信的硬伤是成本和篇幅,一条 70 个字的限制,连一段完整的堆栈都塞不进去,更别提格式化排版了。
自建推送听起来自由,比如自己做长连接、自己搭 WebSocket 服务,但维护成本完全不是一个量级:通道稳定性、客户端保活、消息可达性,每一项都是坑,小团队根本没有精力去填。拿一个现成的协作工具当消息出口,本质上是把"送达"这件事外包出去,在中小团队里这是性价比最高的做法。
自定义机器人相比飞书的应用机器人,最大的优势是零门槛:不需要创建企业应用、不需要申请权限、不需要处理用户授权,拿到 Webhook 地址就能发消息,五分钟能跑通。代价也很明确,它只能单向发,接收不到用户回复,也读不了群里的消息。所以我的建议是:动手之前先想清楚你要的是"通知"还是"交互"。只要通知,自定义机器人足够,别一上来就搞应用鉴权那一套,纯属自找麻烦;要做双向的,再去看第 7 章的思路。
2. 建机器人与安全校验:十分钟搞定前期准备
前期准备这一步看着简单,但真正卡人的地方全在细节里。我见过同事折腾了半小时,最后发现是复制 Webhook 地址的时候多带了一个空格,返回一个莫名其妙的参数错误,怀疑了半天人生。所以这一章我把创建流程、安全机制选型、以及服务器侧需要提前确认的环境项都列清楚,你照着走一遍,十分钟以内能拿到一个可用的地址。
另外要提前说一句:群机器人一旦建好,任何拿到这个 Webhook 地址的人都能往群里发消息,所以它的保管等级要按密钥对待。我的习惯是把它写进环境变量或者配置中心,绝对不硬编码进代码仓库。有热词搜"php网站源码"这类内容的同学尤其要注意,源码一旦外泄,Webhook 也跟着外泄,被人拿去发垃圾消息,你的机器人在几分钟内就会被平台限制甚至停用。
2.1 群机器人的创建与 Webhook 拿到手
流程本身不复杂,但我把几个容易出错的点标出来。
第一步,进入你要接收消息的群,点群设置,找到群机器人,添加机器人,选择自定义机器人。第二步,填一个名字和头像,名字建议带业务前缀,比如"订单告警"、"运维值班",因为一个群里可能挂好几个机器人,名字起得含糊,后面排查都不知道是谁发的。第三步,安全设置这一步不要跳过,具体选哪个看下一节。第四步,拿到以https://open.feishu.cn/open-apis/bot/v2/hook/开头的地址,这就是后续所有代码要用的东西。
注意:这个地址复制之后,建议立刻粘贴到记事本里做一次首尾空格清理再保存。带空格、带换行、被聊天软件自动加上的不可见字符,都会导致签名或者参数校验失败。
2.2 签名校验、IP 白名单、关键词,三者怎么选
平台给了三种安全机制,很多人是随手勾一个,其实它们适用的场景完全不同,我一个个说。
签名校验是最推荐的。它用时间戳加密钥做 HMAC-SHA256,每次请求的签名都不一样,即使地址泄露,别人没有密钥也发不出去。缺点是客户端要实现一段签名逻辑,多二十行代码。但这点成本换来的是安全性的质变,我认为完全值得,尤其是 Webhook 会被写进多个项目配置文件的情况下。
IP 白名单适合服务器出口 IP 固定的场景。如果你的 PHP 跑在自有机房或者有固定弹性 IP 的云主机上,勾上这个再填 IP,是最省事的方案,代码里什么都不用改。但它的局限也很明显:一旦服务器换 IP、加了负载均衡、或者走了容器化的动态出口,消息立刻就发不出去了,而且报错信息通常不会告诉你是白名单的问题,排查起来很费劲。
关键词校验是最弱的方案,只要消息里包含设定好的关键词就放行。它的实际价值是"防止误用"而不是"防止攻击"。我见过有人把关键词设成"告警",结果所有消息都得带着"告警"两个字,格式全被污染了,得不偿失。
我的选择是签名校验为主,IP 白名单作为附加层。两个都开的话,需要同时满足,安全性最高,但也意味着换机器的时候要改两个地方,你自己权衡。
2.3 服务器侧环境自查清单
代码还没写之前,先在服务器上跑一遍这条命令看看扩展和版本,能省掉后面一半的困惑。
php -v php -m | grep -E 'curl|openssl|json|mbstring'必须有的是 cURL(发请求)、OpenSSL(hmac 计算依赖它)、JSON(编解码)、mbstring(中文截断时用得上)。PHP 7.4 以上我建议直接用,我自己现在是 PHP 8.1,类型声明写得舒服,报错信息也更清楚。如果你在 Windows 上做本地开发,Nginx + PHP 的组合记得确认php.ini里extension=curl那一行前面的分号去掉了,Windows 下这个坑特别高频。用 VS Code 开发的话,推荐装 PHP Intelephense 插件,写curl_setopt_array的数组键名时补全能省不少事,拼错键名在 PHP 里是不会报错的,只会静默失效,这是最阴的一类 bug。
另外确认一下服务器能正常解析open.feishu.cn域名,有些内网环境的 DNS 策略比较严,出网被拦住的概率不低,这一点后面网络排查章节还会提到。
3. 消息体结构与签名算法,一次讲透
到了核心部分。飞书机器人这套接口的协议设计其实很克制,总共就那么几个字段,但每个字段都有讲究,尤其是签名算法和消息类型的对应关系,我见过太多人在这两处反复栽跟头。这一章我会把结构差异、签名推导过程、以及三条硬性限制都捋一遍,理解之后你再写代码基本就是填空。
我的经验是,不要急着写 PHP,先用 curl 命令在终端里手动发一条最简单的文本消息。跑通了,说明 Webhook 地址、网络、安全设置三个环节都没问题,这时候再写代码,出问题就一定是代码的问题,排查范围一下子缩小一半。这个"先命令行后代码"的习惯,我强烈建议你养成。
3.1 文本、富文本、卡片、图片四类消息的字段差异
飞书机器人支持的消息类型不少,常用的有四类,它们的顶层字段位置不一样,这是最容易出错的地方。
| msg_type | 内容字段位置 | 典型用途 | 上手难度 |
|---|---|---|---|
| text | content.text | 最简单的一行字通知 | 极低 |
| post | content.post.zh_cn | 标题加多行富文本、带链接 | 低 |
| interactive | 顶层 card 字段 | 卡片、按钮、分栏、颜色标记 | 中 |
| image | content.image_key | 推送生成的图表截图 | 中高 |
看清楚第三行:卡片消息的内容是放在顶层的card字段里,而不是像其他类型那样塞进content。我当初就是照着 text 的结构去拼卡片,结果一直返回参数错误,翻文档才发现位置根本不对。这个差异值得单独记一笔。
还有一点,post类型的内容按语言分组,中文是zh_cn,内容是一个二维数组,外层数组代表段落,内层数组代表同一行里的多个元素。这个二维结构第一次见确实绕,我在 4.3 会用具体例子说明。
图片消息有个前置条件:image_key得先上传图片才能拿到。上传图片接口需要 tenant_access_token,属于应用级鉴权,自定义机器人拿不到。所以如果你打算用 PHP 生成图表再推送,比如用 GD 或者 Imagick 画一张趋势图,那要么自己申请一个企业应用来上传,要么把图片存到自己的图床上、用富文本消息发链接。这一点想清楚再动手,能省掉一天的无效尝试。
3.2 签名算法逐行拆解(最容易搞反的一步)
签名这段代码只有五行,但我敢说八成的人第一次都写错,而且错法很统一:把 key 和 data 的位置搞反了。
飞书的规则是这样的:先拼出一个字符串,内容为时间戳、换行符、密钥三者的连接,也就是timestamp + "\n" + secret。然后把这个字符串当作 HMAC-SHA256 的密钥,对空字符串做哈希运算,得到二进制摘要,最后做 Base64 编码。注意,是拿拼接串当 key,拿空字符串当 data,这个方向不能反。
private function genSign(int $timestamp, string $secret): string { // 注意顺序:时间戳在前,换行符,密钥在后 $stringToSign = $timestamp . "\n" . $secret; // 第四个参数 true 表示返回原始二进制,不能省 $raw = hash_hmac('sha256', '', $stringToSign, true); return base64_encode($raw); }第二个高频错误是hash_hmac的第四个参数。它默认返回十六进制字符串,只有传true才返回原始二进制。如果你漏了这个true,得到的是十六进制串,再 Base64 一次,结果和正确签名完全是两码事,接口会直接告诉你签名不匹配,但你盯着计算过程怎么看都对。我第一次遇到这个问题,来回核对了四十分钟。
第三个细节是时间戳的有效期。签名里的 timestamp 和服务器当前时间不能差太多,官方给的口径是一小时左右,所以不要在应用启动时算一次签名然后一直复用,每次发消息现算,一秒钟的事。时间戳在 JSON 里是以字符串形式传的,不是数字,虽然大多数情况下传数字也能过,但按规范传字符串更稳妥。
3.3 长度、频率与编码三条红线
三条限制,踩过一次就会长记性。
长度方面,单条消息体的大小是有上限的。我实测塞进去几千个汉字没问题,但如果把一整张数据表完整地拼成文本,几万字符往里灌,接口会直接返回参数错误。稳妥的做法是做好截断和分片,比如超长内容只发前 N 行,末尾加一句"完整内容见日志";或者按固定行数拆成多条发送,拆的时候顺手在每个分片的标题里带上"1/3"这样的序号标记,读的人心里有数。
频率方面,机器人有明确的限流,我印象里每个机器人每分钟能发的条数在百条量级、每秒在几条量级,具体数字以官方文档为准,但你要知道这个限制是真实存在的,而且触发之后返回的是限流错误而不是成功。真遇到批量推送,第 5 章的队列方案就是为这个准备的。
编码方面,json_encode默认会把中文转成\uXXXX的形式。这个转义本身飞书能正确解析,消息不会乱码,所以功能上不受影响。但如果你要打印日志排查、或者把请求体存起来做审计,满屏的反斜杠 u 看着非常痛苦。我的习惯是固定加上JSON_UNESCAPED_UNICODE参数,让中文原样输出。同时要注意,如果你的源数据是从数据库里读出来的 GBK 编码内容,那必须在拼装之前统一转成 UTF-8,否则一定会出现乱码,而且乱码出现在群里,所有同事都能看到,比较尴尬。
4. 手写一个能直接抄的 PHP 推送类
前面都是铺垫,这一章上代码。我按"能跑起来"到"能上生产"的顺序写,你可以先抄第一个版本跑通,再逐步替换成完整版。所有代码我都精简过,去掉了业务耦合,你可以直接扔进自己的项目里改。
先说明我的组织方式:一个类文件管发消息,一个配置文件管 Webhook 和密钥,业务侧只调用sendText、sendPost、sendCard三个方法,不关心签名和 cURL 细节。这个分层不是洁癖,是因为后面你一定会遇到"要换群"、"要加新机器人"、"要临时降级成只写日志"这类需求,分层做好了,改动量能控制在一行。
4.1 最小可用版本:二十行发出去第一条消息
先跑通,别管优雅不优雅。
<?php $webhook = getenv('FEISHU_WEBHOOK'); $payload = [ 'msg_type' => 'text', 'content' => ['text' => '第一条测试消息,来自 PHP'], ]; $ch = curl_init($webhook); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, ]); $res = curl_exec($ch); curl_close($ch); echo $res;跑完看返回,{"code":0,"msg":"success"}就说明整条链路通了。这里有两个点我要专门讲。
一是CURLOPT_RETURNTRANSFER。不设置它的话,curl_exec会把响应直接输出到页面,你拿不到返回值,也就没法判断发送成功还是失败。在命令行脚本里影响不大,在 Web 请求里会污染响应体,必须设成 true。
二是Content-Type头。飞书接口认的是application/json,你如果不显式设置,cURL 会默认用application/x-www-form-urlencoded,服务端可能解析不出来,返回一个让人摸不着头脑的参数错误。这个头建议写进封装里,一劳永逸。
4.2 封装成类:签名、超时、重试、错误处理
跑通之后就该上封装了。下面这个类是我在用的版本,去掉了业务相关的东西,保留签名、超时控制、错误返回统一处理。
<?php class FeishuBot { private string $webhook; private string $secret; public function __construct(string $webhook, string $secret = '') { $this->webhook = $webhook; $this->secret = $secret; } private function genSign(int $timestamp): string { $stringToSign = $timestamp . "\n" . $this->secret; return base64_encode(hash_hmac('sha256', '', $stringToSign, true)); } /** * 统一出口,返回 ['code'=>int,'msg'=>string] */ public function post(array $payload, int $timeout = 5): array { if ($this->secret !== '') { $ts = time(); $payload['timestamp'] = (string)$ts; $payload['sign'] = $this->genSign($ts); } $ch = curl_init($this->webhook); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $body = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); curl_close($ch); if ($errno !== 0) { return ['code' => -1, 'msg' => 'curl error: ' . $error]; } $res = json_decode($body, true); if (!is_array($res)) { return ['code' => -2, 'msg' => 'invalid response: ' . $body]; } return $res; } public function sendText(string $text): array { return $this->post([ 'msg_type' => 'text', 'content' => ['text' => $text], ]); } }几个设计上的考量值得说清楚。
CURLOPT_CONNECTTIMEOUT我设成 2 秒,CURLOPT_TIMEOUT设成 5 秒。为什么分开设?因为"连不上"和"连上了但对方不响应"是两回事。IP 不通、DNS 挂了属于前者,2 秒足够暴露问题;服务端处理慢属于后者,给 5 秒已经非常宽裕。如果你把两者合成一个大的超时,一旦网络不通,每条告警都要卡住十秒,在高频调用的场景下会拖垮整个业务进程。这一点在把推送放在同步流程里的时候尤其重要。
另外,我没有在类里做自动重试。这是个刻意的选择。重试逻辑应该放在调用方或者队列消费端,因为只有那里才知道这次推送是否幂等、失败了要不要重发、重发几次合适。放在底层无脑重试三次,很可能把一条本该只发一次的消息发三遍,在告警场景下就是妥妥的刷屏事故。
4.3 把数组变成"表格":两种可行排版方案
这是被问得最多的问题。数据库查出来的二维数组,怎么在群里显示得像张表?
先说结论:飞书的卡片 Markdown 组件支持加粗、斜体、链接、@人,但不支持 Markdown 表格语法。你写进去的竖线分隔符会原样显示出来,非常难看。所以只有两条路。
第一条路是纯文本对齐,简单粗暴,适合列数少、内容短的场景。核心是计算每列的最大宽度,用空格补齐。中文宽度和英文不一样,一个汉字显示占两个字符位,所以不能直接用strlen补齐,要用mb_strwidth算显示宽度。
function padLine(array $cells, array $widths): string { $parts = []; foreach ($cells as $i => $cell) { $w = $widths[$i]; $len = mb_strwidth($cell, 'UTF-8'); $parts[] = $cell . str_repeat(' ', max(0, $w - $len)); } return implode(' ', $parts); } $rows = [ ['订单号', '金额', '状态'], ['SO20240315001', '1280.00', '待发货'], ['SO20240315002', '340.50', '已完成'], ]; $widths = []; foreach ($rows as $row) { foreach ($row as $i => $cell) { $w = mb_strwidth($cell, 'UTF-8'); $widths[$i] = max($widths[$i] ?? 0, $w); } } $lines = array_map(fn($r) => padLine($r, $widths), $rows); $text = "**昨日订单概览**\n" . implode("\n", $lines);第二条路是用卡片的column_set组件做真正的分栏,视觉效果最接近表格,还能加背景色。写法是每个单元格一个 column,整行包一个 column_set。缺点是 JSON 层级很深,手写容易漏字段。
function buildRow(array $cells, string $bg = 'default'): array { $columns = []; foreach ($cells as $cell) { $columns[] = [ 'tag' => 'column', 'width' => 'weighted', 'weight' => 1, 'vertical_align' => 'top', 'elements' => [[ 'tag' => 'div', 'text' => ['tag' => 'lark_md', 'content' => $cell], ]], ]; } return [ 'tag' => 'column_set', 'flex_mode' => 'none', 'background_style' => $bg, 'columns' => $columns, ]; }两种方案我都用过。列数在三列以内、内容偏短的,我选文本对齐,简单、调试快、一眼能看出问题。列数多、需要颜色区分状态的,我选分栏卡片,多花点时间搭 JSON,但值班的人看一眼就知道哪些是异常行。
注意:分栏卡片的
weight是相对权重,不是像素宽度。如果某列内容特别长,光调权重没用,卡片会自动换行。遇到长文本列,我一般直接把它放到表格下方的补充说明里,不硬塞进格子。
4.4 接业务:异常告警与定时报表的落点
代码有了,接下来讲怎么落地到实际业务里。
异常告警我建议放一个统一的入口函数,所有业务代码都调它,而不是各处直接拼消息。这个入口做三件事:判断告警级别、做去重和限流、决定发到哪个群。级别用一个简单的颜色映射就够了,卡片标题用red、orange、green三种 template 区分。去重我习惯用 Redis 做,同一个错误指纹在五分钟内只发一次,指纹就用"文件名+行号+错误摘要"的哈希,简单有效。热词里有人搜"php redis 消费组",如果你用的是 Redis Stream 做消息队列,那这套去重逻辑可以顺手挂在消费端上,一鱼两吃。
定时报表的落点更清晰。用系统的 crontab 或者 supervisor 起一个常驻脚本,每天固定时间跑。报表内容里如果需要对比"上个月同期",那就要算日期间隔。这里提一个我踩过的坑:直接用两个时间戳相减再除以 30 天是错的,月份长度不一样,结果会漂。要么用DateTime::diff拿准确的年月数,要么在 SQL 里用日期函数处理,别在 PHP 里手算。
另外报表脚本要有自我保护。跑失败了要能发出一条"报表生成失败"的消息,而不是静默退出,否则你会以为一切正常,实际上是脚本早就挂了。这个反向告警的思路,是我做运维这些年觉得最值得分享的一条经验:监控系统本身也要被监控。
5. 队列削峰与多任务并发,别把机器人打挂
单条消息的发送很简单,麻烦的是"批量"和"并发"这两个词。业务量一上来,比如批量推送一千个用户的审批提醒,或者在循环里逐条发告警,直接同步发就会撞上限流,而且会把 PHP 进程长时间占住。这一章讲怎么加缓冲层。
5.1 用 Redis 队列给推送加一层缓冲
思路很直白:业务侧不直接发消息,而是把消息体序列化之后塞进 Redis 队列,另一个常驻脚本按固定速率从队列里取出来发送。
生产者端代码就两三行:
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->auth(getenv('REDIS_PASSWORD')); $redis->lPush('feishu:queue', json_encode([ 'webhook' => $webhook, 'payload' => $payload, 'retry' => 0, ], JSON_UNESCAPED_UNICODE));用lPush加brPop的组合就够了,不需要上 Redis Stream 那么重的方案。如果你需要多个消费进程并行、还要求每条消息只被消费一次,那用XREADGROUP的消费组模式更合适,代价是要处理消息确认(XACK)和未确认消息的回收,复杂度上去了。我的原则是:单机、量不大,列表就够;多机、要保证不丢,才上 Stream。
消费者端的关键是控制速率。你可以每发一条usleep一下,把发送频率压在安全线以内。
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1); while (true) { $item = $redis->brPop(['feishu:queue'], 5); if (!$item) { continue; } $job = json_decode($item[1], true); $bot = new FeishuBot($job['webhook'], getenv('FEISHU_SECRET')); $res = $bot->post($job['payload']); if (($res['code'] ?? -1) !== 0) { // 失败处理见下一节 } usleep(250000); // 250 毫秒,约 4 条/秒 }usleep那个数值不是随便写的。假设机器人限制是每秒 5 条,我留出余量跑 4 条,也就是每条间隔 250 毫秒。这个速率单看很慢,但一千条消息四分多钟就能发完,对绝大多数业务场景完全够用。用队列换来的最大好处是:业务侧永远不阻塞,推送慢一点无所谓,但接口响应不能慢。
注意:
brPop的阻塞读取会受 Redis 客户端读超时影响,长连接场景下建议把OPT_READ_TIMEOUT设为 -1,或者在循环外做超时重连,否则消费者可能在空闲几分钟后莫名其妙地断掉,而且不报错,只是不再消费了。这个坑我排查过整整一个下午。
5.2 消费端的重试、幂等与失败落库
队列加上了,接下来是可靠性。
重试策略我用的是固定间隔加次数上限,三次封顶,间隔递增,比如 1 秒、5 秒、15 秒。超过三次就放弃,把这条消息和错误信息写成一行日志,用error_log或者写进一个专门的失败表。为什么不全量落库?因为成功的消息没必要占存储,只有失败的才有复盘价值。这一点和 PHP 里的错误处理逻辑是一致的:正常的路径轻量,异常的路径留痕。
幂等这件事要想清楚。如果你的消息本身可能因为重试导致重复发送,那就得在业务层做标记。最省事的办法是在消息内容里带一个业务唯一键,比如订单号,然后在发送前查一次"这个订单的提醒是不是发过了"。用 Redis 的SETNX加过期时间就够了,键名用业务唯一键,过期时间设成一天,成本极低。热词里"php队列"被反复搜,说明很多人在这个环节纠结,我的经验是别过度设计,先把发送做稳,再去考虑去重。
还有一个容易被忽略的点:消费进程挂了谁来发现。我的做法是让消费脚本每隔几分钟往一个监控地址打个点,或者干脆让脚本在正常情况下也每天发一条心跳消息到运维群。这样一旦消息断了,你立刻能意识到不是"今天没告警"而是"告警通道挂了"。这个思路听起来有点笨,但极其有效。
6. 踩坑实录与排查速查表
前面几章讲了怎么做对,这一章讲做错了会看到什么。我把遇到过的典型问题整理成表,方便你直接对照。
6.1 高频错误码对照与定位思路
飞书接口的返回体里code和msg两个字段最有价值,msg往往比code更直接。我实际遇到过的几类问题:
| 返回信息特征 | 大概率原因 | 排查动作 |
|---|---|---|
| 签名不匹配 | 签名算法写反,或漏了二进制参数 | 重看 3.2 节,重点查hash_hmac第四个参数 |
| 参数错误 | JSON 结构不对,或字段位置放错 | 打印请求体原文,逐字段对照消息类型 |
| 频率超限 | 短时间内发送太多 | 加队列和间隔,检查是否有循环直发 |
| 关键词不匹配 | 安全设置选了关键词但消息没带 | 改成签名校验,或调整消息内容 |
| 无响应或超时 | 网络、DNS、出口被拦 | 用 curl 命令行直连测试 |
| 中文乱码 | 源数据编码不是 UTF-8 | 在拼装前统一做编码转换 |
排查的第一原则是打印原始请求体。很多问题看一眼 JSON 就明白了,比如某个字段少了一层嵌套、字符串里多了个多余的空格。我习惯在开发环境里把json_encode之后的字符串写进日志文件,出问题直接翻日志,比在代码里逐行推理快十倍。
第二原则是分层验证。先命令行,再最小脚本,最后业务代码。每次只引入一个变量,问题出现在哪一层就一目了然。这个思路和调数据库、调第三方接口是一样的,是通用的排查方法。
6.2 中文、换行、JSON 编码的三个隐形坑
这三个坑都属于"看起来没问题但就是不生效"的类型。
第一,换行符。文本消息里的换行要用真正的换行符\n,不是字面上的两个字符。而且在 JSON 里编码的时候要注意转义。我一般是在 PHP 字符串里写"\n"(双引号),而不是'\n'(单引号),单引号里的\n会被当成反斜杠加字母 n,群里显示出来就是一行带反斜杠的字符串。这个错误我见过太多次。
第二,中文转义。前面提过,JSON_UNESCAPED_UNICODE建议常开。多补充一点:如果你的富文本内容从数据库读出来是 GBK,那必须转成 UTF-8 再拼装。可以在连接层统一设置字符集,比如 PDO 连接串里带上charset=utf8mb4,从源头解决,比在上层做转换干净得多。
第三,控制字符。从日志文件、异常堆栈里截取的内容里可能带有不可见控制字符,比如制表符、回车符,这些在 JSON 里会引发解析问题。稳妥做法是在拼装前对内容做一次清洗,把控制字符替换成空格或者直接剔除,尤其注意\r,Windows 上生成的日志文件几乎必然带它。
6.3 网络层的坑:超时、DNS、证书
网络问题的表现通常是"没反应",具体是超时还是 DNS 挂了,得分开看。
超时我前面讲过,连接超时和响应超时要分开设。如果你发现告警延迟特别大,先查是不是CURLOPT_TIMEOUT设得太长,再看是不是每次发送都在等 DNS 解析。DNS 这块有个实用技巧:如果 Webhook 域名解析出来的 IP 很稳定,可以在/etc/hosts里写死,省掉每次解析的时间,在高频发送场景下能省下可观的毫秒数。
证书方面,CURLOPT_SSL_VERIFYPEER建议保持开启。有些人在测试环境图省事设成 false,之后忘了改回,上生产就成了一个隐形的安全弱点。如果确实遇到证书报错,正确做法是更新服务器的 CA 证书包,而不是关掉校验。我踩过一次,服务器上的 CA 包是几年前的老版本,导致校验失败,更新一下就好了。
最后是一个日志习惯:每次发送失败,把 curl 的错误号和错误信息一起记下来。curl_errno返回的数字含义很明确,比只看接口返回的 msg 有用得多。我会在日志里带上时间戳、目标群、错误号、错误信息四项,出问题时 grep 一下就能定位是哪一类问题集中爆发。
7. 从单向推送到双向交互,后面还能怎么扩展
自定义机器人只能发不能收,这条路走到一定程度就会碰到天花板。什么时候该升级?我的判断标准很简单:当你开始需要"人在群里点一下按钮就触发某个动作"的时候,就该换成企业应用了。
走应用这条路,核心多出来三件事。一是事件订阅,你要在应用配置里填一个公网可访问的回调地址,平台会先发一个验证请求过来,你要把请求体里的 challenge 原样返回,验证通过之后才会正式推送事件。二是验签和解密,事件体可能是加密的,需要用到应用配置里的 Encrypt Key 做解密,算法是 AES-256-CBC,这块 PHP 的 openssl 扩展能直接支持。三是权限申请,读取群消息、发消息、获取用户信息,每一项都要单独申请并等待审批,这一步需要时间,提前规划。
还有一个绕不开的话题是幂等。事件推送在网络波动下可能重复投递,同一个 event_id 可能会来两次。处理方式还是那个老办法,拿 event_id 做 Redis 的SETNX去重,处理过的直接返回成功,不重复执行业务逻辑。这个设计在任何消息驱动的系统里都是通用经验,不限于飞书。
我自己在做的过程中最深的一个体会是,接口文档解决的是"能不能通",而工程经验解决的是"稳不稳"。签名写对了,消息能发出去,这只是第一步;后面决定这套东西好用不好用的,是分级、去重、限流、失败落库、心跳监控这些看起来跟接口毫无关系的细节。我第一版代码只有三十行,能用,但每出一次线上问题就补一条规则,攒到现在三百多行,反而觉得代码变简单了——因为每个分支处理的问题都清清楚楚,没有一处是猜的。如果你正在做类似的事情,我的建议是先跑通最小版本,别一开始就设计得很复杂,等真实流量和真实故障教会你该怎么改。