后端开发日记⑧:站内消息与微信订阅额度

订单状态改变以后,用户既需要在消息中心看到记录,也希望收到微信订阅消息。两种渠道作用不同:站内消息保存业务事实,微信消息负责把用户从应用外叫回来。
1. 站内消息按当前用户隔离
用户消息列表接口仍然使用通用分页结构,但查询条件由 Service 补充:
HyNotification query = request == null ? new HyNotification() : request;query.setWxUserId(SecurityUtils.getUserId());return notificationMapper.selectHyNotificationList(query);前端可以筛选已读状态或通知类型,却不能查询其他用户 ID。把身份条件放在 Service 中,可以防止 Controller 新增入口时忘记隔离。
2. 为什么要记录订阅额度
微信一次性订阅消息需要用户主动允许。前端获得授权结果后,把模板 ID 和允许次数上报后端,后端按“用户 + 模板”保存可用额度。
额度记录至少包含:
availableCount:还可发送多少次;usedCount:已经使用多少次;- 用户 ID 与模板 ID 联合定位一条记录。
同一模板再次授权时增加额度,而不是覆盖已有计数。
3. 发送前预扣
如果先调用微信、成功后再减额度,两个并发发送都可能看到余额为 1,最后发送两次。项目把顺序改为:
原子扣减 availableCount -> 调用微信订阅消息接口 -> 成功:保留 usedCount -> 失败:恢复刚才预扣的额度Mapper 的 consumeAvailableCount 在 SQL 条件中要求可用数大于 0,并同时完成减一与已用数加一。是否扣减成功由受影响行数判断。
4. 失败恢复不是无限重试
网络异常或微信响应失败时,服务恢复额度,并记录发送失败结果。恢复解决的是“用户授权不能被白白消耗”,不代表应该在当前请求里无限重试。
对于明确的 Token 失效,可以刷新 Token 后重试一次;对于模板参数错误、用户拒收等业务错误,应保留失败状态供排查。
5. 双通道如何协作
订单审核、开始飞行、送达或异常处理完成时,业务 Service 可以先写站内通知,再尝试发送微信订阅消息:
| 通道 | 特点 | 失败后的处理 |
|---|---|---|
| 站内消息 | 可查询、可分页、可保留历史 | 属于业务数据,应随事务处理 |
| 微信订阅消息 | 到达应用外、依赖用户授权和第三方平台 | 记录结果并恢复额度,不应破坏已完成业务 |
是否让外部推送失败回滚订单,需要按业务风险决定。通常订单状态是主事实,推送失败应进入补偿或重试,而不是把已经完成的配送改回原状态。
6. 授权上报如何限制客户端计数
用户端上报订阅授权时,后端把单次最大数量限制为 1:
Integer count = reportDto.getCount();if (count == null || count <= 0) { count = 1;}if (count > MAX_REPORT_COUNT) { count = MAX_REPORT_COUNT;}这是因为客户端提交的 count 不是可信余额。前端一次授权成功只能证明获得了本次允许,不能随意提交 100 增加额度。
接着按当前用户和模板查询:
Long wxUserId = SecurityUtils.getUserId();HyWxSubscribeQuota quota = mapper .selectByWxUserIdAndTemplateId( wxUserId, templateId);if (quota == null) { // 初始化 availableCount、acceptCount、usedCount mapper.insertHyWxSubscribeQuota(quota);} else { mapper.increaseQuota(wxUserId, templateId, count);}用户 ID 来自安全上下文,模板 ID 来自允许上报的业务参数。生产环境还可以增加模板白名单,阻止用户为系统未知模板创建额度记录。
7. 原子扣减为什么写在 SQL 中
<update id="consumeAvailableCount"> update hy_wx_subscribe_quota set available_count = available_count - 1, used_count = used_count + 1, update_time = now() where wx_user_id = #{wxUserId} and template_id = #{templateId} and available_count > 0</update>判断余额和扣减发生在一条 SQL 中。两个并发请求即使同时到达,数据库也会串行修改同一行:第一个把 1 改成 0,第二个执行时条件 available_count > 0 已不成立,受影响行数为 0。
如果先 SELECT 再 UPDATE,两次查询可能都读到 1,最后发送两条消息。数据库条件更新比 Java 中的普通 if 更接近真正的数据竞争位置。
8. 发送方法的完整分支
HyNotification notification = buildPendingWechatNotification(order);notificationMapper.insertHyNotification(notification);
int rows = quotaMapper.consumeAvailableCount( order.getWxUserId(), templateId);if (rows <= 0) { markNotificationFailed( notification, "用户未订阅或订阅额度已用完"); return;}
try { HyWxUser user = userMapper.selectById(order.getWxUserId()); JsonNode result = sendSubscribeMessage(user.getOpenId(), order); if (result.path("errcode").asInt(-1) != 0) { throw new ServiceException("微信返回失败"); } markNotificationSuccess(notification);} catch (Exception e) { quotaMapper.restoreConsumedCount( order.getWxUserId(), templateId); markNotificationFailed(notification, e.getMessage());}第一步就创建 PENDING 通知,保证无论后续在哪个位置失败都有记录。没有额度时直接标记失败;有额度才访问用户 openid 和微信接口。
成功后设置 SUCCESS 与发送时间,异常时恢复额度并保存失败原因。日志可以看到订单、用户和失败阶段,而不是只得到一个“推送没有到达”的投诉。
9. 恢复额度为什么保护 used_count
<update id="restoreConsumedCount"> update hy_wx_subscribe_quota set available_count = available_count + 1, used_count = case when used_count > 0 then used_count - 1 else 0 end, update_time = now() where wx_user_id = #{wxUserId} and template_id = #{templateId}</update>case 保证异常数据下 used_count 不会被减成负数。但恢复操作本身也需要幂等标识:如果同一个失败被处理两次,available_count 会多加一次。
更严格的设计可以给每次发送建立唯一消息 ID,并在通知表记录是否已经恢复额度;只有从“已预扣”首次转为“发送失败”时执行恢复。
10. 模板 data 为什么要限制长度
private ObjectNode buildSubscribeData(HyDeliveryOrder order) { ObjectNode data = objectMapper.createObjectNode(); data.set("character_string1", valueNode(limit( defaultString(order.getOrderNo()), 32))); data.set("thing3", valueNode(limit( getPickupAddress(order), 20))); data.set("time14", valueNode( DateUtils.parseDateToStr( DateUtils.YYYY_MM_DD_HH_MM_SS, DateUtils.getNowDate()))); return data;}微信模板字段不仅有名称,还有类型与长度限制。订单号使用 character_string,取货点使用 thing,时间使用 time。直接把长地址塞入 thing3 会被微信拒绝,所以在发送前按字段类型截断。
字段名 character_string1、thing3、time14 必须与后台模板配置完全一致。模板变更时最好把字段映射集中管理,并通过测试请求尽早发现不匹配。
11. 跳转页面也属于消息契约
发送体中包含:
body.put("page", "pages/order/detail?orderId=" + order.getOrderId());body.put("miniprogram_state", miniprogramState);body.put("lang", "zh_CN");消息虽然发送成功,但页面路径错误仍会导致用户点击后无法到达订单。后端与小程序需要共同维护路径契约,订单 ID 也要在目标页再次经过当前用户归属校验。
miniprogram_state 可以区分开发版、体验版和正式版,应该通过环境配置而不是在代码中临时修改。
12. 事务与外部请求的边界
发送方法带有数据库事务,但微信请求不会参与数据库回滚。如果微信已经收到消息,随后本地更新通知状态失败,事务回滚额度后可能让系统误以为可以再次发送。
更可靠的演进方案是事务消息表:
订单事务内写入待发送事件 -> 事务提交 -> 独立发送器领取事件并预扣额度 -> 调用微信 -> 保存成功或失败 -> 超时任务重试可重试错误这样订单完成接口不必等待外部网络,也能通过事件 ID 控制幂等。当前同步方案适合规模较小的阶段,但需要清楚它的边界。
13. 本篇小结
消息系统不能只关注“发送接口返回成功”。站内消息负责可追溯,订阅额度负责发送资格,原子预扣负责并发正确性,失败恢复负责不浪费授权。把这些角色分开,通知链路才具备可解释的失败处理。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























