微信小程序订阅消息40037错误:模板ID排查与校验

发布时间:2026/11/3 6:06:57
微信小程序订阅消息40037错误:模板ID排查与校验 1. 先把 40037 这件事说透报错到底在说什么凌晨一点多运维群里的告警机器人把一条消息甩了出来订单发货通知连续失败 37 次。我爬起来翻日志只看到一行很干净的返回{errcode:40037,errmsg:invalid template_id}微信小程序开发里模板消息这条链路上的报错有很多种access_token 过期、openid 不合法、page 路径不对、参数超长、用户未订阅每一个都有自己的坑。但40037 invalid template_id属于那种字面意思极其明确实际排查却能让人绕半天的类型。它就是在说一件事你传过去的那个 template_id服务端认不出来。注意用词是认不出来而不是用错了。这两个说法在排查时的区别很大——认不出来包含了三种可能这个 ID 根本不存在、这个 ID 存在但不属于你当前这个 AppID、这个 ID 曾经存在但现在已经被删掉或者被系统回收了。我把这套东西整理出来是因为这类问题在真实项目里出现频率很高而且往往不是新手才会踩。经历过模板消息时代、又迁移到订阅消息时代的老项目代码库里同时躺着一堆历史遗留的 ID 常量出问题的概率反而更大。内容适合三类人看正在接订阅消息但一直报 40037 的同学、维护着老项目不敢动消息模块的同学、以及做多环境多主体部署、被 ID 错配折磨过的同学。看懂之后你至少能做到两件事一是十分钟内定位是ID 错了还是环境错了二是把校验逻辑前置让这个错误在发请求之前就被拦下来。1.1 errcode 40037 的官方语义与触发链路官方错误码表里40037 的解释非常短template_id 不正确。短到几乎没给任何上下文。但如果你把发消息的整条链路画出来会发现这个校验发生在非常靠前的位置——服务端拿到请求先验 access_token 与 AppID 的对应关系再验 openid 是否属于该 AppID 的合理范围然后才会去看 template_id。这意味着 40037 出现的时候前面几步其实已经过了token 大概率是好的openid 大概率也是对的问题就卡在模板这一层。这个校验顺序的认知很重要。我见过有同学看到 40037 之后开始查 appid 和 secret重置了三次密钥还换了一台服务器部署最后发现是模板 ID 末尾少复制了一个字符。理解校验顺序能帮你砍掉一大半无效排查把精力集中在模板 ID 本身和它所属的那个 AppID 上。还有一点需要注意小程序侧的模板消息和订阅消息走的是两个不同的接口路径但错误码是共用的。消息类型发送接口路径适用场景现状模板消息旧/cgi-bin/message/template/send早期小程序的服务通知新注册小程序已不再支持存量项目逐步下线一次性订阅消息/cgi-bin/message/subscribe/send用户主动订阅一次可下发一条当前主流方案长期订阅消息/cgi-bin/message/subscribe/send一次订阅可长期下发仅特定行业类目可申请普通小程序拿不到两个接口都可能在模板 ID 上返回 40037但导致 ID 无效的原因不完全一样。旧接口的 ID 来自模板库里选定的模板新接口的 ID 来自后台我的模板里添加的订阅消息模板。这两套 ID 长得就不一样混用是最高频的原因之一下面会专门拆。1.2 模板消息与订阅消息两套体系两套 ID很多同学对这两个概念的区分是模糊的因为都叫给用户发通知功能看起来一样。但从技术实现上它们是两条平行的产品线ID 体系、权限体系、下发规则全都不一样。模板消息时代的核心逻辑是开发者在后台模板库里挑一个模板填好关键词顺序系统给你一个模板 ID之后只要用户在小程序里产生过表单提交之类的交互你就可以凭这个 ID 发一条通知。这套机制在新注册小程序上已经关停了。订阅消息的逻辑变成了用户在具体场景下主动点允许你拿到一次下发额度然后凭订阅消息模板 ID 发一条。模板必须在后台订阅消息模块里从公共模板库选出来加到我的模板加完之后才能拿到属于你这个 AppID 的模板 ID。关键在于我的模板里那串 ID 是跟你这个 AppID 绑定的。同样的公共模板A 小程序加进去和 B 小程序加进去拿到的 ID 完全不同。所以你在网上抄一段示例代码里面那个 template_id 拿去跑必然报 40037——它属于别人的 AppID跟你没有任何关系。这是我在带人时见过最多的第一课很多人第一次跑通订阅消息 Demo 时都以为改个 appid 和 secret 就行结果卡在 40037 上一整晚。1.3 为什么偏偏这个错最容易误判因为它长得太像配置问题了。开发者的第一反应往往是我配置改一下就行于是开始翻配置文件、翻后台、翻文档。但实际上它的成因分布很散有的在配置层ID 抄错、多环境错配有的在数据层模板被删、类目变更有的在代码层字符串带空白、接口用错还有的在架构层服务商代调用、多主体共用一套消息中台。这四层用同一个报错提示所以你不能靠猜来定位得靠验证来收敛。我的习惯是先把可能性列成一张收敛表然后用最有信息量的动作逐个排除而不是凭直觉乱改。后面第 3 章会给出我自己常用的四步排查法基本能在半小时内把范围压到一个点上。2. 六类根因逐个拆模板ID为什么会无效前面说了40037 的成因分布在四个层次上。真正落地到排查时我一般把它归纳成六类具体根因。这六类覆盖了我这些年遇到的绝大多数现场剩下的极少数属于官方侧的异常等一段时间重试基本能恢复。理解这六类根因的价值不只在出问题时会修更在于你在设计阶段就能把对应的防护写进去。比如知道多环境会错配你就会在启动时打印当前生效的模板 ID知道字符串会带空白你就会在封装层做一次 trim。这些都是成本极低、收益很高的动作。2.1 ID 来源错位把公众号模板、订阅消息模板混着用这是排在第一位的原因也是最容易识别的一个——只要你看一眼 ID 的长相就能判断个八九不离十。公众号模板消息的模板 ID早期那批是OPENTM开头的长串后来模板库里的短编号类似TM0001这种格式长度通常很短。小程序订阅消息的模板 ID 则是一串四十多位的大小写字母加数字混合的随机串没有任何语义前缀。这两种 ID 的外观差异非常明显。我遇到过一次很典型的现场一个团队同时在做公众号推文提醒和小程序订单提醒两个后台都开在同一个浏览器里配置文件里放了两个常量命名分别是TEMPLATE_ID和SUBSCRIBE_TEMPLATE_ID。结果某次重构时有人优化了命名把两个常量合并成了一个MSG_TEMPLATE_ID运行时就出现了公众号那边正常、小程序这边 40037 的诡异现象。因为公众号的接口对这个 ID 是认的小程序的接口不认。这种一边通一边不通的现象往往就是 ID 来源错位的最强信号。判断方法很简单把出问题的 ID 贴出来看长度和字符构成。短编号必然是旧体系的四十多位随机串才是订阅消息的。如果你手里那串 ID 只有十来个字符还带着明显的前缀语义那基本就不用往下查了直接去后台重新拿。2.2 模板被删、类目调整、模板库下线导致ID失效第二类是ID 曾经是好的现在坏了。这种情况最坑因为代码一个字没改线上突然开始报错很容易被误判成服务端故障。触发场景有这么几种。一是有人在后台我的模板里手滑删掉了某个模板删除之后这个 ID 立刻失效所有引用它的地方全部 40037。二是小程序的行业类目发生了变更而某些模板是和类目挂钩的类目一变不匹配的模板会被清理掉。三是公共模板库本身在做调整某个模板被下架你已经添加的那份可能还能用一段时间也可能直接被回收。四是项目主体做过迁移或者认证状态变化导致后台的部分配置需要重新确认。这类问题的排查思路和第一类完全不同不是去核对 ID 有没有抄错而是去后台看这个 ID 还在不在。进小程序后台的订阅消息模块我的模板列表里能看到的 ID 才是当前有效的。如果列表里找不到你代码里那个 ID问题就定位了。提示模板被删除后历史上已经发出去的消息不受影响但新的下发会立刻失败。如果你的业务有补偿重发机制要注意别让它对着一个已经失效的 ID 无限重试那会白白消耗接口调用额度。我现在给团队的规范是所有模板 ID 集中维护在一个配置文件里并且在后台每次改动模板之后同步更新一份带时间戳的变更记录。这个记录看起来是形式主义但真出问题时能省掉到底是谁改的、什么时候改的这段扯皮时间。2.3 多环境ID错配配置文件里的历史残留开发、测试、预发、生产四个环境四套配置这是标准做法。但订阅消息的模板 ID不随环境变化——它只跟 AppID 绑定。如果你有多个小程序比如主小程序 一个独立的小程序那每个小程序都有自己的模板 ID但同一小程序的开发版、体验版、正式版用的是同一套模板 ID。矛盾就出在这里。有些团队为了环境隔离给每个环境配了不同的 AppID那么模板 ID 也必然不同。这时候如果配置文件里只写了生产环境的 ID在开发环境用测试 AppID 去调必然 40037。反过来如果开发环境的配置文件里残留着上一版小程序的 ID上线之后忘了同步生产环境就会挂。我见过一个更隐蔽的变体CI 流水线里做配置注入时用了一个兜底默认值也就是环境变量没读到的时候用代码里写死的那个 ID。某次发布时环境变量名拼错了一个字母注入失败于是静默走了兜底值而兜底值是半年前的老 ID。整个过程没有任何报错直到用户反馈收不到通知才发现。防护手段其实不复杂在应用启动时打印一行日志把当前生效的 AppID 后四位和模板 ID 的后六位打出来同时做一个长度和字符集校验。这行日志在排查时的价值极高因为它能瞬间告诉你线上跑的是哪套配置。2.4 字符串污染看不见的空格、换行与BOM这一类的比例不低而且最难靠肉眼发现。典型场景是从后台复制模板 ID 时鼠标多拖了一点末尾带了个空格或者从聊天工具里粘贴时中间混进了一个换行符或者从 Excel、CSV 里导入配置时文件开头带了 BOM 头。服务端对 template_id 的校验是严格字符串匹配多一个空格就是不相等。而你在编辑器里看这个字符串肉眼几乎分辨不出来。日志打印时如果不加引号包裹空格也会被吃掉看起来完全正常。我吃过这个亏。有一次排查了快两个小时最后是把请求体用 JSON 序列化之后打印出来才发现 ID 后面有个\n。从那以后我养成了一个习惯所有从外部来源进入的 ID、token、密钥在赋值之后立刻做一次trim()加字符集校验只允许字母和数字其余一律判为异常并抛错。function assertTemplateId(id) { if (typeof id ! string) throw new Error(template_id 必须是字符串); const cleaned id.trim(); if (cleaned ! id) { console.warn([warn] template_id 存在首尾空白已自动清理); } if (!/^[A-Za-z0-9_-]$/.test(cleaned)) { throw new Error(template_id 含非法字符: JSON.stringify(id)); } if (cleaned.length 20) { throw new Error(template_id 长度异常疑似拿错了体系: cleaned.length); } return cleaned; }这段代码不长但它把空格换行长度不对三种情况一次性拦住了。上线之后我们那边因为字符串污染导致的 40037 直接归零。2.5 接口与ID类型不匹配第五类是接口用错了。前面表格里列过旧模板消息接口和订阅消息接口是两个路径。有些同学在迁移过程中只改了模板 ID忘了改接口路径或者反过来接口换了但 ID 还是旧的。这里有个容易混淆的点两个接口的报错长得一模一样都是 40037。所以你不能靠报错区分自己调的是哪个接口必须去看代码里请求的 URL。还有一种情况是混用参数。订阅消息接口的参数结构里data的每个字段是{ value: xxx }的嵌套结构字段名必须是模板里定义的关键词比如thing1、time2、amount3。如果你把一个旧模板消息的数据结构硬塞进订阅消息接口字段名对不上有时报参数错误有时因为模板本身就对不上而先报 40037。我的建议是迁移时不要改要重写。照着当前后台里能看到的最新模板把字段名、字段类型、字段顺序全部重新对一遍然后把旧的发送函数整个替换掉而不是在原函数上打补丁。打补丁留下的历史分支是后面所有诡异问题的温床。2.6 服务商代调用与多AppID场景最后一类出现在稍微复杂一点的架构里有第三方服务商帮多个小程序代发消息或者一个后台系统对接了多个小程序主体。这种情况下access_token 是按 AppID 维度管理的模板 ID 也是按 AppID 维度绑定的。如果你的消息中台用一个统一的缓存池存 token但取 token 和取模板 ID 的时候没做 AppID 对齐就会出现A 小程序的 token 配 B 小程序的模板 ID的组合结果就是 40037。更隐蔽的是缓存键设计。有些实现里token 缓存的 key 只用了业务类型而没带 AppID多主体并发时就会互相覆盖。表现出来就是时好时坏同一个模板 ID 上午能发、下午不能发排查起来非常折磨人。这种场景下的防护要点是把 AppID 作为所有缓存键的前缀把模板 ID 和 AppID 做成一个不可分割的配置对象取模板的时候必须连着 AppID 一起取从数据结构上杜绝错配的可能。// 不推荐两个独立的配置项容易错配 const APP_ID ...; const TEMPLATE_ID ...; // 推荐绑定成一个整体取值时无法分离 const MSG_CONFIG { appId: ..., templates: { orderShipped: { id: ..., fields: [character_string1, thing2, time3] }, paySuccess: { id: ..., fields: [amount1, thing2, date3] }, }, };结构上的约束比人的自觉更可靠这是我这些年最深的体会之一。3. 实操排查从报错到定位的完整路径前面把成因列全了但真到现场你还是需要一个有顺序的动作清单。我的习惯是从信息量最大、成本最低的动作开始逐步收敛。下面这四步是我自己用了很久的流程基本覆盖了上面六类情况。3.1 第一步确认手里的access_token属于哪个AppID这一步经常被跳过因为大家都默认token 肯定没问题报错都说了是模板 ID 的问题。但我要说的是报错说模板 ID 无效不代表 token 一定对。如果 token 属于 A 小程序模板 ID 属于 B 小程序服务端校验模板时会拿 A 的上下文去看 B 的 ID同样报 40037。所以先确认 token 身份能直接排除掉一整类问题。确认方法很直接用当前 token 调一个获取账号基本信息的接口返回里会带 appid 字段curl https://api.weixin.qq.com/cgi-bin/account/getaccountbasicinfo?access_token你的TOKEN返回大致长这样{ errcode: 0, errmsg: ok, appid: wx????????????????, account_type: 1, principal_type: 0, principal_name: ... }拿到 appid 之后和你在后台查模板时登录的那个小程序主体对一下。对不上问题就到手了去查 token 缓存和 AppID 的对应关系。顺便提一下 token 本身的管理。token 有效期是 7200 秒全局唯一重复获取会让旧 token 在短时间内仍然有效但很快失效。如果多个实例同时去刷新很容易互相顶掉。我现在的做法是统一走 stable_token 接口配合 Redis 做单点刷新加分布式锁async function getStableToken(appId, appSecret) { const key wx:token:${appId}; const cached await redis.get(key); if (cached) return cached; const lockKey wx:token:lock:${appId}; const locked await redis.set(lockKey, 1, NX, PX, 10000); if (!locked) { // 其他实例正在刷新短暂等待后重试读缓存 await sleep(300); return redis.get(key); } try { const { data } await axios.post(https://api.weixin.qq.com/cgi-bin/stable_token, { grant_type: client_credential, appid: appId, secret: appSecret, force_refresh: false, }); if (data.errcode) throw new Error(token 获取失败: ${data.errcode} ${data.errmsg}); // 提前 300 秒过期留出安全边界 await redis.set(key, data.access_token, EX, data.expires_in - 300); return data.access_token; } finally { await redis.del(lockKey); } }这段代码里有两个细节值得说。一是expires_in - 300的提前量二是分布式锁。提前量是为了避免刚取到 token 下一秒就过期的边界情况300 秒对大多数业务足够。分布式锁是为了防止多实例同时刷新导致 token 互相失效——这个坑我在一个多副本部署的项目里踩过表现就是每隔两小时有一小段时间消息发送全部失败。3.2 第二步后台比对逐字符核对模板ID确认 token 身份之后下一步是去后台核对模板 ID 本身。这一步听起来很傻但真的有效。打开小程序后台进入订阅消息模块找到我的模板。这里列出的是当前 AppID 下所有可用的模板。你要做的是确认代码里那个 ID 在列表中存在复制后台显示的完整 ID和代码里的值做逐字符比对特别关注首尾以及中间容易混淆的字符关于混淆字符有个细节值得一提这类 ID 是大小写敏感的随机串有些字体下l小写 L和I大写 i几乎一样0和O也是。如果是从截图里手抄的 ID出错概率极高。我现在的原则是绝对不手抄只允许复制粘贴而且在粘贴之后立刻用代码比对长度。如果后台列表里压根找不到这个 ID那就进入 2.2 节说的情况模板可能被删了。这时候要做的不是继续排查代码而是重新选一个模板、拿到新 ID、更新配置、重新走发布流程。3.3 第三步用curl把请求从代码里剥出来前两步排除了配置和身份问题之后如果还是 40037就该怀疑代码链路了。这时候最有价值的动作是把发送逻辑从业务代码里完全剥出来用一个最朴素的方式复现。我一般直接用 curl 拼一个最小请求curl -X POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token你的TOKEN \ -H Content-Type: application/json \ -d { touser: 用户的OPENID, template_id: 你后台看到的完整模板ID, page: pages/order/detail?id123, miniprogram_state: formal, lang: zh_CN, data: { character_string1: { value: SF1234567890 }, thing2: { value: 已发货 }, time3: { value: 2024年6月1日 15:30 } } }这个请求的价值在于所有变量都是你手写的、看得见的。如果 curl 通了说明 ID 和 token 都没问题问题一定在后端代码的变量传递上如果 curl 也报 40037那说明问题在 ID 或 AppID 层面跟代码无关。这个二分动作能砍掉一半的排查范围而且成本极低。我强烈建议把它作为标准动作而不是等到排查两小时之后再想起来。顺带说几个 curl 阶段的易错点。第一miniprogram_state的取值是developer、trial、formal三选一开发阶段如果填了formal而小程序还没发布可能导致跳转异常虽然不影响发送成功与否但会影响点击后的体验。第二page不要以/开头也不要带域名只写页面路径加 query。第三JSON 里的中文如果终端编码有问题会变成乱码建议在文件里写好再用-d file.json引用。3.4 第四步给发送链路加自检和日志如果前四步都过了还是偶发失败那就该怀疑运行时了。这时候要给发送链路加埋点。我一般会在发送函数里记录这么几项本次使用的 AppID 后四位、模板 ID 后六位、模板 ID 长度、openid 前六位、data 各字段的长度、以及最终请求体的完整 JSON 序列化字符串。这几项加起来不会超过 500 字节但对排查的价值极高。function buildLogPayload(appId, templateId, openid, data) { return { appIdTail: appId.slice(-4), tplTail: templateId.slice(-6), tplLen: templateId.length, openidHead: openid.slice(0, 6), fieldLens: Object.fromEntries( Object.entries(data).map(([k, v]) [k, String(v.value).length]) ), }; }日志里不要打完整的模板 ID 和 openid打尾部和长度就够了。这既是合规要求也避免了日志里出现可被滥用的敏感串。前六位加长度的组合在比对时已经足够区分不同的值了。4. 可复用实现把校验做进发送封装排查是事后补救真正省事的是事前拦截。这一章给出两套可以直接拿去用的封装思路是一致的把参数合法性检查和发送绑在一起任何非法输入在本地就抛错不浪费一次网络请求也不会在服务端得到一个语焉不详的 40037。4.1 Node.js 版本的发送封装先看完整的封装结构const axios require(axios); const MSG_CONFIG { appId: process.env.WX_APPID, templates: { orderShipped: { id: process.env.WX_TPL_ORDER_SHIPPED, fields: { character_string1: { type: character_string, max: 32 }, thing2: { type: thing, max: 20 }, time3: { type: time, max: 32 }, }, }, }, }; function validateTemplateId(id) { if (typeof id ! string) throw new Error([40037 前置] template_id 类型错误); const s id.trim(); if (s ! id) console.warn([40037 前置] template_id 有首尾空白已清理); if (!/^[A-Za-z0-9_-]{20,64}$/.test(s)) { throw new Error([40037 前置] template_id 格式非法长度${s.length}); } return s; } function validateData(fields, data) { for (const [name, rule] of Object.entries(fields)) { const raw data[name]; if (raw undefined) { console.warn([参数缺失] ${name} 未提供将导致模板字段为空); continue; } const val String(raw.value ?? raw); if (val.length 0) throw new Error([参数非法] ${name} 为空字符串); if (val.length rule.max) { throw new Error([参数超长] ${name} 长度 ${val.length} ${rule.max}); } } } async function sendSubscribeMsg({ scene, openid, page, data, state formal }) { const tpl MSG_CONFIG.templates[scene]; if (!tpl) throw new Error([配置缺失] 未定义的消息场景: ${scene}); const templateId validateTemplateId(tpl.id); validateData(tpl.fields, data); const token await getStableToken(MSG_CONFIG.appId, process.env.WX_SECRET); const url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token${encodeURIComponent(token)}; const payload { touser: openid, template_id: templateId, page, miniprogram_state: state, lang: zh_CN, data: Object.fromEntries( Object.entries(data).map(([k, v]) [k, { value: String(v.value ?? v) }]) ), }; const { data: resp } await axios.post(url, payload, { timeout: 8000 }); if (resp.errcode ! 0) { const err new Error(发送失败: ${resp.errcode} ${resp.errmsg}); err.errcode resp.errcode; err.payload buildLogPayload(MSG_CONFIG.appId, templateId, openid, payload.data); throw err; } return resp; }这套封装的关键设计是把模板字段规则和模板 ID放在同一个配置对象里。这样一来新增一个消息场景只需要在配置里加一项校验和发送自动生效不会出现加了模板但忘了加校验的情况。另外注意encodeURIComponent那处。access_token 通常只含字母数字和少量符号绝大多数时候不编码也没事但拼接 query 参数时统一做一次编码是不花成本的保险。同理page参数如果带了动态的 query 值里面可能出现中文或特殊字符也需要编码。4.2 Python 版本的等价实现Python 侧的逻辑完全一致只是表达方式不同import os import re import time import requests TEMPLATES { orderShipped: { id: os.environ.get(WX_TPL_ORDER_SHIPPED, ), fields: { character_string1: {max: 32}, thing2: {max: 20}, time3: {max: 32}, }, }, } _TPL_RE re.compile(r^[A-Za-z0-9_-]{20,64}$) def validate_template_id(tpl_id: str) - str: if not isinstance(tpl_id, str): raise ValueError([40037 前置] template_id 类型错误) s tpl_id.strip() if not _TPL_RE.match(s): raise ValueError(f[40037 前置] template_id 格式非法长度{len(s)}) return s def send_subscribe_msg(app_id, app_secret, scene, openid, page, data, stateformal, timeout8): cfg TEMPLATES.get(scene) if not cfg: raise KeyError(f[配置缺失] 未定义的消息场景: {scene}) tpl_id validate_template_id(cfg[id]) for name, rule in cfg[fields].items(): val str(data.get(name, {}).get(value, ) if isinstance(data.get(name), dict) else data.get(name, )) if not val: raise ValueError(f[参数非法] {name} 为空) if len(val) rule[max]: raise ValueError(f[参数超长] {name} {len(val)} {rule[max]}) token get_stable_token(app_id, app_secret) resp requests.post( https://api.weixin.qq.com/cgi-bin/message/subscribe/send, params{access_token: token}, json{ touser: openid, template_id: tpl_id, page: page, miniprogram_state: state, lang: zh_CN, data: { k: {value: str(v.get(value) if isinstance(v, dict) else v)} for k, v in data.items() }, }, timeouttimeout, ) body resp.json() if body.get(errcode) ! 0: raise RuntimeError(f发送失败: {body.get(errcode)} {body.get(errmsg)}) return bodyrequests的params参数会自动做 URL 编码这点比手工拼字符串安全。timeout一定要设订阅消息接口偶尔会有几秒的抖动不设超时会把业务线程挂住。4.3 模板参数长度规则与自动裁剪说完 ID还得说参数。虽然参数超长报的是 47003 而不是 40037但两者经常一起出现而且在排查时容易互相干扰——你改了 ID报错从 40037 变成 47003会让人误以为方向对了但还差一点其实是从一个问题跳到了另一个问题。订阅消息各字段类型的长度限制大致如下字段类型允许内容长度上限备注thing汉字、数字、字母、符号20 个字符超出会被拒建议按 20 个汉字算number纯数字32 位不支持小数和负号letter纯字母32 位大小写均可symbol符号5 位应用面很窄character_string数字、字母、符号32 位订单号、运单号首选time24 小时制时间可带年月日32 位支持15:01或2024年6月1日 15:01date年月日可带时间32 位支持2024年6月1日amount币种符号 数字可带小数12 位左右可带元字结尾phone_number数字、符号17 位支持和-car_number数字、字母8 位车牌场景name汉字10 位人名场景phrase汉字5 位状态词如已发货实际开发中最容易超长的是thing类型。商品名称动辄二三十个字直接塞进去必然失败。我的处理方式是做一个统一的裁剪函数按字符数截断并补省略号function fitThing(text, max 20) { const s String(text ?? ).replace(/\s/g, ).trim(); if (s.length max) return s; return s.slice(0, max - 1) …; }注意这里用的是length而不是按字节算。对于thing类型官方口径是最多 20 个汉字中英文混排时按字符数截断基本不会出问题。但如果你的文案里混了大量全角符号稳妥起见可以按 18 个字符截断留两个字符的余量。注意裁剪要发生在业务层不要在发送封装里做静默截断。因为截断意味着信息丢失业务方应该知道自己传的文案被砍了。我现在的做法是封装层只做校验和抛错裁剪函数交给调用方显式调用这样责任清晰。5. 问题速查表与踩坑心得前面四章把原理、成因、排查、实现都过了一遍。这一章把高频问题整理成表再补几个印象比较深的现场方便你出问题时直接对照。5.1 高频报错速查表errcodeerrmsg最可能的原因优先动作40037invalid template_idID 抄错、体系混用、模板已删、环境错配后台核对 ID 是否存在比对长度和字符集40001invalid credentialtoken 失效或被顶掉检查 token 缓存与刷新逻辑42001access_token expiredtoken 过期同上确认提前刷新机制40003invalid openidopenid 不属于当前 AppID核对 openid 来源禁止跨主体复用40013invalid appidAppID 与 token 不匹配打印 appid 与 token 的对应关系41030invalid pagepage 路径写错或带了域名改成纯页面路径不带前导斜杠47003argument invalid参数字段名错、类型不符、超长对照后台模板字段定义逐项核对43101user refuse to accept the msg用户未订阅或已拒收属正常业务分支不要当异常重试45009reach max api daily quota当日调用量超限检查是否有死循环重试这张表里我特别想强调 45009 和 43101 这两行。它们本身不是配置问题但经常是由配置问题衍生出来的——比如 40037 之后没有区分错误类型无脑重试很快就把当日额度打满然后真正的业务消息也发不出去了。所以重试策略必须按错误码分类40037、40003 这类配置错误重试一万次也没用应该直接进死信队列告警43101 属于正常的用户选择应该静默记录只有网络超时、5xx 这类才值得重试。5.2 几个印象比较深的现场第一个现场是半年前的老 ID 复活。某次发布之后测试环境突然开始报 40037代码 diff 里没有任何相关改动。查了半天发现是那次发布顺带升级了一个依赖包而这个依赖包在初始化时会读一个环境变量环境变量名和业务代码里用的重名了导致业务侧读到了空值走了兜底逻辑兜底里写的是半年前的老 ID。这类问题的教训是兜底值必须显式标注来源和日期超过一定时间就该报错而不是静默生效。第二个现场是后台看着有代码就是不行。有位同学坚持说后台列表里明明有这个 ID代码也是复制的。最后发现他在后台看的是公共模板库里的模板而不是我的模板。公共模板库里的模板是可以预览的但没添加到自己账号下就没有属于你的 ID。这个区别在界面上不算特别明显第一次接触的人很容易看错位置。判断方法很简单公共模板库里的模板有添加按钮已添加的模板会出现在我的模板列表里并且带一串完整 ID。第三个现场是只在深夜失败。一个项目的消息发送在凌晨两三点必挂其他时间正常。查了很久最后定位到是 token 缓存的一个边界问题缓存过期时间和一次定时任务的时间撞上了定时任务刷新 token 之后旧 token 在极短时间内还有效业务侧读到旧 token 去发请求正好落在一个失效窗口里。改成单点刷新加锁之后就好了。这类只在特定时间出问题的现象往往指向缓存和并发而不是配置。第四个现场是字符看着一模一样。两位同学把一个 ID 分别复制到两个文档里比对肉眼看完全一致但一个能用一个不能用。最后用十六进制打印才发现其中一个 ID 的某一位是全角的字母。这个案例之后我把字符集白名单校验变成了封装层的标配就是 2.4 节里那段代码。5.3 几条我自己总结的规矩第一条ID 只允许复制不允许手抄、不允许从聊天记录二次转发。聊天工具会自动给长串加链接或者做换行非常容易污染。要传的话走配置文件或者内部文档别走即时通讯。第二条模板 ID 和 AppID 在数据结构上绑定。前面演示过把它们放进同一个配置对象里从结构上杜绝错配。这条规矩救过我至少两次。第三条所有校验前置让错误在本地就暴露。花半天写一段校验代码能省掉后面无数个线上偶发 40037的排查。校验的成本是一次性的排查的成本是每一次。第四条错误码要分类处理不要统一重试。配置类错误进死信队列并告警用户侧错误静默记录网络类错误才重试。这条规矩本质上是在保护你的接口额度。第五条模板变更要有记录。每次在后台增删改模板同步更新配置文件和一份变更记录写清楚时间、操作人、影响范围。真出问题时这份记录能把你从到底谁动了什么的泥潭里拉出来。我个人在实际操作中的体会是这个报错本身的技术含量并不高它的难处在于信息不对称——服务端只给你一个错误码其余的上下文全在你自己的系统里。所以真正有效的做法不是背错误码而是把自己这一侧的信息整理清楚谁在发、用哪个 AppID、用哪个模板、模板长什么样、参数是什么。这五项信息一旦能随时打印出来40037 就再也耗不掉你一个晚上了。最后再分享一个小技巧。如果你手上有一批历史遗留的模板 ID 不确定还能不能用可以写一个批量校验脚本把配置里所有 ID 取出来逐个拼一个最小请求发出去看返回码。注意touser用一个不存在的 openid 就行因为校验顺序里模板 ID 在 openid 之后……这个思路走不通。更稳妥的做法是逐个在后台我的模板列表里做字符串匹配匹配不上的直接标记为待清理。这个脚本我写过一次一次性清掉了十一个僵尸 ID之后项目的消息模块清爽了很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询