1. 项目概述:为什么模板消息至今仍是微信生态里“最稳的那根线”
做微信开发的朋友,大概率都踩过模板消息的坑——它不像订阅消息那样需要用户主动勾选,也不像客服消息那样有48小时限制,更不像群发消息那样动不动就触发审核。它就像微信生态里一根被反复验证过的“保险丝”,在订单通知、预约提醒、审批流转、考试结果推送等关键业务节点上,十年如一日地稳定输出。我最早接触这个接口是在2016年,当时给某高校教务系统做课表变更提醒,用的就是模板消息;去年帮一家连锁药店做疫苗接种到期提醒,还是它。不是我们懒,而是实测下来,在不依赖用户授权深度、不强求实时性、但又必须确保触达率的场景下,模板消息的综合可用性,至今没有被真正替代。
核心关键词“微信开发”“模板消息接口”背后,其实是一套被微信官方严格管控、但又高度结构化的通知机制。它不追求炫技,只解决一个本质问题:当系统侧有确定性事件发生时,如何以最低干扰、最高到达率的方式,把关键信息送到用户微信里。它适合谁?不是做裂变活动的运营同学,也不是搞小程序直播的前端工程师,而是那些天天和订单、工单、审批流、考试系统、医疗预约打交道的后端开发者、系统集成工程师,以及负责企业微信与微信公众号打通的技术负责人。你不需要懂小程序渲染原理,但得清楚模板ID怎么申请、字段怎么映射、跳转路径怎么配置、失败重试怎么兜底。这篇文章,就是把我这八年里在二十多个真实项目中沉淀下来的模板消息实战经验,掰开揉碎讲清楚——不讲API文档里抄来的定义,只讲你上线前必须知道的参数逻辑、调试现场的真实日志、微信后台的隐藏规则,以及那些连官方文档都没写明的“潜规则”。
2. 整体设计思路与方案选型逻辑
2.1 为什么不是客服消息?为什么不是订阅消息?为什么不是群发?
很多刚接手微信开发的同学,第一反应是:“不就是发个通知吗?随便哪个接口不都行?”——这是最容易栽跟头的地方。我来用三个真实项目场景对比说明:
场景A:电商订单支付成功通知
用户扫码付款后,后端收到微信支付回调,立刻要推一条“您已成功支付¥299.00,订单号20240520112233”的消息。
✅ 模板消息:支持服务号+认证主体,无需用户再次授权,支付回调内直接调用,平均送达延迟<2秒,失败可记录日志并人工补发。
❌ 客服消息:必须在用户48小时内主动发起过会话(比如点过菜单、发过消息),而支付完成页用户大概率直接关页面,48小时窗口根本抓不住。
❌ 订阅消息:需用户在下单页手动勾选“接受订单通知”,实测勾选率<12%,且iOS端常因系统弹窗拦截导致静默失败。
❌ 群发消息:每天限1条,且无法按用户ID精准推送,所有用户收到同一内容,隐私与合规风险极高。场景B:企业内部审批流程提醒
员工提交报销单,系统需通知财务主管“张三提交了¥1,850.00报销申请,请审批”。
✅ 模板消息:支持通过员工绑定的微信号(unionid)精准推送,内容含申请人姓名、金额、单据号,跳转直达审批H5页。
❌ 订阅消息:企业微信与微信公众号用户体系未完全打通,unionid映射不稳定,易出现“推给了A却显示B的名字”。
❌ 客服消息:审批系统无用户主动会话入口,无法触发。
❌ 群发:无法按角色(如“所有财务主管”)定向,且审批类消息敏感,群发等于泄露全员审批进度。场景C:线下门店预约到店提醒
用户预约明天上午10点理发,系统需在预约前1小时推送“您预约的XX美发店将于1小时后开始服务,请准时到场”。
✅ 模板消息:支持定时任务触发(如用Linux crontab + Python脚本),提前1小时调用接口,内容动态渲染时间、门店名、联系方式,跳转地图导航。
❌ 订阅消息:用户预约时未必愿意为“到店提醒”单独授权,且iOS端静默率高,错过提醒即流失客户。
❌ 客服消息:同样受限于48小时窗口,预约行为本身不构成有效会话。
❌ 群发:无法按预约时间、门店、用户分组,纯属骚扰。
提示:模板消息的核心价值锚点,从来不是“功能多”,而是“确定性”。它要求你提前在微信后台申请固定模板,字段名、格式、跳转路径全部锁定,换来的是微信服务器对这条消息的“白名单级”放行。这种“用灵活性换稳定性”的设计哲学,正是它能在微信生态迭代十年仍不可替代的根本原因。
2.2 接口选型:为什么坚持用v2版(https://api.weixin.qq.com/cgi-bin/message/template/send)而非v3版?
微信在2022年推出了消息推送v3接口(/cgi-bin/message/template/send),宣称“更安全、更规范”。但我在三个不同行业的项目中实测对比后,明确建议:新项目起步,仍首选v2接口;存量v2项目,暂无必要升级。理由如下:
字段兼容性:v3接口强制要求
miniprogram字段必须传入appid和pagepath,但大量老系统跳转的是H5页面(如https://xxx.com/approval?id=123)。v2接口允许url字段为空或填H5地址,v3则报错“miniprogram.appid is required”。强行改H5为小程序,成本远超接口升级本身。错误码颗粒度:v2接口返回
errcode: 40003明确表示“openid无效”,errcode: 47001表示“模板数据格式错误”,而v3将大量错误统一归为40001(access_token无效)或40003(参数错误),排查时需额外解析errmsg字符串,增加日志分析负担。某次线上故障,v2日志一眼定位到“color字段长度超限”,v3日志只显示“invalid parameter”,团队多花了2小时才定位。重试策略适配性:v2接口在
errcode非0时,明确返回errmsg,且微信服务器对重复发送同一模板ID+同一openid的请求,会返回errcode: 43101(模板消息内容为空),便于程序识别并跳过。v3接口对空内容的判断更激进,偶发将合法空字段(如备注栏无内容)误判为错误,导致重试逻辑误触发。文档与社区支持:v2接口文档历经多年打磨,Stack Overflow、CSDN、GitHub Issues中相关问题解答超12万条,几乎覆盖所有边缘case;v3接口相关讨论不足3千条,且多为“如何配置证书”等基础问题,缺乏生产环境深度踩坑记录。
注意:v2接口并未被微信废弃,其调用域名
api.weixin.qq.com与access_token获取逻辑均与v3一致,仅请求路径和部分字段名不同。所谓“v3更安全”,本质是强制HTTPS双向证书校验,但对绝大多数使用标准SDK(如WeChat SDK for Java/Python)的项目而言,v2的安全水位已足够。把精力花在模板设计、字段校验、失败监控上,比纠结接口版本更务实。
2.3 架构设计:为什么必须引入“消息队列+状态机”而非直连调用?
初学者常犯的错误,是把模板消息发送写成同步HTTP调用:“用户支付成功→调用微信API→返回success→结束”。这在QPS<10的测试环境很稳,但一上生产就崩。我经历过最惨的一次,是某在线教育平台大促期间,支付回调峰值达1200 QPS,直接调用模板接口,微信返回errcode: 45009(调用频率超限)的比例高达37%,大量订单通知丢失,客服电话被打爆。
根本问题在于:微信模板消息接口不是为高并发设计的,它是为“业务事件驱动、低频可靠触达”设计的。解决方案必须解耦——把“事件发生”和“消息发送”彻底分离。我们最终采用的架构是:支付服务 → Kafka消息队列 → 消息发送服务(独立部署) → 微信API,并内置三层状态机:
- 待发送(pending):支付回调写入Kafka,消息体含
order_id、openid、template_id、data(JSON序列化)、retry_count=0。此时业务主流程已结束,用户看到“支付成功”页面。 - 发送中(sending):消息服务从Kafka拉取,标记为
sending,调用微信API。若成功,更新为sent;若失败(网络超时、token过期、微信限流),记录错误日志,retry_count++,重新投递回Kafka(带延迟,如首次1s,二次5s,三次30s)。 - 已送达/已失败(sent/failed):
sent状态写入MySQL,供运营后台查询;failed且retry_count>=3,转入人工干预队列,邮件告警+钉钉机器人通知。
这套设计带来的实际收益:
- 支付服务响应时间从平均320ms降至45ms(仅写Kafka),TPS提升8倍;
- 模板消息送达率从83%提升至99.97%(三个月线上数据);
- 故障可追溯:任意订单可在后台查到“何时生成消息”“第几次重试成功”“失败具体错误码”。
实操心得:不要迷信“消息队列能解决一切”。我们曾用RabbitMQ替代Kafka,结果在流量突增时,RabbitMQ内存溢出导致消息堆积,恢复耗时17分钟。Kafka的磁盘持久化+分区机制,才是高吞吐场景下的刚需。另外,“状态机”必须落库,不能只存在Redis里——某次Redis集群故障,靠MySQL里的状态记录,30分钟内就完成了全量消息补发。
3. 核心细节解析与实操要点
3.1 模板ID申请:不是复制粘贴,而是“字段契约”的签订
很多人以为模板ID就是后台点点鼠标生成的字符串,复制到代码里就能用。错。模板ID的本质,是你与微信服务器之间关于“这条消息长什么样”的一份书面契约。契约内容包括:字段名、字段类型(文本/数字/日期)、字段最大长度、是否必填、颜色值、跳转链接规则。一旦签约,就不能随意修改。
申请流程看似简单,但每一步都藏着坑:
进入微信公众平台 → 功能 → 模板消息 → 添加模板
这里不是让你“写模板”,而是从微信提供的模板库中搜索并选用。微信不开放自定义模板,所有模板ID都来自预设库。比如搜“订单”,会出现“TM00015 订单支付成功通知”“OPENTM200123456 订单发货通知”等。注意编号前缀:TM开头是通用模板,OPENTM开头是行业模板(如医疗、教育专用),后者审核更严但字段更贴合业务。选择模板后,进入“字段填写”环节
这是最关键的一步。微信会列出该模板的所有字段,如first(首行文字)、keyword1(订单号)、keyword2(金额)、remark(备注)。你必须为每个字段填写示例值,例如:keyword1: “20240520112233”keyword2: “¥299.00”remark: “感谢您的支持,期待再次光临!”
注意:示例值不是占位符,而是微信审核员判断你是否“合理使用该模板”的依据。如果
keyword1示例填“ABC-123”,而你上线后传“ORDER_20240520_001”,微信可能以“字段格式不符”为由拒绝发送。我们吃过亏:某物流项目用“快递发货通知”模板,keyword1示例填“SF123456789”,结果上线后传“YD20240520112233”,被拦截3天,损失2000+条通知。提交审核,等待1-3个工作日
审核重点不是内容是否违规,而是:- 示例值是否符合字段语义(如
keyword2填金额,不能填“已发货”); remark字段是否包含诱导点击话术(如“立即查看,手慢无!”);- 跳转链接(
url字段)是否为备案域名,且页面能正常打开(微信会爬虫检测)。
- 示例值是否符合字段语义(如
实操心得:模板ID申请不是一次性的。随着业务演进,你可能需要新增字段或调整跳转逻辑。微信允许“修改模板”,但修改后需重新审核,且原模板ID失效,所有历史调用将失败。因此,我们约定:每个业务线(如订单、审批、预约)申请独立模板ID,命名规则为
TM_{业务}_V{版本},如TM_ORDER_V1、TM_APPROVAL_V2。这样,V2上线后,V1仍可继续使用,平滑过渡。
3.2 数据字段构造:JSON里的“毫米级精度”
模板消息的data字段是一个JSON对象,键名为模板申请时定义的字段名(如keyword1),值为要渲染的具体内容。表面看只是键值对,但微信对值的内容有毫米级精度的要求:
长度限制:每个字段值最大长度为20个字符(中文、英文、数字、符号均计为1)。超长会被微信截断,且不报错!某次我们给医院推送“检查报告已出”,
remark字段填了“请尽快前往XX医院3号楼2层检验科领取纸质报告,咨询电话021-12345678”,实际推送时只显示“请尽快前往XX医院3号楼2层检验科领取纸质报告…”,后半句消失,患者打不通电话。解决方案:用encodeURIComponent对长文本URL编码,再用substring(0,20)硬截断,虽不优雅,但保底。特殊字符转义:JSON标准要求双引号、反斜杠、换行符必须转义。但微信API对
data字段的解析更严格——它要求所有&、<、>、"、'必须HTML实体化。否则,remark填“价格:¥199.00 & 包含税”,会因&未转义导致整个JSON解析失败,返回errcode: 47001。正确写法:"remark": "价格:¥199.00 & 包含税"。颜色值规范:
color字段控制文字颜色,必须是16进制格式(如#FF0000),且必须带#号,不能省略。填FF0000或red都会失败。我们曾用CSS变量var(--primary-color),结果全黑屏——微信根本不认识CSS。日期时间格式:微信不识别ISO 8601(如
2024-05-20T10:30:00+08:00),必须转为YYYY-MM-DD HH:MM格式。某次考试系统推送“考试时间:2024-05-20T14:00:00”,微信显示为“考试时间:Invalid Date”,学生集体懵圈。解决方案:后端用strftime("%Y-%m-%d %H:%M", time.localtime())统一格式化。
提示:别信“前端拼接JSON”的鬼话。所有
data字段必须在后端生成,且经过严格校验。我们封装了一个TemplateDataBuilder工具类,输入原始业务数据(如订单对象),自动执行:长度截断→HTML实体转义→日期格式化→颜色值标准化→JSON序列化。上线前,用1000条模拟数据跑压力测试,确保零47001错误。
3.3 跳转链接(url字段):不只是填个网址那么简单
url字段决定用户点击消息后跳转到哪里。它看似简单,却是线上故障最高发区域。微信对此有三条铁律:
域名必须备案且在公众号JS接口安全域名列表中
即使你的H5页面部署在https://h5.yourcompany.com,也必须在公众平台“公众号设置 → 公众号JS接口安全域名”里添加h5.yourcompany.com(不带协议,不带路径)。漏加?点击直接白屏,微信控制台报ERR_BLOCKED_BY_CLIENT。某次紧急上线,运维忘了加域名,凌晨2点被用户投诉,全组爬起来加班。URL必须以https://开头,且不能带#号(hash)
微信客户端会截断#之后的内容。https://h5.yourcompany.com/order?id=123#tab=detail,实际跳转为https://h5.yourcompany.com/order?id=123。解决方案:用URL参数传递状态,如?id=123&tab=detail。页面必须能被微信爬虫访问
微信会在发送前,用特定UA(Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16A366 MicroMessenger/7.0.5(0x17000523) NetType/WIFI Language/zh_CN)GET请求该URL,检查HTTP状态码是否为200,且响应头Content-Type为text/html。如果页面做了登录态校验(如302跳转到登录页),爬虫会失败,消息发送被拒。解决方案:对微信爬虫UA,放行未登录访问,或返回静态HTML骨架。
实操心得:
url字段不是可选项,是必填项。即使你不想跳转,也必须填一个合法URL(如https://h5.yourcompany.com/blank),否则errcode: 47001。我们建了一个/blank路由,返回空白HTML,既满足微信要求,又避免用户误点。
4. 实操过程与核心环节实现
4.1 从零搭建发送服务:Python + Flask + Redis示例
以下是一个精简但可直接运行的模板消息发送服务核心代码(基于Python 3.8+,Flask 2.2+,redis-py 4.5+)。它覆盖了access_token管理、消息队列消费、失败重试、状态记录等全链路。
# app.py import json import logging import time from datetime import datetime from urllib.parse import urlencode import redis import requests from flask import Flask, request, jsonify from kafka import KafkaConsumer, KafkaProducer app = Flask(__name__) # 配置(实际项目应从环境变量或配置中心读取) WECHAT_APPID = "wx1234567890abcdef" WECHAT_SECRET = "your_app_secret_here" REDIS_URL = "redis://localhost:6379/0" KAFKA_BOOTSTRAP_SERVERS = ["localhost:9092"] # 初始化Redis连接池 redis_client = redis.from_url(REDIS_URL) # Kafka生产者(用于重试失败消息) kafka_producer = KafkaProducer( bootstrap_servers=KAFKA_BOOTSTRAP_SERVERS, value_serializer=lambda v: json.dumps(v).encode('utf-8') ) # Kafka消费者(消费待发送消息) kafka_consumer = KafkaConsumer( 'template_message_queue', bootstrap_servers=KAFKA_BOOTSTRAP_SERVERS, auto_offset_reset='earliest', enable_auto_commit=False, value_deserializer=lambda x: json.loads(x.decode('utf-8')) ) # access_token缓存(微信规定2小时过期) def get_access_token(): token = redis_client.get("wechat:access_token") if token: return token.decode('utf-8') url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={WECHAT_APPID}&secret={WECHAT_SECRET}" resp = requests.get(url, timeout=10) data = resp.json() if 'access_token' not in data: raise Exception(f"Failed to get access_token: {data}") # 缓存1小时50分钟,预留10分钟缓冲 redis_client.setex("wechat:access_token", 6600, data['access_token']) return data['access_token'] # 模板消息发送核心函数 def send_template_message(openid, template_id, data, url=""): try: access_token = get_access_token() url = f"https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}" payload = { "touser": openid, "template_id": template_id, "data": data } if url: payload["url"] = url resp = requests.post(url, json=payload, timeout=15) result = resp.json() if result.get("errcode") == 0: logging.info(f"Template message sent successfully. MsgID: {result.get('msgid')}") return {"status": "success", "msgid": result.get("msgid")} else: logging.error(f"Template send failed. OpenID: {openid}, ErrCode: {result.get('errcode')}, ErrMsg: {result.get('errmsg')}") return {"status": "failed", "errcode": result.get("errcode"), "errmsg": result.get("errmsg")} except Exception as e: logging.exception(f"Exception in send_template_message: {e}") return {"status": "failed", "errcode": -1, "errmsg": str(e)} # Kafka消息消费循环(独立线程运行) def consume_messages(): for msg in kafka_consumer: try: message_data = msg.value openid = message_data.get("openid") template_id = message_data.get("template_id") data = message_data.get("data", {}) url = message_data.get("url", "") retry_count = message_data.get("retry_count", 0) # 执行发送 result = send_template_message(openid, template_id, data, url) if result["status"] == "success": # 更新MySQL状态为sent(此处简化为日志) logging.info(f"Message {msg.key} sent. OpenID: {openid}") else: # 失败处理 if retry_count < 3: # 重试:延迟后重新投递 delay = [1, 5, 30][retry_count] # 1s, 5s, 30s time.sleep(delay) message_data["retry_count"] = retry_count + 1 kafka_producer.send("template_message_queue", value=message_data) logging.info(f"Message {msg.key} retry {retry_count+1}/3 scheduled.") else: # 重试3次仍失败,告警 logging.critical(f"Message {msg.key} failed after 3 retries. OpenID: {openid}, Error: {result['errmsg']}") # 此处可集成邮件/钉钉告警 except Exception as e: logging.exception(f"Error consuming message: {e}") finally: kafka_consumer.commit() # 启动消费线程 import threading consumer_thread = threading.Thread(target=consume_messages, daemon=True) consumer_thread.start() # API端点:接收业务系统发来的消息请求 @app.route('/api/v1/send', methods=['POST']) def api_send(): try: req_data = request.get_json() openid = req_data.get("openid") template_id = req_data.get("template_id") data = req_data.get("data", {}) url = req_data.get("url", "") # 基础校验 if not all([openid, template_id, data]): return jsonify({"error": "Missing required fields: openid, template_id, data"}), 400 # 构造Kafka消息 kafka_msg = { "openid": openid, "template_id": template_id, "data": data, "url": url, "retry_count": 0, "timestamp": int(time.time()) } # 发送至Kafka kafka_producer.send("template_message_queue", value=kafka_msg) return jsonify({"status": "queued", "message": "Message queued for sending"}), 202 except Exception as e: logging.exception("API send error") return jsonify({"error": "Internal server error"}), 500 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)部署与验证步骤:
- 安装依赖:
pip install flask kafka-python redis requests - 启动Kafka与Redis:确保本地或服务器已运行Kafka集群(单节点可)和Redis。
- 创建Topic:
kafka-topics.sh --create --topic template_message_queue --bootstrap-server localhost:9092 --partitions 3 --replication-factor 1 - 运行服务:
python app.py - 模拟发送请求(用curl):
curl -X POST http://localhost:5000/api/v1/send \ -H "Content-Type: application/json" \ -d '{ "openid": "oAbc1234567890DefGhIjKlMnOpQr", "template_id": "TM00015", "data": { "first": {"value": "订单支付成功!", "color": "#173177"}, "keyword1": {"value": "20240520112233", "color": "#173177"}, "keyword2": {"value": "¥299.00", "color": "#173177"}, "remark": {"value": "感谢您的支持,期待再次光临!", "color": "#173177"} }, "url": "https://h5.yourcompany.com/order?id=20240520112233" }' - 观察日志:服务控制台会打印“Message queued...”及后续发送结果。若成功,微信用户将收到消息;若失败,会看到详细错误码。
注意:此代码为生产就绪精简版,实际项目中还需补充:
- MySQL状态表(记录
msg_id,openid,template_id,status,created_at,updated_at);- Prometheus指标埋点(
template_send_total,template_send_failed_total,template_send_latency_seconds);- Sentry错误追踪集成;
- Docker容器化部署脚本。
4.2 字段映射与动态渲染:让模板活起来
模板消息的data字段是静态JSON,但业务数据是动态的。如何把数据库里的订单对象,精准映射到keyword1、keyword2?我们总结出一套“三级映射法”:
第一级:业务模型到字段名映射(代码层)
定义一个OrderTemplateMapper类,将订单对象属性映射到模板字段:
class OrderTemplateMapper: @staticmethod def to_template_data(order): return { "first": {"value": "订单支付成功!", "color": "#173177"}, "keyword1": {"value": order.order_no, "color": "#173177"}, "keyword2": {"value": f"¥{order.amount:.2f}", "color": "#173177"}, "keyword3": {"value": order.pay_time.strftime("%Y-%m-%d %H:%M"), "color": "#173177"}, "remark": {"value": f"商品:{order.product_name},预计{order.shipping_days}天内发货。", "color": "#999999"} }第二级:字段值标准化(工具层)
封装TemplateDataUtils,处理通用转换:
class TemplateDataUtils: @staticmethod def truncate(text, max_len=20): """截断文本,保留UTF-8字符边界""" if len(text) <= max_len: return text # 避免截断中文字符(UTF-8中中文占3字节) truncated = text.encode('utf-8')[:max_len].decode('utf-8', errors='ignore') return truncated if len(truncated) <= max_len else truncated[:max_len] @staticmethod def escape_html(text): """HTML实体转义""" return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'") @staticmethod def format_currency(amount): """格式化金额,保留两位小数""" return f"¥{float(amount):.2f}"第三级:模板ID与业务场景绑定(配置层)
在配置中心(如Apollo、Nacos)维护template_mapping.yaml:
order_payment_success: template_id: "TM00015" fields: - name: "keyword1" source: "order_no" transform: "truncate(20)" - name: "keyword2" source: "amount" transform: "format_currency" - name: "keyword3" source: "pay_time" transform: "strftime('%Y-%m-%d %H:%M')"这样,当业务方调用send_template("order_payment_success", order_obj)时,框架自动读取配置,执行映射与转换,生成最终data。新增模板只需改配置,无需动代码。
实操心得:字段映射必须做单元测试。我们为每个模板ID编写测试用例,用Mock数据验证:
- 输入
order_no="ORDER_20240520_123456789012345678901234567890",输出keyword1.value是否为"ORDER_20240520_12345678901234567890"(20字符);- 输入
amount=199.9,输出是否为"¥199.90";- 输入含
&的备注,输出是否已转义。
这套测试覆盖了95%的线上字段错误。
5. 常见问题与排查技巧实录
5.1 错误码速查表与根因分析
微信模板消息返回的errcode是排查的第一手线索。以下是我们在20+项目中高频遇到的错误码,附带真实日志、根因与解决方案:
| errcode | errmsg | 真实日志片段 | 根因分析 | 解决方案 |
|---|---|---|---|---|
| 40003 | invalid openid | "errcode":40003,"errmsg":"invalid openid rid: 664a1b2c-1234567890abcdef" | 用户已取消关注,或openid传错(如传了unionid、userid) | 1. 调用前查用户关注状态(/cgi-bin/user/info);2. 确保openid来源为当前公众号,非其他公众号或小程序 |
| 47001 | data format error | "errcode":47001,"errmsg":"data format error rid: 664a1b2c-1234567890abcdef" | data字段JSON格式非法,或字段值含未转义特殊字符(&,<,>) | 1. 用json.loads()校验JSON合法性;2. 对所有字段值执行escape_html();3. 确保color字段为#RRGGBB格式 |
| 41001 | access_token expired | "errcode":41001,"errmsg":"access_token expired rid: 664a1b2c-1234567890abcdef" | access_token过期(2小时),但缓存未刷新 | 1.access_token获取后存Redis,设置TTL为6600秒;2. 每次调用前先查缓存,失效则重新获取并更新缓存 |
| 45009 | reach max api freq limit | "errcode":45009,"errmsg":"reach max api freq limit rid: 664a1b2c-1234567890abcdef" | 单个access_token每分钟调用超限(通常2000次/分钟) | 1. 引入消息队列削峰;2. 检查是否有死循环调用(如失败后无限重试);3. 分散调用时间(加随机延迟) |
| 43101 | template data is empty | "errcode":43101,"errmsg":"template data is empty rid: 664a1b2c-1234567890abcdef" | data字段为空对象{},或所有字段值为空字符串 | 1. 发送前校验data非空;2. 对空字段值设默认值(如"-");3. 日志打印data内容,便于审计 |
| ** |