十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序订阅消息PHP开发实战:一次性授权如何实现多次推送

微信小程序订阅消息PHP开发实战:一次性授权如何实现多次推送 微信小程序订阅消息这个东西圈子里的同学应该都不陌生。天天被运营催着“给用户推消息”但微信对订阅消息的限制又卡得很死尤其是长期订阅类目不开放普通开发者根本拿不到权限。我之前做一个工具类小程序的时候就卡在这个坎上折腾了大半个月才把整套链路理顺。今天把这段实战经历整理出来重点说说如何用一次性订阅消息加合理的交互设计贴近甚至达到“多次发送”的效果附上可以跑起来的PHP后端代码给正在被这个问题折磨的同行一个参考。先说清楚本文不是教你怎么绕过微信的风控或者滥用接口。微信明确不开放长期订阅给大部分类目这是政策和产品层面的红线硬刚没有意义。我讲的“绕过”是指通过产品交互和次数管理让一次性订阅消息发挥出类似长期订阅的多次触达能力。这是目前行业内比较主流、也是微信官方默认接受的方案很多头部小程序都在这么用。这篇内容适合谁后端是PHP、正在做微信小程序开发的同学尤其是被消息触达率低、用户召回难困扰的小伙伴。如果你对access_token、模板消息API这些概念还不熟我也会把基础部分讲透尽量让新手也能照着做出来。1. 长期订阅为何难申请业务思路如何转向先说一个挺现实的背景。微信小程序订阅消息从上线到现在一直分为“一次性订阅消息”和“长期订阅消息”两种形态。长期订阅的好处非常直观用户只要授权一次你就能在后续任意时间给他推送消息不受次数限制。但问题也出在这里微信把这个能力控制得非常谨慎目前仅向部分特定类目开放比如医疗、政务、民生服务等普通电商、工具、内容类小程序基本申请不到。当初我接到需求的时候产品那边直接说“我们要给用户推送发货提醒和活动通知最好每天都能发”。看完文档我心里就凉了半截我们的类目根本不在长期订阅的白名单里。硬着头皮申请了几次每次都是模板审核不通过回复基本是“该类目暂未开放长期订阅消息”。后来我开始认真研究一次性订阅消息的规则发现它其实有一个很关键的特性用户每授权一次开发者只能给他发送一条订阅消息。很多人看到这个“一次性”就直接放弃了但实际上它并没有次数上限。也就是说用户如果愿意多次点击授权理论上你可以获得无数次推送机会。这就是整套方案的核心转折点既然长期订阅申请不下来那就换个思路用“一次性订阅的多次授权”来凑次数。把每一次授权看作一个“消息额度”存到自己的服务器上等需要推送的时候再消耗额度发送。这样产品的推送需求照常实现而用户侧的体验也不会太差——毕竟主动权还是在用户手里他点了授权说明他愿意收到你的消息。我设计的完整业务链路大致是这样的用户在小程序里触发某个关键动作比如下单、预约、提交表单时小程序端弹出订阅授权框用户允许后把授权记录同步到后端后端存储一次可发送的额度后业务系统触发推送事件检查用户剩余额度大于0就调用微信接口下发消息并扣减额度。这套流程的好处是推送次数可控不会骚扰用户而且每个动作的授权都是自然的不需要故意诱导。2. 一次性订阅触发机制与产品交互设计2.1 选择正确的订阅触发时机订阅消息能否顺利发送第一步其实是前端交互。我见过很多开发者的误区把订阅授权框放在用户第一次进入小程序时就弹出来结果用户一脸懵大概率直接点拒绝。拒绝过一次之后后续再调用wx.requestSubscribeMessage就不会弹窗了只能引导用户去小程序的设置页手动开启链路就断了这是非常伤的。所以我后来定的原则是订阅授权必须跟用户当前的动作强相关。比如用户提交了一个预约单他天然关心预约结果这时候弹出订阅窗口让他授权接收“预约状态通知”成功率会高很多。再比如用户付款购买了一个服务给他授权接收“订单进度提醒”也是一个很合理、很自然的请求。比较推荐的做法是在用户完成核心操作之后、跳转结果页之前进行订阅请求。这时候用户的心流是确定的他对小程序的价值已经有了基本认同弹出的授权框不会显得太突兀。如果是在用户刚进入首页就弹用户只会觉得被打扰拒绝率极高。2.2 利用按钮行为实现多次授权的交互套路单次授权的确只能发一条消息但我们可以通过在业务流程中创造多个授权触点让用户多次授权。举个例子我做的预约类小程序用户每次提交一个预约都会触发一次订阅授权。他一周预约了三次那就积累了三次推送额度。这在产品逻辑上完全说得通用户也是知情同意的。订单类的项目还可以这样设计在订单列表页放一个主动按钮比如“订阅该订单进度提醒”用户一旦点击就调起订阅授权。这类按钮适合放在用户期望获取更多信息的场景旁边转化率一般不错。还有一个思路是善用wx.requestSubscribeMessage支持同时传多个模板ID的能力一次弹窗可以让用户同时授权多个模板不同模板对应不同业务类型这样单次授权的价值就变大了。需要注意的是微信这边的订阅授权是“用户点击即授权未点击即拒绝”不存在中间状态。也就是说每次调用wx.requestSubscribeMessage用户只要在弹窗里点了“允许”后端就能得到一次有效额度。如果用户点了“取消”那这一次就是失败不会积累到次数。2.3 前端调用requestSubscribeMessage的坑wx.requestSubscribeMessage是前端调用的核心API代码逻辑比较简单但有几个隐性坑值得说一下。首先是tmplIds参数它有一个限制最多只能传3个模板ID。有些项目模板特别多想着多传几个让用户一次全部订阅实测会直接报错。再有一个是需要用户真正的手势触发。这个API必须放在点击事件回调里调用不能在onLoad、onShow这些生命周期里直接执行也不能用setTimeout延迟调用否则会报requestSubscribeMessage:fail can only be invoked by user TAP gesture。这个限制一开始很容易踩我还以为是基础库版本问题查了很久才发现是手势触发的硬性要求。最后是错误码处理。前端api调用失败时返回的errMsg不同失败原因的文案可以用来判断用户有没有在设置里关闭权限。比如返回requestSubscribeMessage:fail user denied说明用户拒绝如果返回requestSubscribeMessage:fail The main switch is switched off说明用户在小程序设置里关了订阅消息总开关这时候就需要引导他去设置页打开了。把这些分支在代码里处理清楚才能给用户一个完整的闭环体验。下面是前端核心代码片段我直接贴一个简单可用的版本Page({ data: { templateIds: [ 模板ID1, 模板ID2, 模板ID3 ] }, handleSubscribe: function () { wx.requestSubscribeMessage({ tmplIds: this.data.templateIds, success: (res) { // 这里只处理用户点了“允许”的模板 const granted []; for (const item of this.data.templateIds) { if (res[item] accept) { granted.push(item); } } if (granted.length 0) { wx.showToast({ title: 授权失败, icon: none }); return; } // 把授权的模板列表提交给后端做额度累加 wx.request({ url: https://yourdomain.com/api/subscribe/add, method: POST, data: { openid: this.data.openid, templateIds: granted }, success: (resp) { console.log(授权记录同步成功, resp); } }); }, fail: (err) { console.error(订阅消息授权失败, err); } }); } })注意一下res里的数据是res[templateId] accept | reject | ban这样的结构。ban表示用户之前拒绝过微信不再弹窗需要走设置引导。我在项目里就是靠这个状态来区分用户属于哪种情况的。3. PHP后端实现订阅消息发送全流程3.1 用户授权次数的存储设计小程序端把授权情况上报到后端后我们需要在数据库里记录次数。存储结构我不建议只存一个总数因为次数跟模板ID是绑定关系的。比如用户对“订单进度通知”授权了2次对“活动通知”授权了1次这两个模板的额度不能混在一起用。我的表结构很简单类似于下面这样的设计CREATE TABLE subscribe_record ( id int(11) unsigned NOT NULL AUTO_INCREMENT, openid varchar(64) NOT NULL DEFAULT COMMENT 用户openid, template_id varchar(64) NOT NULL DEFAULT COMMENT 小程序模板ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0未使用 1已使用, create_time int(11) NOT NULL DEFAULT 0 COMMENT 授权时间, use_time int(11) NOT NULL DEFAULT 0 COMMENT 使用时间, PRIMARY KEY (id), KEY idx_openid_template (openid, template_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订阅消息授权记录表;每次用户授权一个模板就插入一条status为0的记录。需要发送消息时先去表里查同openid且同template_id并且status为0的记录取一条发送成功后再更新status为1。这种“写流水、扣流水”的方式能够有效避免并发情况下超发次数的问题。如果你只用一个数字字段做加减两个请求同时读到次数为1就可能发出去两条消息这在高并发场景下会有隐患。不过如果团队项目很小、并发极低用字段累加也问题不大。我建议起点尽量高一点流水表在排查问题时也更方便可以清晰地看到用户每一次授权和消费的时间点。3.2 获取access_token的PHP方法调微信接口之前必须先拿到access_token。这个是调所有微信API的通行证有效期默认7200秒也就是两个小时。千万注意一个点同一个小程序的access_token是有数量上限的如果频繁获取旧的会失效。在开发阶段无感一旦上了生产环境多个服务同时刷token很容易把前面的token顶掉导致各种莫名其妙的401错误。所以拿到token一定要做缓存。我用的是ThinkPHP自带的缓存也可以用Redis只要保证全局只有一个地方获取即可。分享一下我的写法?php namespace app\common\service; use think\facade\Cache; class WechatApi { private $appid; private $secret; public function __construct($appid, $secret) { $this-appid $appid; $this-secret $secret; } /** * 获取access_token带缓存避免频繁调用 * return string * throws \Exception */ public function getAccessToken() { $cacheKey wechat_access_token_ . $this-appid; $token Cache::get($cacheKey); if ($token) { return $token; } $url sprintf( https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappid%ssecret%s, $this-appid, $this-secret ); $result $this-httpGet($url); $data json_decode($result, true); if (isset($data[access_token])) { // 提前5分钟过期防止边界情况 Cache::set($cacheKey, $data[access_token], $data[expires_in] - 300); return $data[access_token]; } throw new \Exception(获取access_token失败: . json_encode($data, JSON_UNESCAPED_UNICODE)); } private function httpGet($url) { $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $result curl_exec($ch); curl_close($ch); return $result; } }这里我缓存时间设置成expires_in - 300也就是实际使用时间比有效期少5分钟。因为微信这边的时间戳和服务器时间可能有偏差如果卡着临界点去调用很容易拿到一个已经失效的token。提前刷新是最稳妥的做法。3.3 发送订阅消息的核心代码拿到access_token之后就可以调用订阅消息发送接口了。微信这边发送订阅消息的接口是POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_tokenACCESS_TOKEN请求体是一串JSON包含touser、template_id、page、data等字段。这里有个细节要提醒data里的字段名不能随便写必须跟模板里设定的变量名完全一致。比如模板里定义的是thing1、time2你提交的data里就必须是这些名字多一个少一个都会报错。而且不同类型的字段有长度限制thing类型限制20个字符以内中文字符算一个还是两个记得确认清楚测试时很容易截断或者报错。还有page字段如果不传用户点击消息跳转的就是小程序的首页如果传必须是小程序内部页面路径不能带https://前缀域名。这个很多人忽略我一开始接的时候就因为带了完整URL结果审核报错。下面是完整的发送方法?php namespace app\common\service; class SubscribeMessage { private $wechatApi; public function __construct(WechatApi $wechatApi) { $this-wechatApi $wechatApi; } /** * 发送订阅消息 * param string $openid 用户openid * param string $templateId 模板ID * param string $page 点击消息跳转的页面路径 * param array $data 模板变量数据 * return bool * throws \Exception */ public function send($openid, $templateId, $page, $data) { $accessToken $this-wechatApi-getAccessToken(); $url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token . $accessToken; $body [ touser $openid, template_id $templateId, page $page, data $data, miniprogram_state formal // 可选值developer开发版、trial体验版、formal正式版 ]; $result $this-httpPost($url, json_encode($body, JSON_UNESCAPED_UNICODE)); $resultArr json_decode($result, true); if (isset($resultArr[errcode]) $resultArr[errcode] 0) { return true; } // 记录错误信息到日志便于排查 \think\facade\Log::error(订阅消息发送失败, [ openid $openid, templateId $templateId, result $resultArr ]); return false; } private function httpPost($url, $body) { $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ Content-Type: application/json, Content-Length: . strlen($body) ]); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $result curl_exec($ch); curl_close($ch); return $result; } }这里有一个值得展开的点miniprogram_state字段。如果你的小程序还在开发阶段而调试用的微信号不是开发者账号发送订阅消息很容易失败。把这里设成developer或trial可以在开发版和体验版里正常测试但正式上线前一定要改成formal否则线上用户会收不到消息。我之前就因为忘了改这个字段测试环境一切正常上线后消息石沉大海排查了很久才找到原因。3.4 额度消耗与发送流程的整合发送消息时不能只调发送接口还要同时处理数据库中的额度扣减。这里的顺序建议是先扣减再发送或者在同一事务里进行防止重复发送。我的设计是发送前先查询可用的授权记录然后用UPDATE ... WHERE status0 LIMIT 1这样的方式抢占一条记录再调微信发送接口。如果发送失败把记录状态回滚让用户可以使用这条额度重新发送。这个逻辑在订单通知里特别重要比如用户下单后我们发送“订单已收到”通知如果发送失败总不能让用户的额度白白浪费。也有一个提法是先把消息发出去再更新记录状态这样如果微信发送失败可以再次触发重试。但这里存在双重发送的风险一旦第一次调用超时但实际发送成功第二次重试就会让用户收到两条相同消息。所以我的推荐是发送前先锁定额度发送后按结果更新状态宁可发失败不要发重复。用户的信任感一旦被消费后面再好的模板也没用。4. 实战过程中的常见报错与排查记录4.1 43101这个错误码用户拒绝不掉坑在对接订阅消息的过程中你一定绕不开errcode: 43101它的含义是“用户拒绝接受消息或者模板消息发送次数已达上限”。这个错误码出现时我第一时间会去查数据库里该用户的授权记录看看是不是当初授权确实没有积累下来。如果授权记录存在且status还是0那么问题大概率出在微信侧对用户订阅状态的判断。有一种情况比较隐蔽用户在同一台设备上切换过微信号或者用户在小程序设置里关闭了订阅消息总开关都会导致发送接口返回43101。这种情况只能靠引导用户在设置页重新打开授权开关没有别的办法。所以我在业务层做了一个降级策略当发送接口返回43101时系统自动给这个用户打上一个“不可订阅”的标签后续运营策略里会跳过给这类用户发消息避免无谓的API调用。4.2 模板内容变量长度超限被拒小程序订阅消息的模板变量是有严格长度限制的thing类型只能填20个字符以内number类型是32位整型amount类型是1位小数或者2位小数。有些运营同学喜欢在模板里写很长的活动说明比如“感谢您参与双十一年终大促本店将以最优质的商品回馈您的信任”结果一提交就报错。解决办法是在后端发送前做一道数据清洗把超长字符串截断或者做摘要处理。比如活动名称截取前15个字符再加省略号。这件事需要产品侧提前对接好不然上线后出现截断问题用户收到的消息读起来不完整会影响体验。4.3 access_token并发刷新的坑如果项目是多机部署PHP服务在多个节点同时跑每个节点各自缓存一份access_token就会导致token频繁刷新相互覆盖。最后某个节点拿着旧token去调接口就会收到40001错误码表示token无效。我的解决方式是做一个全局锁或者直接统一把token缓存放到Redis这样多实例共享同一份缓存谁过期了谁刷新其他实例直接读取。再者就是在获取token的代码块上加一个互斥锁防止同时多个进程去请求微信接口。我们当时用的是Redis的setnx命令做锁效果很理想。生产环境一定要避免每个请求都去调微信的token接口不然很容易触发接口频控被微信限流。4.4 测试环境与线上环境的模板配置差异开发调试时为了方便我喜欢直接用正式环境的appid和secret但这样有个不好容易把测试数据发到真实用户手机上。后来我改成申请了一个测试小程序专门跑订阅消息的联调流程正式环境的模板ID和测试环境的模板ID是两套代码里用配置项做切换。还有一个坑是提交代码时容易把测试用的模板ID硬编码进业务逻辑上线时忘了替换。这类问题看似低级但真的会在某个忙碌的周五晚上发生。现在的做法是在配置文件里统一管理模板ID上线时只需要检查配置环境代码层面完全不用动。收到线上反馈说“消息发不出去”第一个检查的一定是模板ID是否对应当前环境的正式小程序。5. 从实战经验中提炼的几个心得做微信小程序订阅消息这件事技术上并不算多难核心难点其实在理解微信的产品规则和用户心理。写代码只要照着文档和官方API调用就好真正的价值在于怎么设计交互链路让用户愿意授权怎么管理有限的消息次数让每一分触达价值最大化。我自己在几次项目迭代中踩了不少坑总结下来最值钱的经验就是订阅消息不是简单的API调用而是一套需要前后端紧密配合、精细运营的增长工具。前端要设计好授权时机后端要管好额度运营要设计好用户可感知的内容价值。如果用户觉得你的消息有用、好看、不打扰多授权几次很正常如果用户觉得你在骚扰他哪怕只发一次他也会把小程序删掉。最后分享一个小技巧如果消息模板比较充裕可以在用户订阅后的第二天给他发一条“订阅成功通知”感谢他的信任并展示他能通过消息获得什么。这一步对长线留存非常有帮助把“被授权”变成“用户主动期待你的服务”整个订阅消息的运营效率会有质的提升。试过几次之后你会发现用户后续的授权率会比沉默状态高出不少。
返回列表