订阅消息这个东西,很多做小程序的同学一开始都容易想简单。以为像公众号模板消息那样,后端拿到用户openid就能随时随地推一条过去。结果真做起来才发现,微信把门卡得死死的:用户不主动点一次授权按钮,你连一条都发不出去。就算用户点了授权,也只是一次性模板,发完一条下一次还得再点。这套机制刚接触的时候确实让人头大,但搞懂之后你会发现,它本质上就是一个“用户主动订阅 + 服务端按订阅次数推送”的受限通道。
这篇文章我就用Java Spring Boot + Redis这套组合,完整跑一遍微信小程序订阅消息推送的实战链路。从订阅消息的类型区别、模板申请、小程序端授权,到后端access_token缓存、订阅次数记录、消息发送,再到线上最容易踩的坑,全部过一遍。文章里的代码都是我实际项目里验证过的写法,不是那种贴上去就报错的半成品。如果你正打算给自己小程序接入订阅消息,或者已经被43101、40001这些错误码折磨过,这篇应该能帮你省不少时间。
1. 订阅消息机制拆解:为什么推送前非得先拿用户授权
1.1 微信设计这层限制的真实意图
先说个背景。订阅消息是微信小程序用来替代“模板消息”的官方方案。旧的模板消息是被动触发的,只要用户在小程序里产生过某个操作,开发者在七天内就可以给用户推送一条模板消息,结果就是用户经常莫名其妙收到一堆营销推送,体验很差。
订阅消息把逻辑彻底反过来:主动权完全交给用户。每一次推送都必须建立在“用户明确点击过订阅授权”的基础上,而且大多数业务场景下,一次订阅只能对应一次推送。发完即消耗,没有余量。
这背后的设计意图其实很好理解:微信是在用“准入门槛”换“用户体验”。用户不被骚扰,小程序的推送通道才不会被滥用。做开发的我们确实多了一些步骤,但反过来想,能发出去的每条消息,都是用户主动想要的,转化率反而更高。
1.2 一次性订阅与长期订阅的适用边界
订阅消息分成两种类型,这个一开始就要选对,不然后期改起来很痛苦。
| 类型 | 授权方式 | 适用场景 | 获取条件 |
|---|---|---|---|
| 一次性订阅 | 用户点击一次授权,服务端可推送一条 | 预约成功通知、订单发货提醒、活动开奖结果等 | 小程序后台直接选用模板,无额外申请门槛 |
| 长期订阅 | 用户点击一次授权,在有效期内可多次推送 | 天气预警、航班变动、医疗报告等 | 仅部分类目(政务、医疗、金融等)开放,需单独申请 |
一次性订阅是绝大多数普通小程序能用的方案,长期订阅如果你不是特殊类目基本申请不下来。所以这篇实战也以一次性订阅为主。
1.3 模板申请环节的关键细节
模板不是自己创建的,从小程序后台的“订阅消息”模块里选。后台在“功能 - 订阅消息 - 公共模板库”里按关键词搜索,比如你要做预约通知,就搜“预约成功”。
选模板的时候要注意关键词的字段类型。订阅消息的data字段有thing、time、amount、phone等多种类型,每种字段对长度有硬性限制,比如thing类型不能超过20个字符(注意是字符不是字节),time类型必须是标准时间格式。
我当初就栽过这个跟头。选了个模板,里面带一个thing类型的“备注”字段,我在业务代码里把一长串地址备注塞进去,整整30多个字符,结果每次发送都报47003参数错误。后台调试了半天才发现是长度超了。
所以选模板时的建议是:字段类型越简单越好,优先选thing、time、name这类常见类型,字段含义越贴近你的真实业务越好。选好后记下模板ID,形如“F2xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx”,后面代码里要用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis在订阅消息链路中的定位:不是数据库的替代品,是必需品
2.1 为什么这个场景必须有Redis
先理清一个概念:Redis在这里并不是扛大流量用的缓存层,而是解决“状态同步”和“逻辑约束”的问题。
订阅消息推送链路里有几个状态必须跨请求、跨接口保存:
第一个是access_token。微信小程序的接口调用凭证有效期只有7200秒,而且官方对每日获取次数有限制,不是说你每次发消息前现取一次就可以。必须在有效期内全局复用同一个token,过期了才重新获取。这个“先查缓存再取接口”的逻辑,用Redis的String类型存最合适。
第二个是用户的订阅授权次数。用户点击一次订阅授权,后端就要记录下来“这个用户对某个模板有几次推送资格”。推送的时候先查次数,有资格才发,发完扣减一次。这个场景用Redis的Hash类型或者直接String键值对都能实现。
第三个是消息幂等。订阅消息发送涉及外部接口调用,网络超时的情况下,你无法确定微信到底收到没有。如果直接重发,用户可能收到两条;如果不重发,又可能漏发。这个矛盾需要用Redis的幂等标记来解决。
2.2 满足不同场景的数据结构选型
再展开说一下Redis在本项目里的用法,因为很多人对Redis的印象还停留在“缓存”这一个功能上,其实不同数据结构对应不同场景。
- String:存access_token、存单次授权标记。用
SET key value EX 7200天然支持过期时间。 - Hash:记录一个用户对多个模板的订阅次数。key是openid,field是templateId,value是剩余次数。每次授权用
HINCRBY自增,每次推送用HINCRBY -1扣减。 - String + SETNX:做发送幂等,推送前先
SETNX一个业务唯一键,成功返回1才继续发送,失败说明这条已经在发或者已经发过。 - ZSet:如果需要做失败重试的延迟队列,用ZSet按时间戳排序,定时任务拉取到期任务重试。
这些在这篇文章的代码部分都会体现。实际做下来你会发现,订阅消息的所有核心逻辑,几乎都离不开Redis的支撑。可以说没有Redis,这套链路要么多写很多代码,要么就靠数据库轮询,性能和准确性都不太行。
3. 小程序端准备:获取openid、订阅授权与参数传递
3.1 小程序登录与openid获取
订阅消息发送的对象是用户的openid,所以第一步是要让程序能拿到当前用户的openid。
小程序的登录流程这里不展开太多,核心就是:小程序端调用wx.login()拿到一个临时code,传给后端;后端拿code去微信接口jscode2session换取openid和session_key;后端把openid和用户体系绑定后,返回给小程序一个自定义登录态。
后端用Spring Boot实现这个接口的核心代码如下:
java复制@PostMapping("/api/wx/login")
public Result login(@RequestBody WxLoginRequest request) {
String url = String.format(
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
wechatProperties.getAppId(),
wechatProperties.getSecret(),
request.getCode()
);
String response = restTemplate.getForObject(url, String.class);
// 解析openid, 存入用户表或Redis, 返回登录态
}
拿到openid之后,建议把它存到Redis的登录态里,键设计成类似login:token:{tokenValue},值就是openid,过期时间设30天。后面小程序每次请求都带上这个token,后端从Redis里反查openid,避免每次都要调微信接口。
3.2 wx.requestSubscribeMessage的正确打开方式
小程序端订阅授权用的是wx.requestSubscribeMessage接口,这个接口有个特点:必须由用户点击行为直接触发,不能在小程序启动时自动调用。
正确做法是:在业务关键节点弹一个“订阅授权框”,比如用户点击“提交预约”按钮时,同时拉起订阅授权。
javascript复制wx.requestSubscribeMessage({
tmplIds: ['F2xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'],
success(res) {
// res['F2xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'] === 'accept' 表示用户同意
// 'reject' 表示拒绝,'ban' 表示被禁用
if (res['F2xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'] === 'accept') {
// 这里把结果上报给后端
}
},
fail(err) {
// 用户点击了取消授权按钮, 这里不要弹错误提示, 静默处理即可
}
});
一个很容易被忽略的细节:tmplIds数组最多能传3个模板ID,也就是说一次可以拉起三个订阅授权框。如果你的业务里有多个消息场景,可以合并成一次弹窗,减少对用户的打扰。
3.3 授权结果上报后端
小程序端拿到授权结果后,要把结果上报给后端。这里建议把整个授权结果对象都传给后端,而不是只传个“成功/失败”。
后端收到上报后,判断如果accept,就给对应的openid和templateId增加一次授权次数。为什么必须由后端来记?因为小程序端的数据是不可信的,用户可以改代码伪造授权结果。如果只在小程序端记录,那推送资格就没法保证了。
后端接收授权结果的接口:
java复制@PostMapping("/api/subscribe/record")
public Result recordSubscribe(@RequestBody SubscribeRecordRequest request) {
// request.openid 从登录态获取, 不从小程序传的参数里取
String openid = getOpenIdByToken(request.getToken());
Boolean accept = request.getAccept();
List<String> templateIds = request.getTemplateIds();
if (accept != null && accept) {
for (String templateId : templateIds) {
// 每次授权, 对应模板的可推送次数 +1
String hashKey = "subscribe:count:" + openid;
stringRedisTemplate.opsForHash().increment(hashKey, templateId, 1);
}
}
return Result.success();
}
这里有几个关键点:openid一定是从你自己的登录态里反查出来的,不能信任小程序传过来的任何身份参数;授权次数用Hash存储,天然支持多模板;这个接口唯一的职责就是计数,不涉及任何业务逻辑。
4. Spring Boot后端实现:access_token管理、消息发送与业务触发
4.1 依赖准备与项目配置
先说pom里需要的依赖。这是一个标准的Spring Boot Web项目,Redis用Spring Data Redis的starter即可。
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
application.yml里配置Redis连接和微信小程序参数:
yaml复制spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
wechat:
app-id: your-app-id
secret: your-app-secret
# 订阅消息模板ID
subscribe-template-id: F2xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
用Jackson做JSON序列化,Spring Boot默认集成了,不用额外加。
4.2 配置类与RestTemplate封装
先搞一个微信配置类,把appId和secret注入进来:
java复制@Component
@ConfigurationProperties(prefix = "wechat")
@Data
public class WechatProperties {
private String appId;
private String secret;
private String subscribeTemplateId;
}
再单独封装一个微信HTTP客户端。这里为什么不直接在用的时候new RestTemplate?因为微信接口的调用有一些公共逻辑(比如超时设置、错误码统一处理),封装成一个组件复用,代码会清爽很多。
java复制@Component
public class WechatApiClient {
private static final String SUBSCRIBE_SEND_URL =
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=";
private static final String ACCESS_TOKEN_URL =
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
private final RestTemplate restTemplate;
private final StringRedisTemplate stringRedisTemplate;
private final WechatProperties wechatProperties;
// 省略构造器注入
public String getAccessToken() {
// 先从Redis取, 取不到再调微信接口
String token = stringRedisTemplate.opsForValue().get("wx:access_token");
if (token != null) {
return token;
}
String url = String.format(ACCESS_TOKEN_URL,
wechatProperties.getAppId(), wechatProperties.getSecret());
String response = restTemplate.getForObject(url, String.class);
JsonNode node = objectMapper.readTree(response);
if (node.has("access_token")) {
String newToken = node.get("access_token").asText();
int expiresIn = node.get("expires_in").asInt();
// 提前5分钟过期, 避免边界情况
stringRedisTemplate.opsForValue().set(
"wx:access_token", newToken, expiresIn - 300, TimeUnit.SECONDS);
return newToken;
}
throw new RuntimeException("获取access_token失败: " + response);
}
}
这里要说一下Redis中access_token的存储设计。我用的是wx:access_token这个键,过期时间设置成接口返回的expires_in减去300秒,也就是提前5分钟过期。原因是access_token存在边界时间问题:你在有效期最后1秒请求微信接口,微信可能已经判定它过期了。提前过期可以让系统在这个token真正失效之前就换新的,避免4xx错误。
4.3 订阅消息发送核心逻辑
发送订阅消息是整个链路最核心的一步。微信接口的请求格式是JSON,data里的字段要和模板里的关键词一一对应。
java复制public SendMessageResult sendSubscribeMessage(String openid, String templateId,
Map<String, String> dataMap) {
// 1. 检查剩余订阅次数
String hashKey = "subscribe:count:" + openid;
Object remaining = stringRedisTemplate.opsForHash().get(hashKey, templateId);
int remainingCount = remaining == null ? 0 : Integer.parseInt(remaining.toString());
if (remainingCount <= 0) {
return SendMessageResult.noSubscription();
}
// 2. 幂等检查, 防止重复发送
String idempotentKey = "subscribe:send:" + businessId;
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1", 1, TimeUnit.DAYS);
if (!success) {
// 这条业务消息已经处理过
return SendMessageResult.duplicate();
}
// 3. 构造请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("touser", openid);
requestBody.put("template_id", templateId);
requestBody.put("page", "pages/index/index");
Map<String, Map<String, String>> data = new HashMap<>();
for (Map.Entry<String, String> entry : dataMap.entrySet()) {
Map<String, String> valueObj = new HashMap<>();
valueObj.put("value", entry.getValue());
data.put(entry.getKey(), valueObj);
}
requestBody.put("data", data);
// 4. 调用微信接口
String url = SUBSCRIBE_SEND_URL + getAccessToken();
String response = restTemplate.postForObject(url, requestBody, String.class);
JsonNode node = objectMapper.readTree(response);
int errCode = node.get("errcode").asInt();
if (errCode == 0) {
// 发送成功, 扣减订阅次数
stringRedisTemplate.opsForHash().increment(hashKey, templateId, -1);
return SendMessageResult.success();
} else if (errCode == 43101) {
// 用户拒绝接收, 属于不可重试错误
return SendMessageResult.userRejected();
} else if (errCode == 40001) {
// access_token无效, 清除缓存重新获取后重试
stringRedisTemplate.delete("wx:access_token");
return SendMessageResult.tokenInvalid();
}
// 其他错误码
return SendMessageResult.failed(errCode, node.get("errmsg").asText());
}
这段代码的每一段都有讲究。
发送前的“检查订阅次数”不是可选项而是必选项。有些开发者图省事不在后端记录授权次数,每次都直接调微信接口,结果用完了还继续发,微信会报43101。虽然不影响主流程,但每次浪费一次HTTP调用,而且在日志里会堆积大量错误信息。
幂等键的设计是关键。我用的是setIfAbsent,这是Redis的原子操作,多个线程同时进来只有一个能成功。这个businessId用的是你业务系统里的唯一单号,比如预约单号、订单号。为什么不用openid+templateId做幂等键?因为用户对同一个模板可能有多条不同的业务消息,比如定期订阅了10条提醒,用openid+templateId会把后面的合法发送全给幂等掉了。
发送成功后立即扣减剩余次数,这个逻辑要放在发送成功之后,不能发送前扣。原因很简单:如果发送前扣减,但网络超时微信实际没收到,你的次数就凭空消失了;用户那边也没有收到消息,体验非常差。
4.4 业务触发示例:预约成功通知
上面讲的都是基础能力,真正要用起来得嵌入到业务代码里。我用一个“预约成功通知”的场景串一下。
比如用户在小程序上预约了一个线下课程,预约成功后,你想给他推送一条“预约成功”的订阅消息。业务流程是:
- 用户提交预约时,小程序端拉起订阅授权弹窗
- 用户点击同意后,小程序把授权结果上报后端,后端记录该用户+模板的订阅次数+1
- 用户提交预约成功,后端执行预约入库逻辑
- 预约入库后,调用发送订阅消息的Service,传入openid、模板ID和模板数据
- 发送成功后,扣减订阅次数
Controller层代码:
java复制@PostMapping("/api/appointment/create")
public Result createAppointment(@RequestBody AppointmentRequest request) {
// 1. 预约入库
Appointment appointment = appointmentService.create(request);
// 2. 构建订阅消息内容
Map<String, String> dataMap = new HashMap<>();
dataMap.put("thing1", "Java实战训练营");
dataMap.put("time2", "2025-03-15 14:00");
dataMap.put("thing3", "请提前10分钟到场签到");
// 3. 异步发送订阅消息
subscribeMessageService.sendAsync(
request.getOpenid(),
wechatProperties.getSubscribeTemplateId(),
"appointment:" + appointment.getId(),
dataMap
);
return Result.success(appointment);
}
发送操作建议做成异步的,因为微信接口调用有网络开销,会拖慢用户的操作响应。可以用@Async注解配合独立的线程池,或者直接丢到消息队列里去处理。
4.5 异步发送与失败补偿
这里展开写一下异步处理的细节。直接在主线程里调微信接口,接口耗时一般在100-300毫秒,如果微信接口偶发变慢,用户提交预约要等好几秒才能看到结果,体验非常差。
我用一个简单的@Async方法来做异步发送:
java复制@Async("subscribeExecutor")
public void sendAsync(String openid, String templateId, String businessId,
Map<String, String> dataMap) {
SendMessageResult result = sendSubscribeMessage(openid, templateId, dataMap);
if (!result.isSuccess()) {
// 记录日志, 方便排查
log.error("发送订阅消息失败: openid={}, templateId={}, businessId={}, result={}",
openid, templateId, businessId, result);
// 这里可以根据错误类型决定是否重试
if (result.isRetryable()) {
retryService.addRetryTask(openid, templateId, businessId, dataMap);
}
}
}
线程池配置就不贴全代码了,核心是ThreadPoolTaskExecutor,核心线程数设4-8个就够,队列容量别太大,因为订阅消息的量一般不会特别大。
失败的场景也要想清楚。如果tokenInvalid,清掉缓存后重试一次基本能成功。如果userRejected,用户已经拒绝接收,再怎么重试都是徒劳,直接放弃。如果paramError(47003),是你的参数有问题,重试也没用,要检查代码。如果systemError或timeout,可以放到一个延迟重试队列里,5分钟后再试一次。
5. 发送频率限制与失败重试:线上环境必须处理的两个问题
5.1 微信接口的限频策略
微信订阅消息接口的调用是有频控的,官方接口文档上写的是“单个用户/模板/时间的发送限制”。具体数字不同类目不同,但经验上,同一个用户对同一个模板的发送频率不要太高。
如果触发了频控,微信会返回45009错误码,意思是“接口调用超过频率限制”。这个错误码是致命的,因为它会持续一段时间,期间你什么都发不了。
应对策略是我在项目里的做法:
- 发送前先查Redis里该openid当天的发送次数,超过阈值就不发了,直接记录到待发送队列第二天再发
- 对同一个模板的发送做全局限流,比如每秒最多10次,用Redis的
INCR+EXPIRE做简单的滑动窗口计数 - 遇到45009错误,把任务丢到延迟队列,等窗口重置之后再试
5.2 可重试与不可重试错误分类
重试不是无脑重试,得先分辨这个错误是可恢复的还是不可恢复的。
| 错误码 | 含义 | 是否可重试 | 重试策略 |
|---|---|---|---|
| 0 | 发送成功 | - | - |
| 40001 | access_token无效 | 可重试 | 删除缓存token,重新获取后再发 |
| 40003 | openid无效 | 不可重试 | 检查登录态,可能是用户注销 |
| 43101 | 用户拒绝接收 | 不可重试 | 该用户对该模板的授权已用完 |
| 47003 | 参数格式错误 | 不可重试 | 检查模板字段类型和长度 |
| 45009 | 接口调用频控 | 可重试 | 延迟一段时间后再发 |
| -1 | 系统繁忙 | 可重试 | 指数退避,最多重试3次 |
可重试的任务我用Redis的ZSet做一个简单的延迟队列:
java复制public void addRetryTask(RetryTask task) {
String key = "subscribe:retry:queue";
// score是重试时间戳
stringRedisTemplate.opsForZSet().add(key, JSON.toJSONString(task),
System.currentTimeMillis() + task.getDelayMillis());
}
定时任务每分钟扫描一次,把到期的任务取出来重新发送:
java复制@Scheduled(fixedDelay = 60000)
public void processRetryQueue() {
String key = "subscribe:retry:queue";
long now = System.currentTimeMillis();
Set<String> tasks = stringRedisTemplate.opsForZSet()
.rangeByScore(key, 0, now);
for (String taskJson : tasks) {
RetryTask task = JSON.parseObject(taskJson, RetryTask.class);
// 重试发送
sendSubscribeMessage(task.getOpenid(), task.getTemplateId(), task.getDataMap());
// 从队列移除
stringRedisTemplate.opsForZSet().remove(key, taskJson);
}
}
这个方案的好处是:不引入额外的MQ组件,用Redis原生的ZSet就能把延迟队列跑起来,适合中小规模的订阅消息推送场景。
6. 线上踩坑记录:授权成功却不推送、token明明有效却报40001
6.1 场景一:用户点了授权,服务端发送还是报43101
这是我被问得最多的一个问题。用户在开发工具里点了“允许”,服务端拿openid去发订阅消息,结果报43101“用户拒绝接受订阅消息”。
排查链路是这样的:
- 先确认这个openid是不是真的经历了“授权”动作。很多时候开发为了省事,在开发者工具里直接跳过授权弹窗,用代码模拟了一个“已授权”的状态,但用户的openid在微信那边根本没有授权记录。
- 确认授权是在和发送消息同一个环境(正式版/体验版)里完成的。开发版里点授权、正式版里发消息,openid虽然一样,但授权记录不一定互通。
- 确认用户当前的小程序版本是不是最新版。如果用户手机里装的是旧版本,他授权的是旧版里的模板ID,而后端已经换过模板,就会出现“授权了但发送失败”。
- 手动在真机上走一遍完整流程,看授权弹窗是不是真的弹出来了。有时候弹窗被遮罩层挡住了,用户根本没看到授权框,自然也就没点。
最关键的排查点是:授权记录的Redis键和发送校验的Redis键是不是同一个。比如授权时用subscribe:count:{openid},发送时却写成了subscribe:count:{openid}:{appid},那当然查不到剩余次数。
6.2 场景二:缓存里的access_token明明没过期,却报40001
这个问题我遇到过两次。第一次是服务器时间不准,和微信服务器的时钟差了七八分钟。access_token的有效期判断是微信那边做的,如果本机时间偏慢,本地认为还有效,微信那边已经判定过期了。只要同步一下NTP时间就解决了。
第二次是用了多环境部署。测试环境和生产环境共用了一个Redis,测试环境获取的access_token覆盖了生产环境的token。微信的access_token是和应用维度绑定的,同一个appid的token在任意地方获取都会让之前的token失效。解决方法是:给不同环境设置不同的Redis key前缀,比如prod:wx:access_token和test:wx:access_token。
补充一个容易忽略的点:如果你同时维护了多个小程序(不同appid),access_token的缓存键一定要带上appid维度,比如wx:access_token:{appid}。否则两个小程序共用一个Redis,token会互相覆盖,导致频繁40001。
6.3 场景三:发送成功但用户没收到
这种问题在开发环境很难复现,因为开发者工具里的是虚拟用户,真机测试才会有真实结果。我总结了几种可能原因:
- 订阅消息的接收开关在小程序设置里被用户关掉了。微信给用户提供了“接收订阅消息”的总开关,用户关掉后,服务端发送仍然返回成功,但用户就是收不到。
- 消息被微信折叠到了“服务通知”里,用户没注意到。这个只能靠消息文案和运营引导。
- page字段指向的页面路径不存在,点击消息跳转时报错,但消息本身已经送达。
遇到用户反馈“没收到”的时候,先排查错误码,再确认用户订阅开关,最后才考虑是不是被系统过滤了。
6.4 场景四:真机测试授权弹窗不弹出
同一个模板在开发者工具里能弹出授权框,到了真机上却完全没有反应。这个坑大概率是模板ID不匹配。
开发者工具会自动把模板ID替换成测试模板ID,但真机上必须使用后台申请的真实模板ID。如果你在代码里硬编码了一个模板ID,而且这个模板在后台已经被删除或者换了新版,真机上就弹不出来。
另外,wx.requestSubscribeMessage在用户已经点击过“总是保持以上选择”的情况下,再次触发时不一定会弹窗。微信会直接沿用用户之前的授权结果。所以测试的时候不要以为每次调用都会弹框。
6.5 一个容易忽略的性能细节
最后说一个性能细节。sendSubscribeMessage里每次发送都要getAccessToken(),这个方法内部先查Redis再调接口。如果Redis或者微信接口出现短暂故障,会导致整条发送链路抛异常。建议在发送方法外层加一个兜底的try-catch,把异常捕获后记录日志,不要让订阅消息的失败影响主业务流程。要知道订阅消息推送从产品视角看是一个“通知”类功能,它失败了不应该阻塞用户的预约、下单等核心操作。
我在项目里的做法是:发送逻辑全部走异步线程池,外层catch所有异常,只记录日志。宁可丢一条通知,也不能让用户预约都提交不了。
7. 实际接入过程中的几点体会
做订阅消息这块,我最大的感受是:微信的这套机制,逼迫你必须在产品设计阶段就把“消息场景”想清楚。
你不可能像过去那样,等业务跑起来了,突然想给用户推一条营销消息,拿起来就发。订阅消息要求你在用户操作的关键节点预埋“订阅授权弹窗”,弹窗设计得不好,用户拒绝率会很高;弹窗出现的时机不对,又会影响主流程的转化。
几个做产品设计时的建议:
- 授权弹窗要放在“用户已经完成核心操作、即将看到结果”的节点,比如提交订单后、支付完成前。用户这时候的期望值最高,授权意愿也最强。
- 一次弹窗最多带3个模板,不要贪多。给用户选择的余地,但不要让他感到被轰炸。
- 模板字段的组合要提前想好。比如你有“预约成功通知”和“预约提醒通知”两个场景,如果模板字段类似,尽量共用一个模板,减少授权次数。
- 后端一定要有订阅记录和发送记录的日志,定位问题的时候,没有日志你只能干瞪眼。
就聊到这里。如果你的业务正好要接入订阅消息,希望这篇文章能帮你少踩几个坑。遇到具体问题,欢迎在评论区把你的错误码和排查过程贴出来,一起讨论。
