1. 从一个“消息发不出去”的深夜说起飞书群机器人Custom Bot的 Webhook 看起来是最好上手的集成方式之一在群里点几下拿到一条 URL往上一贴curl一发消息就进群了。我第一次接的时候也是这么想的结果从晚上十点折腾到凌晨一点消息要么石沉大海要么返回一堆看不懂的错误码要么格式全乱。后来陆续在几个项目里反复踩坑才把这条链路摸清楚。这篇内容就是把我踩过的坑、排查思路和最终稳定跑通的方案完整摊开讲。核心关键词是飞书 Webhook、群机器人、消息卡片、签名校验、频率限制。它适合三类人刚接触飞书机器人、准备把告警/通知接入飞书群的开发者已经接了但消息时好时坏、想搞清楚根因的人以及需要把 Webhook 做成稳定生产级通知通道的运维或后端同学。读完你应该能独立完成从建机器人到消息稳定送达的全过程并且知道每个参数为什么这么设。先说一个反直觉的结论飞书 Webhook 发不出去八成不是网络问题而是消息体格式、签名、或者安全设置三者之一出了问题。很多人第一反应是“是不是被墙了”“是不是 URL 过期了”然后开始瞎试其实方向从一开始就偏了。下面我按真实排查顺序一层层拆。2. 建机器人这一步安全设置选错后面全是坑2.1 三种安全设置到底该选哪个在飞书群里添加自定义机器人时安全设置有三个选项自定义关键词、IP 白名单、签名校验。很多人随手勾一个就过了结果后面发消息一直失败回头才发现是这里的问题。我先把三者的适用场景讲清楚这是后面所有坑的源头安全设置生效逻辑适合场景主要坑点自定义关键词消息文本里必须包含设定词固定格式的简单通知关键词大小写、位置敏感卡片消息容易漏IP 白名单只有白名单 IP 能调用服务器 IP 固定的后端服务出口 IP 变动、多机部署时漏配签名校验每次请求带时间戳签名生产环境、公网调用时间戳单位、签名算法最容易写错我的建议很直接只要不是纯内网固定 IP 的简单场景一律用签名校验。关键词方式看着简单但它对消息内容有强约束你发个富文本卡片关键词藏在某个字段里没被识别到消息就直接被拒而且报错信息往往不明确排查起来很痛苦。IP 白名单在容器化、弹性伸缩的环境里基本没法用出口 IP 随时会变。签名校验虽然多写几行代码但它是唯一不依赖消息内容、也不依赖网络环境的方式最稳。2.2 拿到 Webhook URL 之后先别急着写代码拿到那条https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx的 URL 之后先做一件事用最原始的方式验证它通不通。不要一上来就写业务代码那样出了问题你分不清是 URL 的问题还是代码的问题。最省事的验证方式是用命令行直接发一条最简单的文本消息curl -X POST -H Content-Type: application/json \ -d {msg_type:text,content:{text:测试消息}} \ https://open.feishu.cn/open-apis/bot/v2/hook/你的hook_id如果这一步就失败了那问题一定在安全设置或 URL 本身跟你的业务代码无关。如果这一步成功说明通道是通的后面再出问题就是消息体或签名的事。这个“分层验证”的思路是我排查所有 Webhook 问题的基本方法能帮你快速缩小范围。注意如果你开了签名校验上面这条不带签名的 curl 一定会失败返回签名相关的错误码。这是正常的说明签名校验生效了。验证时要么先临时关掉签名要么直接带上签名参数测。2.3 机器人被移出群聊或群被解散这个坑比较隐蔽。Webhook URL 是绑定到具体群聊的如果机器人被移出群、或者群被解散、或者群成员把机器人删了URL 就会失效返回的错误码通常是19001或类似的无效凭证类错误。这种情况你代码怎么改都没用得重新在群里添加机器人拿新 URL。我遇到过一次测试环境一切正常上线后告警全丢查了半天代码最后发现是运维同学清理群成员时把机器人踢了。所以上线前一定要确认机器人还在群里并且给它一个不会被误删的备注名。3. 签名校验90% 的失败都栽在这几行代码上3.1 签名到底是怎么算出来的飞书的签名机制是这样的你把时间戳 \n 密钥拼成一个字符串然后用 HMAC-SHA256 算法、以这个拼接串为密钥、对空字符串做摘要得到的结果就是签名。听起来有点绕我用代码说清楚。关键点在于密钥secret是拼接串的一部分而不是 HMAC 的输入数据。很多人第一反应是“用 secret 当 key对 timestamp 做 HMAC”这是错的方向反了。正确的逻辑是import hashlib import base64 import hmac import time def gen_sign(secret): timestamp str(int(time.time())) # 拼接串时间戳 换行 密钥 string_to_sign f{timestamp}\n{secret} # 以拼接串为 key对空字节做 HMAC-SHA256 hmac_code hmac.new( string_to_sign.encode(utf-8), digestmodhashlib.sha256 ).digest() sign base64.b64encode(hmac_code).decode(utf-8) return timestamp, sign我第一次写的时候就是搞反了把 secret 当 key、timestamp 当 data结果签名永远对不上返回19021签名校验失败。这个错误码是签名问题里最常见的看到它基本可以锁定是签名算法写错了。3.2 时间戳单位秒还是毫秒这是第二个高频坑。飞书签名用的时间戳是秒级10 位不是毫秒级13 位。很多语言默认给的是毫秒比如 JavaScript 的Date.now()返回的就是毫秒。如果你直接把毫秒时间戳拿去算签名服务端校验时会认为时间戳偏差过大直接拒绝。而且飞书对时间戳的有效期有要求请求时间与时间戳的偏差不能超过 1 小时3600 秒。这意味着你不能缓存一个签名反复用每次发消息都得重新算。我见过有人为了省事把签名写死成常量结果一小时后全部失效还以为是服务端抽风。// JavaScript 正确写法 const crypto require(crypto); function genSign(secret) { const timestamp Math.floor(Date.now() / 1000).toString(); // 注意除以1000 const stringToSign ${timestamp}\n${secret}; const sign crypto .createHmac(sha256, stringToSign) .update() .digest(base64); return { timestamp, sign }; }3.3 签名参数放在哪里算出来的timestamp和sign不是放在请求头里而是放在请求体的顶层字段和msg_type、content平级{ timestamp: 1700000000, sign: 计算出来的签名字符串, msg_type: text, content: { text: 带签名的消息 } }我踩过一次坑把 timestamp 和 sign 塞进了 header服务端完全读不到一直报签名缺失。飞书的文档里这个位置写得不算特别显眼容易看漏。记住签名参数在 body 顶层不在 header。3.4 一个完整的带签名请求示例把上面几点串起来一个能直接跑的 Python 请求长这样import requests import json def send_feishu(webhook, secret, text): timestamp, sign gen_sign(secret) body { timestamp: timestamp, sign: sign, msg_type: text, content: {text: text} } resp requests.post( webhook, headers{Content-Type: application/json}, datajson.dumps(body) ) return resp.json()实测下来只要gen_sign写对、时间戳用秒、参数放对位置签名校验基本不会出问题。这三个点任意一个错了都会失败而且错误码不一定能精确指向具体原因所以建议你先用官方文档里的示例值对一遍签名结果确认算法无误再接入业务。4. 消息体格式文本、富文本、卡片各有各的脾气4.1 msg_type 和 content 必须严格对应飞书的消息类型msg_type和内容结构content是强绑定的用错组合直接报错。常见的对应关系msg_typecontent 结构说明text{text: 内容}纯文本最简单post富文本嵌套结构支持标题、多段落、链接interactive卡片 JSON功能最强结构最复杂image{image_key: xxx}需先上传图片拿 key最常见的错误是msg_type写text但content里塞了卡片结构或者反过来。服务端会返回9499之类的格式错误。排查时第一件事就是核对 msg_type 和 content 是否匹配。4.2 富文本 post 的嵌套结构容易写错富文本post的结构是三层嵌套语言 → 段落 → 元素。很多人第一次写会少一层或者多一层。正确结构大致是这样{ msg_type: post, content: { post: { zh_cn: { title: 标题, content: [ [ {tag: text, text: 第一段文字 }, {tag: a, text: 链接, href: https://example.com} ] ] } } } }注意content是个二维数组外层是段落内层是段落里的元素。我见过有人写成了一维数组结果整条消息渲染异常或者直接报错。另外zh_cn这个语言 key 不能省即使你只发中文。4.3 卡片消息好看但最容易翻车消息卡片interactive是飞书通知里最常用的因为可以带颜色、按钮、分栏告警场景特别合适。但它的结构也最复杂坑最多。第一个坑是卡片版本。飞书卡片有 1.0 和 2.0 两个版本结构不兼容。你用 2.0 的语法去发 1.0 的卡片或者反过来都会渲染失败。建议统一用 2.0在卡片 JSON 里显式声明版本。第二个坑是字段名大小写和层级。卡片 JSON 里elements、fields、text这些字段名必须完全正确少一个字母或者大小写错了卡片就渲染不出来而且报错信息往往很模糊。第三个坑是模板变量。如果你用卡片模板template变量替换的语法是${变量名}但变量必须在template_variable里传且类型要匹配。传了个字符串给期望数字的变量卡片会显示异常。我的经验是卡片先用飞书的卡片搭建工具可视化编辑导出 JSON再往代码里贴。手写卡片 JSON 出错率极高可视化工具能帮你避开大部分结构问题。4.4 换行和特殊字符的处理文本消息里想换行直接写\n就行但要注意 JSON 转义。如果你在代码里拼字符串\n要写成\\n才能正确传递。这个坑在动态拼接告警内容时特别常见比如把多行日志拼进消息结果全挤成一行。另外消息内容里如果包含{、}、$这类字符在卡片模板里可能被当成变量语法解析导致渲染错误。动态内容建议做一次转义或者干脆用文本消息而不是卡片。5. 频率限制与错误码消息发太快会被静默丢弃5.1 飞书 Webhook 的频率限制飞书自定义机器人有频率限制默认是每分钟最多 100 条消息具体数值可能随官方调整以实际为准。超过限制后后续消息会被拒绝返回频率相关的错误码。这个坑在告警场景里特别致命。比如你的服务突然大面积报错告警系统瞬间触发几百条通知全往一个群里灌结果超过限制后面的告警全丢了而你还在纳闷为什么没收到。正确做法是做告警聚合和限流相同类型的告警在时间窗口内合并成一条或者用队列削峰控制发送速率。我一般的做法是在发送层加一个简单的令牌桶或者固定间隔保证每秒不超过 1-2 条宁可稍微延迟也不要丢消息。5.2 常见错误码对照表把我在实践中遇到过的错误码整理成表方便你快速定位错误码含义排查方向19001无效的 webhookURL 错误、机器人被移出群19021签名校验失败签名算法、时间戳单位、参数位置9499消息格式错误msg_type 与 content 不匹配19003频率超限发送太快需限流19002关键词不匹配消息未包含设定的关键词看到错误码先别慌对照这张表基本能锁定方向。最忌讳的是不看返回体直接重试那样只会浪费时间和触发限流。5.3 一定要打印返回体这是我最想强调的一条经验每次发送后务必把服务端返回的 JSON 完整打印出来。飞书的返回体里code和msg字段会告诉你具体哪里错了很多人只看 HTTP 状态码200 就以为成功了其实 body 里code是非 0 的失败。resp requests.post(webhook, jsonbody) result resp.json() if result.get(code) ! 0: print(f发送失败: {result}) # 一定要打出来我早期就是因为没打返回体白白排查了两个小时最后发现返回里明明白白写着签名错误。6. 把 Webhook 做成稳定生产通道的几个实操心得6.1 封装成独立模块别散落在业务代码里Webhook 发送逻辑一定要封装成一个独立函数或类统一处理签名、重试、限流、日志。散落在各处的话改一个参数要满项目找而且容易出现有的地方带签名有的地方不带。我的做法是封装一个FeishuNotifier类对外只暴露send_text、send_card几个方法内部把签名和错误处理都吃掉。6.2 重试要克制别把限流触发得更狠网络抖动导致的失败可以重试但重试次数不要超过 2-3 次且要加退避间隔。无脑重试在限流场景下会雪上加霜。我的策略是签名错误、格式错误这类确定性失败不重试重试也没用只有网络超时、5xx 这类临时性失败才重试且间隔递增。6.3 密钥不要硬编码Webhook URL 和 secret 都是敏感信息绝对不要硬编码在代码里提交到仓库。用环境变量或配置中心管理。我见过有人把带 secret 的代码推到公开仓库结果被人拿去乱发消息。这是安全底线不能省。6.4 上线前做一次“断网演练”正式接入告警前我会故意把 Webhook 配错比如改错 secret观察系统在发送失败时的行为是静默吞掉还是有日志、有降级、有兜底通知。通知通道本身也需要被监控否则它挂了你还不知道。可以加一个定时任务每隔一段时间发一条心跳消息确认通道存活。6.5 卡片内容别塞太多注意可读性告警卡片信息量要克制。我见过把整个堆栈、几十行日志全塞进一张卡片的手机上根本没法看。卡片只放关键信息服务名、错误类型、时间、跳转链接详细内容放日志平台卡片里给个链接。这样既清爽又实用。7. 我个人的几条经验总结折腾飞书 Webhook 这几年最大的体会是它本身不难难的是细节。签名算法、时间戳单位、参数位置、消息体结构、频率限制每一个都是小点但任何一个错了都会让你卡很久。我的建议是第一次接入时严格按“先验证通道、再验证签名、最后调格式”的顺序来每步都打印返回体别跳步。另外别迷信“复制粘贴就能用”的示例代码。不同语言、不同版本的库签名实现细节可能不一样一定要自己跑一遍验证。我现在的习惯是任何新的 Webhook 接入先写一个最小可运行脚本确认能发出消息再往业务里集成。这个习惯帮我省下了大量排查时间。最后分享一个小技巧如果你不确定签名算得对不对可以拿一个固定的 timestamp 和 secret手动算一遍和官方文档里的示例结果对比。对上了说明算法没问题剩下的就是参数位置和消息格式的事了。这个“锚点验证法”在排查任何加密签名问题时都好用。 SEO 优化官网定制响应式建站教育培训建站