拓冰建站拓冰建站
首页 / 资讯中心 / 正文

小程序订阅消息多次发送实战:善用一次性订阅授权(附PHP代码)

做小程序开发的人大概率都有过这种体验业务逻辑跑得顺顺当当一到订阅消息就卡壳。尤其是电商、预约、工具类小程序经常需要给用户连续推送好几条状态通知比如“订单已支付”“已发货”“已完成”但微信官方给普通行业开放的基本只有一次性订阅消息用户点一次授权你只能下发一条。长期订阅消息听起来很美好可那是特定公共领域才有资格申请的东西绝大多数团队根本拿不到。于是“如何绕过长期订阅限制、实现多次发送”就成了开发者圈子里被反复问起的问题。这篇文章我把自己的实战经验完整写出来不绕弯子。你先理解订阅消息的额度机制再看两套基于官方能力设计的多发送方案最后是一份可以直接抄的 PHP 后端代码包括 access_token 缓存、订阅消息下发、错误码排查都覆盖到位。适合正在做小程序前端的同学也适合负责小程序接口的 PHP 后端工程师。1. 先搞懂订阅消息到底卡在哪1.1 长期订阅为什么大多数人都申请不下来微信的订阅消息分为一次性订阅消息和长期订阅消息两类。一次性订阅消息的逻辑是用户授权一次开发者获得一条对应模板的下发额度用完就没了。而长期订阅消息理论上可以让开发者在用户授权后的一段时间内多次下发不用每次征求用户同意听起来非常符合“多次发送”的需求。但现实是长期订阅消息的开放范围非常窄。根据微信开放平台的规则它主要面向政务、医疗、民生服务、交通出行等涉及公共服务的特定类目。普通电商、工具、内容、企业服务类小程序在后台配置订阅消息模板的时候基本找不到可选的长期订阅模板即便个别类目显示有长期订阅模板提交后还要经过严格的人工审核。所以对大多数团队来说与其花时间死磕长期订阅的申请资格不如认真研究一次性订阅消息的规则在规则内做出“多次发送”的效果。这里有一个很多人误会的点一次性订阅消息限制的并不是“一个用户只能收一条”而是“一次授权对应一次下发”。你把这句话读三遍后面的方案设计思路就打开了大半。1.2 真正决定发送次数的是“授权次数”而不是“用户数”很多开发者把一次性订阅消息理解成终身只能给用户发一条这是一个典型的误解。wx.requestSubscribeMessage每次被用户主动操作触发时用户都会看到一个授权弹窗里面展示了你传进去的一个或多个模板。用户每同意一个模板你的后端就多了一条该模板的下发额度。用户不同意则什么都不会发生。举个例子用户在你的小程序里完成了一次预约你在他点击“确认预约”的回调里调用了一次wx.requestSubscribeMessage传了一个模板A。用户点了允许你得到A模板的一次下发额度。过了一周用户又来预约了一次再次点击“确认预约”又会弹出授权他再次允许你又得到一次额度。所以这个机制天然支持“按业务动作累计发送次数”前提是得让用户每次真实地做出那个关键动作。这也是“绕过长期订阅限制”最核心的底层逻辑不追求一次性给用户未来所有消息授权而是把每一次消息绑定到用户每一次可能产生消息的业务行为上。业务上需要N条通知就设计成用户在业务上有N次操作机会从而获得N次授权。这个思路完全在官方规则之内也是长期订阅之外最稳妥的落地方式。2. 两套方案如何在官方规则内实现多次发送2.1 方案一一次点击把多个模板一次性打包授权如果你的业务模式比较固定比如订单类小程序一定会经历“支付成功”“发货”“完成”这几个阶段你完全可以在用户支付成功后那一次关键操作里把三个阶段对应的模板ID一次性传进去。wx.requestSubscribeMessage支持一次拉起多个模板常见的边界值是一次最多3个用户在这个弹窗里可以分别勾选或拒绝。前端代码大致长这样wx.requestSubscribeMessage({ tmplIds: [ 模板ID_支付成功, 模板ID_订单发货, 模板ID_订单完成 ], success(res) { // res 里每个模板ID对应一个状态 // accept 表示用户接受reject 表示用户拒绝ban 表示被系统限制 if (res[模板ID_支付成功] accept) { // 通知后端支付成功模板已获得额度 } if (res[模板ID_订单发货] accept) { // 通知后端发货模板已获得额度 } if (res[模板ID_订单完成] accept) { // 通知后端完成模板已获得额度 } } })用户如果全部接受你的后端就一次性收到了三份额度后续订单流转到哪个环节就用对应的模板下发消息。这个方案的优势是简单粗暴一次弹窗解决整个订单生命周期劣势也很明显弹窗里模板越多用户越容易嫌烦甚至直接全选“拒绝”。所以我的建议是三个模板已经差不多是用户体验的上限了千万别为了攒次数把一个根本不需要通知的场景也塞进去。多问一句自己——如果我是用户看到这三个通知会觉得合理吗如果有一个明显是营销性质那这次授权通过率会直线下降。2.2 方案二把授权动作嵌进真实业务动作里如果你的业务通知是不规律、按用户行为触发的比如打卡提醒、库存到货、活动开奖那更适合用第二种方案在每个会产生消息的业务动作发生后立即插入一次订阅授权请求而不是集中一次性拉取。举个我们项目里的例子。一个打卡类小程序用户每天点“开始打卡”时我们需要在晚上提醒他“今天还没打卡”。原先我们的做法是希望依靠长期订阅结果申请不下来。后来我换了一个思路用户在每天早上点“开始打卡”这个动作本身就是用户主动行为我就在这个按钮的回调里调用wx.requestSubscribeMessage把“每日提醒”模板传进去。用户允许就记录一次额度然后等晚上触发提醒时去消费。这样从表面看用户每天都只授权了一次但实际上我们每天都拿到了一次新的下发额度。连续打卡5天就攒下了5次提醒发送机会完全不需要长期订阅。这是我认为最接近“长期订阅效果”的替代方案而且因为授权请求和用户利益是强绑定的授权通过率通常还不错。需要特别提醒的是wx.requestSubscribeMessage必须在用户真实点击事件的回调里同步调用不能放在setTimeout、网络请求回调等异步流程里否则很容易出现弹窗拉不起来的情况。这一点在官方文档里有说明但在实际开发里踩坑的人非常多。2.3 这么绕到底会不会违规这是每次分享时大家最关心的问题。我的结论是只要你的授权请求是基于用户真实操作、意图明确、不隐瞒就没有问题。这个机制本身就是官方设计的一部分wx.requestSubscribeMessage允许你反复调用授权弹窗也允许用户反复拒绝或同意。真正会被官方处理的行为是“诱导订阅”“强制订阅”和“消息内容与用户授权场景严重不符”。比如用户只是想看一个商品你非让他先授权五个模板又或者用户授权了“订单提醒”结果你给他发广告这两种情况都容易招致投诉和限制。所以方案设计上一定要克制宁可少拿一次额度也别为了多抓一个授权把用户体验做崩。3. PHP 后端完整实现从 openid 到消息下发3.1 准备工作与接口清单后端发订阅消息需要三个基础要素access_token、用户openid、模板ID。模板ID在小程序管理后台的“订阅消息”页面里新建和查看openid则通过用户在小程序端wx.login()得到的临时code由后端调用jscode2session接口换取。这一步经常被叫做“用 code 换 openid”也是很多新手第一次接触后端接口时容易搞混的地方。整个流程涉及微信公众号平台接口我把常用接口整理如下接口用途请求方式接口地址获取 access_tokenGET/cgi-bin/tokencode 换 openidGET/sns/jscode2session发送订阅消息POST/cgi-bin/message/subscribe/send注意发送订阅消息的接口地址是/cgi-bin/message/subscribe/send不是老旧的/cgi-bin/message/template/send。后者是模板消息接口适用于公众号小程序订阅消息要认准subscribe/send否则会一直报路径不对。3.2 封装一个可复用的 PHP 类这一节我直接给完整代码。我会封装一个WechatMiniApi类把 access_token 获取与缓存、code 换 openid、订阅消息发送都放到一起。access_token 使用文件缓存是为了避免每次请求都去微信接口刷新 token触发频率限制。?php class WechatMiniApi { private $appid; private $secret; private $cacheFile; public function __construct($appid, $secret, $cacheFile /tmp/wechat_access_token.json) { $this-appid $appid; $this-secret $secret; $this-cacheFile $cacheFile; } /** * 获取 access_token带文件缓存 */ public function getAccessToken() { if (file_exists($this-cacheFile)) { $data json_decode(file_get_contents($this-cacheFile), true); if ($data $data[expire_time] time() 60) { return $data[access_token]; } } $url sprintf( https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappid%ssecret%s, $this-appid, $this-secret ); $result $this-httpGet($url); $json json_decode($result, true); if (isset($json[access_token])) { // 提前 60 秒过期避免临界点使用失败 file_put_contents($this-cacheFile, json_encode([ access_token $json[access_token], expire_time time() intval($json[expires_in]) - 60, ])); return $json[access_token]; } throw new Exception(获取 access_token 失败: . $result); } /** * wx.login 的 code 换取 openid */ public function codeToOpenid($code) { $url sprintf( https://api.weixin.qq.com/sns/jscode2session?appid%ssecret%sjs_code%sgrant_typeauthorization_code, $this-appid, $this-secret, $code ); return json_decode($this-httpGet($url), true); } /** * 发送订阅消息 * * param string $openid 用户 openid * param string $templateId 模板 ID * param array $data 模板字段形如 [thing1 [value 文案]] * param string $page 点击消息跳转的小程序页面路径 */ public function sendSubscribeMessage($openid, $templateId, array $data, $page ) { $accessToken $this-getAccessToken(); $url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token . $accessToken; $body [ touser $openid, template_id $templateId, data $data, miniprogram_state formal, lang zh_CN, ]; if ($page ! ) { $body[page] $page; } $result $this-httpPost($url, json_encode($body, JSON_UNESCAPED_UNICODE)); return json_decode($result, true); } private function httpGet($url) { $ch curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); $res curl_exec($ch); curl_close($ch); return $res; } private function httpPost($url, $postData) { $ch curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $postData); curl_setopt($ch, CURLOPT_HTTPHEADER, [Content-Type: application/json]); $res curl_exec($ch); curl_close($ch); return $res; } }这里有两个容易被忽略的点。第一CURLOPT_SSL_VERIFYPEER我在本地和测试环境关掉了生产环境有条件的话应该打开并配置正规的 CA 证书避免中间人风险。第二access_token 用文件缓存对单机部署够了如果服务是多机部署或者需要更高并发建议换成 Redis 并加锁防止同一时刻多个进程同时刷新 token。3.3 实际调用订单发货通知场景假设用户在小程序端完成了支付后端收到了支付回调你需要给用户推一条“订单已发货”的订阅消息。调用方式是先通过订单记录拿到用户 openid然后把模板需要的字段按data格式组装好。$api new WechatMiniApi(你的appid, 你的secret); $res $api-sendSubscribeMessage( oOpenIdXXXX, 模板ID_订单发货, [ thing1 [value 您的订单已从仓库发出], character_string2 [value DD202401010001], time3 [value 2024-01-02 10:30], ], pages/order/detail?order_id10001 ); if (isset($res[errcode]) $res[errcode] 0) { // 发送成功 } else { // 记录日志结合错误码处理 error_log(json_encode($res, JSON_UNESCAPED_UNICODE)); }模板字段的键名必须和你后台配置完全一致比如后台模板是thing1、character_string2、time3那代码里就写这三个键。thing类型最多20个汉字character_string最多32个字符time的格式是yyyy-MM-dd HH:mm。字段长度超了或者格式不对接口会返回错误而且往往不会告诉你具体哪个字段错只能一个个排查所以组装数据时就要严谨。3.4 额度的后端管理别发了就完事要做提单记录方案一和方案二的核心都是“多次授权、多次发送”那后端就必须知道每个用户手里有几个模板、每个模板还剩几次额度。我的做法是建一张订阅额度表前端在用户授权成功之后把该用户同意了的模板ID上报给后端后端插入一条额度记录真正发消息时消费掉对应的一条额度。建议的建表SQLCREATE TABLE user_subscribe_quota ( id int(11) unsigned NOT NULL AUTO_INCREMENT, openid varchar(64) NOT NULL DEFAULT , template_id varchar(64) NOT NULL DEFAULT , scene varchar(32) NOT NULL DEFAULT , status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0未使用 1已使用, created_at datetime NOT NULL, consumed_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_openid_template_status (openid, template_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订阅消息额度表;发送消息时尽量用条件更新来消费额度避免并发重复发送。比如先更新status1再判断受影响行数只有更新成功了才调用微信接口$sql UPDATE user_subscribe_quota SET status 1, consumed_at NOW() WHERE id ? AND status 0; $stmt $pdo-prepare($sql); $stmt-execute([$quotaId]); if ($stmt-rowCount() 0) { // 额度已被消费直接中断防止重复下发 return; } // 到这里再调用 sendSubscribeMessage这种“先锁额度再发送”的思路能帮你规避超时重试时把同一条消息给用户发两次的尴尬。如果微信接口返回的是明确的额度错误比如 43101说明这条额度本身不可用你还需要把对应记录标记成“失效”等前端下次授权再补。4. 高频问题排查与避坑技巧实录4.1 错误码速查表我把开发时遇到最多的几个返回码整理成了一张表遇到问题可以先对照一下大部分都能直接定位方向。errcode含义处理建议0发送成功无需处理40001access_token 无效或过期清空缓存重新获取40003openid 不正确检查是否用正确的 code 换取40037template_id 不正确检查模板ID和当前小程序是否匹配43101用户拒绝订阅或额度不存在前端重新拉起授权弹窗45009接口调用频率超限降低发送频率或错峰重试47003data 字段格式错误核对字段类型、长度、时间格式41030page 页面路径不正确确认路径存在且不以 / 开头需要说明的是微信的返回码会随平台策略更新这张表只覆盖最常见的场景。遇到不认识的状态码第一时间去开发者工具里的“接口调试”面板看完整返回信息量比文档截图大得多。4.2 前端弹窗拉不起来怎么排查订阅消息授权弹窗拉不起来90% 的原因是wx.requestSubscribeMessage没有被放在用户点击事件的同步调用链里。比如你先发了一个网络请求然后在请求的回调里去调授权这在很多情况下会导致弹窗不出现。正确的做法是在bindtap事件里直接调用如果需要等后端返回就先弹授权拿到结果再去做其他事。另外要注意授权弹窗不能被堆叠调用。假如你一个点击事件里既调了wx.requestSubscribeMessage又调了wx.getLocation这类同样是弹窗的接口很可能会有一个弹窗被系统吞掉。常见表现就是只弹了一个另一个毫无反应。真机调试时尤其明显开发工具里反而不一定复现。还有一类问题出在模板配置上如果模板没有通过审核或者模板ID复制错了wx.requestSubscribeMessage会在回调里返回对应模板的状态为reject或者直接报错。所以排障时先去管理后台确认模板状态再排查代码。4.3 字段格式这个坑能让后端排查到怀疑人生47003错误往往是最让人头疼的因为它只告诉你 data 格式不对不说哪里不对。我见过最多的三类坑第一thing类型长度超限。官方限制是20个汉字以内但很多人填入了一长串“您的订单已发货快递单号是xxx请注意查收”妥妥超了。第二time格式写错必须是yyyy-MM-dd HH:mm秒要不要带要看模板定义一般建议照着模板示例填。第三字段键名和模板不一致。后台模板显示的是thing1代码里写成了thing01或者time2写成了date2都会直接报错。我的排查经验是先用后端硬编码一个最简单的 data比如每个字段只填“测试”两个字看接口是否返回0。如果最简单的都报错那问题一定在键名或模板ID如果简单的能过、换成真实数据就报错那再去抠长度和格式效率会高很多。4.4 开发版、体验版和正式版的消息差异还有一个让不少人困惑的地方同一个sendSubscribeMessage调用在开发版、体验版和正式版里表现可能不一样。秘密就在请求体里的miniprogram_state参数。它的取值有三个developer表示开发版trial表示体验版formal表示正式版。如果你在测试阶段用正式版的小程序发消息消息是能发出的但点击这条消息进入的小程序版本会依赖这个参数。生产环境请务必设置成formal否则测试用户点进消息跳转到的可能是体验版体验会很奇怪。另外lang参数是控制消息卡片语言的国内用户通常填zh_CN就完了不要留空留空在某些客户端版本上行为不稳定。5. 几点不吐不快的实操心得5.1 申请长期订阅这件事我劝你尽早放弃第一次做订阅消息的时候我也天真地以为申请到长期订阅就万事大吉结果在模板审核上耗了小半个月最后灰溜溜地回到一次性订阅的老路上来。现在回头看那段时间最值钱的收获恰恰是逼我把“额度积累”这套机制想明白了。如果你所在的类目不是公共服务的范围真的不用再为长期订阅浪费审核周期了官方规则短期内不太可能对普通行业放开研究一次性订阅的多次授权方案才能解决眼下的业务问题。5.2 把额度管理做进公共模块比什么方案都省心我在多个项目里已经把这套user_subscribe_quota表沉淀成了公共模块前端每次授权成功就上报后端统一调度业务代码里只有一个“发消息”的抽象方法。这样过来的好处是无论以后新增多少个模板、多少种业务场景都不用再改底层逻辑只需要新增一行模板配置。新同事接手代码时也不用把整套授权机制重新看一遍省下的沟通成本很可观。5.3 授权转化率才是真正的护城河最后想提醒一点方案能让你在规则内实现多次发送但别把它当成“无限骚扰用户”的通行证。用户每授权一次都是拿自己的手机屏幕空间给你投了一次信任票。我在实际运营中发现真正能把订阅消息打开率做高的团队不是发了最多消息的团队而是每次发的内容都跟用户当前需求强相关的团队。消息发得准用户才愿意下次继续点授权次数积累得越多长线收益越大。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门