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

1988 字
10 分钟
后端开发日记⑧:站内消息与微信订阅额度

订单状态改变以后,用户既需要在消息中心看到记录,也希望收到微信订阅消息。两种渠道作用不同:站内消息保存业务事实,微信消息负责把用户从应用外叫回来。

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 &gt; 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 &gt; 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. 本篇小结#

消息系统不能只关注“发送接口返回成功”。站内消息负责可追溯,订阅额度负责发送资格,原子预扣负责并发正确性,失败恢复负责不浪费授权。把这些角色分开,通知链路才具备可解释的失败处理。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端开发日记⑧:站内消息与微信订阅额度
https://firefly.cuteleaf.cn/posts/backend-dev-diary-8/
作者
Yore
发布于
2026-07-19
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0