1. 先想清楚:群机器人这条推送链路到底适合干什么
企微机器人消息推送这件事,看起来只是"调一个 HTTP 接口发条消息",但真正落到项目里,坑基本都不在接口本身,而在你把它当成什么来用。我见过太多团队把它当短信网关使,凌晨告警刷屏刷到全群静音,最后线上炸了却没人看见。所以动手写代码之前,先花十分钟把定位想清楚,比后面改十版代码都值。
简单说,企微群机器人就是群聊里的一个"机器人成员",你给它一个专属的 Webhook 地址,往这个地址 POST 一段 JSON,群里就会多出一条以机器人名义发出的消息。它不需要你申请企业应用、不需要走管理后台审批、不需要维护 access_token 的刷新逻辑,一个 URL 就能打通。这三条"不需要",是我在无数临时需求里优先选它的核心原因。
这篇文章面向三类人:一是刚接触企微自动化、想半小时内跑通第一条推送的朋友;二是已经能发消息、但被限流、被丢消息、被群成员投诉折腾过的同学;三是想把推送做成一个小而稳的公共模块、供多个项目复用的开发者。下面所有代码都是 Python 3.8+ 可直接跑的,接口字段以官方文档为准,我补充的是文档里没写、但实际会咬人的部分。
1.1 三种常见推送路径的取舍
把消息推给群里的人,常见有三条路,我按"接入成本 / 稳定性 / 灵活度"三个维度拉个表,你自己对号入座。
| 路径 | 接入成本 | 稳定性 | 灵活度 | 典型适用 |
|---|---|---|---|---|
| 群机器人 Webhook | 极低,一个 URL | 中,受 20 条/分钟限制 | 中,支持的 msgtype 有限 | 告警通知、构建结果、日报周报 |
| 自建应用消息推送 | 中,需创建应用、维护 token | 高,有完整错误码体系 | 高,可发给指定成员、可撤回 | 需要精准触达个人 |
| 邮件 / 短信通道 | 中,需配置第三方服务 | 高 | 低,格式单一 | 强合规场景、外部通知 |
我不是说哪个更好,而是说它们在项目里通常共存。我自己的习惯是:对人不对事的通知走应用推送,对群不对人的广播走机器人。比如"你的审批单待处理"发给指定的人,走应用;"今天的构建全绿了"发给整个研发群,走机器人。混着用反而最省钱。
还有一点容易被忽略:群机器人发出去的消息,机器人本身无法撤回,只能人工在客户端删。这意味着你发错群的成本是真实存在的,尤其是测试环境和生产环境用同一份配置的时候。我在后面配置管理那一节会专门讲怎么隔离。
1.2 Webhook 机制的本质与能力边界
从协议层面看,Webhook 就是一个无状态、单向的 HTTP 回调:你 POST,服务端收,返回一个{"errcode":0,"errmsg":"ok"},流程结束。没有长连接、没有回执确认、没有消息状态回调。这句话翻译成人话就是——你无法 100% 确定消息真的被人看见了。
这个特性带来两个直接后果。
第一,你要自己保证"发得出去"。网络抖动、超时、服务端偶发 5xx,都需要你的代码去重试。别指望 requests 默认行为,它不会自动重试,超时也不会主动告知你消息到底发没发成功。
第二,你要自己保证"发得不多"。errcode: 0只代表服务端接受了这次请求,不代表群成员愿意看。一次故障触发 200 条重复告警,比不告警更伤害信任。所以聚合、去重、静默这些逻辑,不是锦上添花,是必需品。
能力边界也要心里有数:群机器人只能发消息,不能收消息,用户回复它看不到;它不能主动拉人进群,也不能读取群成员列表;它的消息不支持已读回执。想清楚这些,你就不会在需求评审时被"让机器人回答一下用户问题"这种话带偏。
2. 动手前的准备:Webhook 地址、安全设置与限流规则
准备工作其实只有三步:建群、加机器人、拿地址。但每一步都藏着小细节,我按实际操作顺序走一遍。
2.1 拿到 Webhook 地址并选择消息格式
在群聊右上角的设置里找到"群机器人",添加之后会得到一个形如https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx的地址。这个key就是你的全部凭证,它等价于密码,任何拿到它的人都能往群里发消息。
关于消息格式,添加机器人时可以选"文本"或"图文"两种展示形态,但这只影响客户端里消息气泡的默认样式,不影响你用哪种msgtype发送。我一般选"图文",因为卡片样式的可读性更好,而且后续发 markdown 时视觉上也更协调。
这里有个实操细节:一个群最多可以加多少个机器人、一个机器人能不能加到多个群,答案是可以的——同一个 Webhook 地址只对应一个群,但你可以给多个群分别建机器人,然后在配置里做分发。我做多环境隔离时的做法是:测试群一个 key,预发群一个 key,生产群一个 key,全部放进不同的配置文件,靠环境变量切换。这样哪怕代码写错分支,也不会把测试数据甩到生产群。
注意:Webhook 地址里的 key 一旦泄露,最快的止损方式是把这个机器人从群里移除,然后重新添加生成新 key。改代码比删机器人慢得多。
2.2 加签模式与 IP 白名单的取舍
企微的安全设置里给了三种校验方式:自定义关键词、IP 白名单、加签。三者可以任选其一或组合使用。
自定义关键词最简单,消息内容里必须包含你设置的词,否则发送失败。适合小团队快速上手,但缺点是所有消息都得带这个词,视觉上很脏;而且一旦有人猜到你关键词是"告警",照样能伪造消息。
IP 白名单的约束力最强,只有白名单内的出口 IP 能发。问题在于很多团队的服务器出口 IP 是动态的,容器化部署后更不可控,维护成本不低。如果你的推送任务跑在固定的几台机器上,我强烈建议开这个。
加签是我在绝大多数场景下的选择,因为它在安全性和可移植性之间最平衡。原理不复杂:机器人给你一个secret,每次请求时你把时间戳 + "\n" + secret这段字符串,用secret作为 HMAC-SHA256 的密钥算出摘要,再 base64 编码、URL 编码,作为sign参数拼到 URL 上,同时带上timestamp。服务端用同样的算法验一遍,时间戳还有有效期(一般允许一定的时间偏差),所以抓包重放也没用。
加签的代价是你必须自己实现签名函数。好消息是这段代码只有十来行,我在第 3 章会给完整实现。坏消息是——系统时间必须准。我踩过一次坑:容器宿主机的时间漂移了几分钟,签名一直报"签名不合法",排查了两个小时才想到看date。现在我的部署脚本里都会加一句时间同步检查,比事后救火便宜太多。
2.3 限流、长度与文件大小这三条硬线
这三条限制是写代码前必须写进设计里的,而不是上线后被用户投诉了才补。
限流:每个机器人每分钟最多发送 20 条消息。注意这个口径是"每个机器人",不是"每个群"。所以如果你用一个机器人给多个群发,总量是共享的。超了会返回45009,而且这个限制是按滑动窗口算的,不是整分钟重置。20 条听着不少,但一次服务雪崩能瞬间产生上百条告警,所以我在发送层统一加了一个令牌桶,超出直接排队或丢弃。
文本长度:text类型的content最长 2048 个字节,markdown最长 4096 个字节。注意是字节不是字符。UTF-8 编码下,一个常用汉字占 3 个字节,所以 text 消息实际只能塞下约 680 个汉字。很多人写长日志直接塞进去,结果被截断或者报 40058,就是这个原因。
我在推送长内容时用的分片函数是这样的:先按字节切片,但不能切在汉字中间,所以要用"逐字符累加 + 长度校验"的写法,而不是直接content[:2048]。后面第 4 章会给具体实现。
文件大小:上传文件走的不是send接口,而是单独的upload_media接口,限制是20MB,而且拿到的media_id只有3 天有效期。图片走send接口的 base64 方式,原始文件不能超过 2MB。这两条数字我建议直接写进代码的常量里,超过就提前报错,别等服务端返回 40009。
3. Python 实现:从最小可跑到能上生产
先说依赖。整个项目只需要requests一个第三方库,其余全是标准库。不上 SDK 是有意的选择——企微官方没有提供官方的 Python SDK,社区封装的版本质量参差,而且接口字段一更新就得等别人发版。自己写两百行,比引入一个黑盒依赖更可控。
pip install requests3.1 环境准备与最小可运行示例
先跑通最短路径,确认地址和权限没问题,再往上叠功能。这是我一直坚持的调试顺序——先证明通路,再优化体验,否则出问题时你不知道是网络问题还是代码问题。
import requests WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的KEY" def send_text(content: str) -> dict: payload = { "msgtype": "text", "text": {"content": content} } resp = requests.post(WEBHOOK, json=payload, timeout=5) resp.raise_for_status() return resp.json() if __name__ == "__main__": print(send_text("第一条测试消息,通路验证"))跑通这一步,你会拿到{'errcode': 0, 'errmsg': 'ok'}。如果返回93000,是 key 写错了或机器人已被移除;返回40058,是参数结构不对,通常是把json=payload写成了data=payload,导致发出去的是 Python 的 dict 字符串而不是合法 JSON。
timeout=5这个参数不要省。我见过默认不设超时的代码,网络一旦卡住,整个任务线程就挂在那里,最后是上游定时任务堆积、第二天早上才发现推送全断了。5 秒对我来说是个合理的经验值:正常情况下响应在 200ms 内,5 秒还没回来基本可以判定异常。
3.2 封装一个带重试与签名的推送类
下面的类是我在多个项目里迭代出来的版本,砍掉了业务耦合,只保留通用能力。它包含签名计算、指数退避重试、业务错误码识别、令牌桶限流四个部分。
import base64 import hashlib import hmac import time import threading import urllib.parse from typing import Optional import requests class WecomBot: SEND_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send" UPLOAD_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media" def __init__(self, key: str, secret: Optional[str] = None, timeout: int = 5, max_retry: int = 3, rate_limit: int = 18): self.key = key self.secret = secret self.timeout = timeout self.max_retry = max_retry self.rate_limit = rate_limit self._lock = threading.Lock() self._sent = [] # 记录最近 60 秒内的发送时间戳 # ---------- 限流 ---------- def _acquire(self) -> bool: with self._lock: now = time.time() self._sent = [t for t in self._sent if now - t < 60] if len(self._sent) >= self.rate_limit: return False self._sent.append(now) return True # ---------- 签名 ---------- def _signed_url(self, base: str) -> str: if not self.secret: return f"{base}?key={self.key}" ts = str(int(time.time())) raw = f"{ts}\n{self.secret}".encode("utf-8") digest = hmac.new(self.secret.encode("utf-8"), raw, digestmod=hashlib.sha256).digest() sign = urllib.parse.quote_plus(base64.b64encode(digest)) return f"{base}?key={self.key}×tamp={ts}&sign={sign}" # ---------- 发送 ---------- def send(self, payload: dict) -> dict: if not self._acquire(): raise RuntimeError("本地限流:60 秒内发送次数已达上限") url = self._signed_url(self.SEND_URL) last_err = None for attempt in range(self.max_retry): try: resp = requests.post(url, json=payload, timeout=self.timeout) data = resp.json() code = data.get("errcode", -1) if code == 0: return data # 频率超限,退避后重试 if code == 45009: last_err = RuntimeError(f"45009 频率超限") time.sleep(2 ** attempt) continue # 其他业务错误,重试无意义,直接抛 raise RuntimeError(f"发送失败 code={code} msg={data.get('errmsg')}") except requests.RequestException as e: last_err = e time.sleep(2 ** attempt) # 1s, 2s, 4s raise RuntimeError(f"重试 {self.max_retry} 次仍失败: {last_err}")这段代码里有三个决策值得展开说。
为什么本地限流设 18 而不是 20?因为服务端是按滑动窗口计数的,你本地按整分钟算,边界上很容易超。留 2 条的余量,相当于给自己一个缓冲。这个数字不是死的,如果你的发送频率远低于阈值,可以不用管;但如果是告警系统这种突发流量场景,这 2 条余量能省掉很多 45009。
为什么只对 45009 和网络异常重试?因为其他业务错误码(比如 msgtype 不合法、内容超长)是确定性错误,重试一百次结果一样,只会拖慢故障暴露。重试只对"可能自愈"的错误有意义,这个判断标准我在很多地方都用。
为什么用列表维护时间戳而不是计数器和定时器?因为在多线程环境下,定时重置的边界很容易出竞态。维护一个时间戳列表,每次清理过期项,逻辑更直观,也不会有线程安全问题(外面已经加了锁)。
3.3 关键参数拆解:超时、退避、幂等
超时怎么定?我按 P99 响应时间乘以 3 来设。实测企微这个接口在国内机房 95% 的请求在 300ms 内返回,P99 大概 1.2s,所以 5 秒已经很宽松了。如果你跑在跨区域网络里,适当放大到 8 秒也合理。但千万别设 30 秒,那样一个卡死的请求会占满你的线程池。
退避为什么用 1、2、4 秒?指数退避的意义是给对端恢复时间。企微的限流窗口是分钟级的,所以退避到 4 秒其实还不够,理想情况是退避到下一个窗口。我在生产环境里的做法是:45009 直接放弃本次发送并记日志,由上游的补偿任务在 1 分钟后重放,而不是在原地死等。同步重试和异步补偿是两种不同策略,选哪个取决于你的消息能不能等——告警能等 1 分钟,但不能等 1 小时。
幂等怎么处理?这是最容易被忽略的一点。群机器人接口不支持自定义 msgid,服务端不去重。所以如果你重试了 3 次,其中一次其实成功了但响应超时了,群里就会出现两条一样的消息。
我的解法很土但有效:在消息内容里埋一个短哈希,比如把消息正文的 MD5 前 8 位附在末尾(对用户可见的部分做成"流水号 #a3f9c2d1"这种形式),同时本地维护一个最近 10 分钟已发送哈希的集合。命中就直接跳过。这不是真正的幂等,但能挡住 99% 的重复。
import hashlib, time class Dedup: def __init__(self, window: int = 600): self.window = window self.seen = {} def check(self, text: str) -> bool: """返回 True 表示是重复消息,应跳过""" h = hashlib.md5(text.encode("utf-8")).hexdigest()[:16] now = time.time() self.seen = {k: v for k, v in self.seen.items() if now - v < self.window} if h in self.seen: return True self.seen[h] = now return False这个方法有个前提:消息内容里不能包含时间戳这类每次都变的字段,否则哈希永远不重复。所以我在拼告警消息时,会刻意把时间段归一化(比如"最近 5 分钟"而不是"12:03:17 至 12:08:17"),既让哈希稳定,也让消息更易读。
4. 消息体实战:四种最高频的推送形态
接口支持的 msgtype 有 text、markdown、markdown_v2、image、news、file、voice、template_card 等。日常用得最多的其实是前四种加卡片,我把它们的实际效果和坑都过一遍。
4.1 文本消息与 @ 提醒的正确写法
text 是最基础的形态,字段就三个:content、mentioned_list、mentioned_mobile_list。
def send_text_with_at(bot: WecomBot, content: str, userids=None, mobiles=None, at_all=False): text = {"content": content} if at_all: text["mentioned_list"] = ["@all"] elif userids: text["mentioned_list"] = userids if mobiles: text["mentioned_mobile_list"] = mobiles return bot.send({"msgtype": "text", "text": text})mentioned_list填的是成员的 userid,mentioned_mobile_list填的是手机号。两者可以同时给,服务端会做并集。
这里有个很多人不知道的细节:@all必须放在mentioned_list数组里作为元素,而不是把 content 写成 "@all 大家好"。后者只是文本,不会触发真正的提醒。真正的 @ 会在客户端产生红点提醒,是两种完全不同的效果。
还有一个坑:userid 写错了不会报错,消息照样发出去,只是没人被提醒。所以我在配置里维护了一份 userid 到姓名的映射,发送前先校验一遍,把不认识的 userid 打成 warning 日志。这个校验救过我很多次——尤其是有人离职后 userid 失效的情况。
关于@all的使用,我的个人原则是:生产环境默认禁用,只允许在"服务完全不可用"这一级别放开。因为一旦有人滥用,整个群的人会直接把机器人拉黑,你的告警通道就废了。代码里我一般会做一个开关,写在配置里,需要临时开启得改配置走发布流程,人为增加一点摩擦。
4.2 Markdown 排版能做什么、不能做什么
markdown 是我最常用的形态,因为它能把一条消息组织得层次分明。但它支持的语法是一个子集,不是完整 Markdown,这点必须提前接受。
支持的:#到######的标题、**加粗**、*斜体*、>引用、[文字](链接)、行内代码、<font color="info">文字</font>这种颜色标签(info 绿色、comment 灰色、warning 橙红)。
不支持的:表格、图片、代码块的高亮、有序列表的自动编号渲染(会原样显示)、@ 提醒。
表格这条是最容易踩的。很多人习惯用表格对齐数据,发出去发现是一堆竖线挤在一起。解决办法有两个:一是改用markdown_v2(新版协议支持表格和更多语法,但对客户端版本有要求),二是老老实实用空格和等宽字符对齐,做"伪表格"。
我用得最多的排版套路是这样的:
def build_alert_md(title: str, level: str, items: list) -> str: color = {"P0": "warning", "P1": "warning", "P2": "comment"}.get(level, "info") lines = [ f"## <font color=\"{color}\">{title}</font>", f"> 级别:**{level}** 时间:{time.strftime('%Y-%m-%d %H:%M')}", "", "**异常明细**", ] for it in items[:8]: lines.append(f"- {it['name']}:<font color=\"warning\">{it['msg']}</font>") if len(items) > 8: lines.append(f"- …… 其余 {len(items) - 8} 项已省略") lines.append("") lines.append("[查看监控面板](https://example.com/dashboard)") return "\n".join(lines)这个模板有几个小心思:标题用顶部二级标题做视觉锚点;关键信息用引用块包起来,和正文区分开;明细只列前 8 条,超出的折叠成一句话,避免消息过长被截断;最后一行固定放跳转链接,让收到消息的人有下一步动作可做。一条好的告警消息,应该让人看完就知道该点哪里。
还有个小技巧:\n换行在 markdown 里是生效的,但连续两个\n\n才会产生真正的空行段落。我早期写代码时用单个\n想做段落分隔,结果所有内容挤成一坨,排查了半天才发现是渲染规则问题。
4.3 图片与文件的两条不同路径
这两者经常被混为一谈,其实走的是完全不同的接口。
图片走send接口,msgtype为image,需要提供base64和md5两个字段:
import hashlib, base64 def send_image(bot: WecomBot, path: str) -> dict: with open(path, "rb") as f: raw = f.read() if len(raw) > 2 * 1024 * 1024: raise ValueError(f"图片超过 2MB,实际 {len(raw)/1024/1024:.2f}MB") payload = { "msgtype": "image", "image": { "base64": base64.b64encode(raw).decode("utf-8"), "md5": hashlib.md5(raw).hexdigest() } } return bot.send(payload)注意 md5 是原始二进制的 md5,不是 base64 字符串的 md5。这个细节文档里写得不算显眼,我第一次写的时候算错了,返回"图片不合法",改成对原始字节算就对了。
文件走upload_media接口,先拿到media_id,再用send发出去,两步:
import os def upload_file(bot: WecomBot, path: str) -> str: size = os.path.getsize(path) if size > 20 * 1024 * 1024: raise ValueError(f"文件超过 20MB,实际 {size/1024/1024:.2f}MB") url = bot._signed_url(bot.UPLOAD_URL) + "&type=file" with open(path, "rb") as f: files = {"media": (os.path.basename(path), f, "application/octet-stream")} resp = requests.post(url, files=files, timeout=30) data = resp.json() if data.get("errcode") != 0: raise RuntimeError(f"上传失败: {data}") return data["media_id"] def send_file(bot: WecomBot, path: str) -> dict: media_id = upload_file(bot, path) return bot.send({"msgtype": "file", "file": {"media_id": media_id}})上传的timeout我设了 30 秒,因为 20MB 的文件在这个接口上确实可能传十几秒,沿用 5 秒会导致大量超时报错。
media_id的3 天有效期是个隐藏坑:有些人喜欢把 media_id 缓存起来长期复用,结果第三天开始报错。我的做法是每次发都重新上传,因为文件通常不大,上传耗时在可接受范围;只有在大文件重复推送的场景下才考虑缓存,并且要带上过期时间判断。
还有一点:文件名会直接显示在客户端里,所以不要用带路径的完整路径,也别用包含敏感信息的中文名。我通常会重命名成report_20250101.xlsx这类规范格式,既好看又避免各种编码问题。
4.4 模板卡片:把告警做成一张有层次的通知
template_card是我认为被严重低估的能力。它比 markdown 更结构化,点击区域更大,在手机上阅读体验明显更好。用一个"构建结果"的例子:
def send_build_card(bot: WecomBot, branch: str, status: str, cost: str, failed: int, url: str): color = 1 if status == "success" else 3 # 1 绿 2 蓝 3 红 card = { "msgtype": "template_card", "template_card": { "card_type": "text_notice", "source": {"desc": "流水线通知", "desc_color": 1}, "main_title": {"title": f"构建{status}", "desc": f"{branch} 分支"}, "emphasis_content": {"title": str(failed), "desc": "失败任务"}, "sub_title_text": f"耗时 {cost}", "horizontal_content_list": [ {"keyname": "分支", "value": branch}, {"keyname": "状态", "value": status} ], "card_action": {"type": 1, "url": url} } } return bot.send(card)卡片的核心优势是emphasis_content这个字段——它会把一个数字放大显示,一眼就能看到"有 3 个失败任务"。这比在一段文字里找数字快得多。
代价是字段多、容易写错。我的建议是:第一次做卡片时,先把 JSON 结构在文档里对着拼一次,确认能发出去再封装成函数。因为字段名拼错时返回的错误码往往很笼统(40058),很难定位到具体是哪个字段。
另外card_action里的url必须是完整 URL,不能是相对路径。如果你的系统在内网,链接记得带上协议头,否则点开会报错。
5. 常见问题与排查实录
这一章按我实际遇到的问题频率排序,每条都附了定位思路。
5.1 报错码速查表
| errcode | errmsg 含义 | 真实原因 | 处理方式 |
|---|---|---|---|
| 0 | ok | 成功 | 无需处理 |
| 40058 | 参数不合法 | JSON 结构不对、字段名拼错、类型错误 | 打印完整 payload 逐字段比对 |
| 40008 | 消息类型不合法 | msgtype 拼写错或不支持 | 检查是否写成markdown_v2而客户端不支持 |
| 45009 | 接口调用超过限制 | 60 秒内超 20 条 | 本地令牌桶 + 异步补偿重放 |
| 93000 | webhook key 无效 | key 复制少了字符、机器人被移除 | 重新获取地址 |
| 93004 | 机器人已停用 | 被管理员在后台禁用了 | 找群管理员确认 |
| 40009 | 图片大小超限 | 原图超过 2MB | 压缩后再发 |
| 40001 | 凭证无效 | 加签模式下 sign 或 timestamp 算错 | 检查系统时间、签名算法 |
这张表我建议直接做成代码里的常量字典,遇到错误码时打日志能直接看到人话解释,省去每次翻文档。
5.2 换行失效、@ 不生效、中文挤在一起的真相
换行失效:text 消息里\n是生效的;markdown 里单个\n也生效,但如果你想做空行,必须两个。还有一种情况是——你在 Python 里用了三引号字符串,缩进被带进了内容里,看起来像是"格式乱了"。我的习惯是拼消息时全部用列表append再"\n".join(),从源头上避免缩进问题。
@ 不生效:先确认是不是发成了 markdown 类型。markdown 消息没有 @ 字段,你在内容里写名字只是普通文本。要触发红点提醒,必须用 text 类型的mentioned_list。这个限制没有绕过的方法,我一般会在需要 @ 的场景直接改用 text 发送,格式简单点就简单点。
中文挤在一起:这不是编码问题,是排版问题。有时候你从别处复制来的中文带了全角空格或者零宽字符,在客户端里就显示成"挤在一起"甚至乱序。我的解决办法是发送前做一次清洗,把\u200b、\ufeff这类不可见字符去掉。
import re def clean_text(s: str) -> str: s = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", s) return s.replace("\r\n", "\n").strip()5.3 定时任务与并发下的踩坑记录
坑一:定时任务重叠执行导致重复推送。用的是最朴素的schedule库或者while True + sleep,如果某次任务执行时间超过了间隔,下一次就会和上一次重叠。我现在一律在任务入口加文件锁或者 Redis 锁,宁可这次跳过也不重入。
坑二:多进程共用一个机器人,限流各算各的。本地令牌桶是进程内内存,两个进程各发 15 条,加起来就是 30 条,直接超限。这种情况要么改用 Redis 做分布式限流,要么干脆把发送收敛到单一进程,其他进程把消息丢进队列。
坑三:容器时区不同导致时间戳算错。加签依赖时间戳,如果容器是 UTC 而你以为本地是东八区,算出来的签名看着没问题但对不上。我的做法是签名前统一用time.time()拿 UTC 时间戳,不要去格式化本地时间再转,少一次转换就少一次出错机会。
坑四:异常被吞了。很多代码写成try: send() except: pass,结果推送断了一周没人知道。至少要在 except 里打一条 ERROR 日志,并且带上是哪个群、什么内容、什么错误码。
6. 工程化收尾:配置、日志与告警闭环
代码能跑起来只是开始,真正决定这套东西能不能长期用的,是这最后一层。
6.1 密钥管理与多环境配置
Webhook key 和 secret 绝对不能硬编码进代码。我见过最离谱的一次是把 key 直接提交到了公开仓库,第二天就有人在群里发垃圾广告。
我的做法是分三层:本地开发用.env文件(加进.gitignore);测试和生产用环境变量注入;再多环境的话,用配置文件按环境名索引,启动时根据APP_ENV加载对应的一组。
import os CONFIG = { "dev": {"key": os.getenv("BOT_KEY_DEV"), "secret": os.getenv("BOT_SECRET_DEV")}, "prod": {"key": os.getenv("BOT_KEY_PROD"), "secret": os.getenv("BOT_SECRET_PROD")}, } def get_bot() -> WecomBot: env = os.getenv("APP_ENV", "dev") cfg = CONFIG[env] if not cfg["key"]: raise RuntimeError(f"环境 {env} 未配置机器人 key") return WecomBot(cfg["key"], cfg["secret"])启动时做一次"配置缺失就报错退出",比运行到发送那一步才失败要好得多——把错误暴露在最便宜的时机。
另外我在测试环境的机器人名字里统一加前缀,比如"[测试]",这样从视觉上就能一眼区分,避免误把测试消息当真。
6.2 发送日志与失败重放
每条消息我都记三个东西:发送时间、目标机器人(用哈希后的短标识,不记原始 key)、结果码。只在内存里留最近 1000 条,定期落盘。
失败的消息进一个重放队列,用一个独立的小任务每分钟扫一次,最多重试 3 次,超过就标为"死信"并在下一次成功发送时附一条汇总,比如"过去 1 小时有 3 条通知投递失败"。这样既不刷屏,也不会让问题石沉大海。
这个设计参考了消息队列的思路,但实现可以非常轻量——一个 JSON 文件加一把锁就够了。小系统不要过度设计,等到真的每天几万条再上正经的队列也不迟。
6.3 和监控、流水线的对接姿势
最后说接入方式。我的经验是把推送做成一个独立的 HTTP 小服务,而不是让各个系统各自 import 一份代码。这样限流、去重、日志、配置全都在一处,其他系统只需要 POST 一个结构化的消息描述进来。
好处很直接:哪天需要换群、换机器人、加个静默时段、加个消息聚合,改一处就行;坏处是多了一次网络跳转,以及需要保证这个小服务本身的高可用。对大多数团队来说,这个代价是值得的。
如果不想搞服务,退一步的做法是把推送封装成一个内部包,通过私有源分发。核心原则是一样的:限流和去重逻辑只允许存在一份实现,多份拷贝必然会在某次修改后出现行为不一致。
我个人在最近几个项目里,还加了一个"发送前预演"的能力:把拼好的消息内容和目标群打印出来,只在不带--confirm参数时真的发。这个开关让我在调试排版时少发了无数条垃圾消息到生产群,算是性价比最高的一个小功能。