C#小程序订阅消息推送实战:授权链路、额度管理与错误排查
接到一个预约提醒需求时我第一反应是轻敌了。用户在小程序里约了服务服务开始前要提醒他听起来就是后台一个定时任务、一次通知调用的事。真正动手做 C# 小程序订阅通知推送才发现这套链路和 App 推送完全不是一个物种平台不允许开发者想推就推用户必须先在小程序里主动点一次“允许订阅”服务端才获得一次下发资格推完这一次授权就作废。这个“先订阅、后推送、推完即废”的机制是所有后续设计的起点。这篇文章不打算做成文档翻译我直接按自己从零把这个功能跑通的顺序讲先说什么业务适合用订阅消息再讲授权链路里后端要接住哪些数据然后给一套可以直接改的 C# 发送实现接着是我实测遇到的高频报错与排查思路最后是一些从“能用”到“好用”的进阶设计。对刚接手小程序后端、被订阅消息文档绕晕的 C# 同学应该能省下不少踩坑时间。1. 订阅通知不是群发短信先弄清它的触发边界1.1 模板消息退场之后为什么必须改用订阅消息在订阅消息之前小程序用的是模板消息开发者可以在用户完成支付等特定操作后主动推一条模板消息几乎不需要用户显式授权限制是“7天内有效、每个用户每个模板只推一次”。后来平台把模板消息关停统一换成订阅消息核心变化就一句话推送前必须获得用户本人的订阅授权没有授权就没有发送资格。对 C# 后端来说这意味着不能再把推送当成纯后端行为前面必须有一条完整的授权链路用户授权数据要落库下发次数要管理授权失效要处理。很多从模板消息时代过来的老项目改造时最容易犯的错就是在老逻辑里直接换接口地址接口一调发现全是 43101排查半天才明白用户根本没有授权数据等于对着空气发送。1.2 一次性订阅与长期订阅普通人拿不到后者别在选型上卡住订阅消息分两种一次性订阅消息和长期订阅消息。一次性订阅消息用户点一次“允许”开发者获得一次下发机会发送完一条后机会即失效如果同一个模板用户连续点了多次允许下发次数可以累计。长期订阅消息则允许开发者在获得用户授权后长时间内持续下发多条但长期订阅仅向特定服务类目开放政务、医疗、民生这类才可能申请下来普通个人主体和一般企业类目基本不用想。做 C# 后端选型时我建议先去小程序平台后台看一眼自己的服务类目支持哪种订阅类型别在一次性订阅的业务上硬等长期订阅审批那是白耗时间。绝大多数电商、工具、本地生活类小程序走的都是一次性订阅。订阅消息本身也不是无限下发平台侧对同一用户同一模板存在频控策略具体阈值以当前平台规则为准所以消息模板和触达频次要提前规划。1.3 业务适配判断哪些场景适合哪些场景趁早放弃订阅消息适合“用户主动关心结果”的场景预约成功后提醒服务开始审核通过后通知去查看结果活动报名成功后发送入场指引订单状态更新时告诉用户进度。这些业务里用户对消息有明确期待点“允许”的心理成本低推送后的打开率也高。不适合的是平台单方面想触达用户的强营销场景比如“新品上架”“限时折扣”。用户没有点过订阅你根本没有推送资格即便你诱导用户订阅了发的还是他不关心的广告下次他再也不点允许。我做项目前的判断标准很简单这条消息用户看了会觉得自己“被服务了”还是“被打扰了”。前者值得接后者别碰。2. 从用户点击“允许”到服务端落库授权链路里后端要接住这些数2.1 前端授权是整条链路的第一环也是最容易被忽略的一环订阅消息的授权动作必须由用户主动点击触发小程序端调用wx.requestSubscribeMessage弹出授权框传入模板 ID 数组一次最多传三个。这里有个硬性要求调用必须放在按钮点击事件里不能放在页面onLoad里自动触发平台会直接拦截用户感觉“点了没反应”其实是调用时机就不合规。用户点“允许”后success回调里会返回每个模板 ID 对应的结果值为accept代表允许reject代表拒绝。一个很常见的误区是前端只关心accept后弹了个 toast却没有把这次授权结果传给服务端。授权结果不上报后端数据库里永远没有这条订阅关系后面发送逻辑自然无从谈起。wx.requestSubscribeMessage({ tmplIds: [模板ID在后台复制的那串], success(res) { const status res[模板ID在后台复制的那串]; if (status accept) { // 这里务必要把 openid templateId 当前时间 上报给后端接口 wx.request({ url: https://api.example.com/subscribe/record, method: POST, data: { templateId: 模板ID在后台复制的那串 } }); } } });后端接口从登录态里取 openid和前端传的 templateId 一起落库。注意不要把 openid 也交给前端传openid 属于服务端登录态数据前端传过来的 openid 理论上不可信容易被伪造。2.2 授权结果落库接住的不是一条记录而是一笔额度真正容易漏掉的是额度概念。一次性订阅的本质是给用户建立“可发次数余额”用户授权一次余额加一发送成功一次余额减一。我见过不少对接代码把授权结果只打了一个日志没有落库等到真正要发消息时后端手里根本没有“用户订阅过没有”的数据只能瞎猜这是消息发不出去的第一大原因。下面这个建表思路可以直接参考字段刻意做得简单方便后续扩展CREATE TABLE subscribe_record ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, OpenId NVARCHAR(64) NOT NULL, TemplateId NVARCHAR(64) NOT NULL, SubscribeCount INT NOT NULL DEFAULT 1, UsedCount INT NOT NULL DEFAULT 0, LastSubscribeTime DATETIME NOT NULL, LastConsumeTime DATETIME NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); -- 同一用户同一模板多次授权时SubscribeCount 累加查询可用次数时执行SubscribeCount - UsedCount小于等于 0 就说明没有可发额度直接拦截发送不用再调微信接口。这张表也是后面排查“用户说没收到”的第一手证据。2.3 openid 归属校验与跨端陷阱订阅记录里的 openid一定是用户在当前小程序下的 openid不是公众号 openid也不是 unionid。小程序登录时服务端通过 code 换取 openid这个值才是后面touser参数要填的。如果项目里同时有公众号和小程序两套体系很容易出现把公众号 openid 填进小程序订阅接口的低级错误微信接口会回 40003。这里没有捷径唯一可靠的办法是服务端在记录订阅关系时把 openid 与此前登录体系里存的 openid 做一次对照确保来源一致。另外openid 本身会随用户在不同小程序之间变化同一个用户在小程序 A 和小程序 B 里的 openid 不一样换主体、换小程序后历史订阅记录基本等于作废要重新引导用户授权。3. C# 服务端发送的核心实现access_token 缓存与序列化陷阱3.1 access_token 的获取、缓存与并发保护调用小程序任何服务端接口都需要先拿到全局唯一调用凭据 access_token通过 appid secret 换取有效期 7200 秒。最容易踩的坑是每个请求都去刷新既浪费接口配额又会在并发场景里让多个请求互相覆盖 token导致部分请求用到了刚失效的旧 token。正确做法是缓存起来过期前静默续期。在 C# 里我倾向用单例服务 内存缓存打底多实例部署时再升级为 Redis 缓存。public interface IAccessTokenProvider { Taskstring GetAccessTokenAsync(CancellationToken ct default); } public class AccessTokenProvider : IAccessTokenProvider { private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; private readonly string _appId; private readonly string _appSecret; public AccessTokenProvider(IConfiguration config, IHttpClientFactory httpClientFactory, IMemoryCache cache) { _httpClientFactory httpClientFactory; _cache cache; _appId config[WeChat:AppId]; _appSecret config[WeChat:AppSecret]; } public async Taskstring GetAccessTokenAsync(CancellationToken ct default) { if (_cache.TryGetValue(wx_access_token, out string token) !string.IsNullOrEmpty(token)) return token; using var semaphore new SemaphoreSlim(1, 1); await semaphore.WaitAsync(ct); try { if (_cache.TryGetValue(wx_access_token, out token) !string.IsNullOrEmpty(token)) return token; var client _httpClientFactory.CreateClient(wechat); var url $https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappid{_appId}secret{_appSecret}; var resp await client.GetAsync(url, ct); var body await resp.Content.ReadAsStringAsync(ct); using var json JsonDocument.Parse(body); if (json.RootElement.TryGetProperty(access_token, out var tokenProp)) { token tokenProp.GetString(); // 保险起见提前 5 分钟过期避免边界时间恰好失效 _cache.Set(wx_access_token, token, TimeSpan.FromSeconds(7140)); return token; } throw new InvalidOperationException($获取access_token失败: {body}); } finally { semaphore.Release(); } } }两个细节要注意。一是SemaphoreSlim保证了并发下只有一个线程真正去刷新 token其余线程等锁之后直接读缓存二是提前 5 分钟过期防止 token 在边界时间恰好失效导致接口偶发报错。另外小程序 access_token 和公众号 access_token 互不通用公众号的 token 不能用在小程序接口上这个搞混了会排查到怀疑人生。3.2 发送接口与 C# 序列化大小写陷阱订阅消息发送接口是cgi-bin/message/subscribe/sendPOST 请求。核心参数有这些参数说明touser用户在小程序下的 openidtemplate_id模板 ID来自后台申请并通过审核的模板page用户点击消息后跳转的小程序页面路径miniprogram_state跳转版本正式环境填 formaldata模板关键词数据key 为后台定义的关键词编号lang语言类型默认 zh_CN这里有一个纯 C# 开发者才会踩的坑微信接口要求 JSON 字段全部是 camelCase小驼峰而 System.Text.Json 默认序列化匿名类是 PascalCase首字母大写。如果你直接new { Touser openid, TemplateId templateId }就序列化发出去的 JSON 是{Touser: xxx}微信端识别不了字段直接报 47003 参数格式错误而且报错信息很笼统不细看根本想不到是大小写问题。解决办法是显式配置JsonSerializerOptionsvar payload new { Touser openid, TemplateId templateId, Page pages/appointment/detail, MiniprogramState formal, Lang zh_CN, Data new { Thing1 new { Value 您的预约即将开始 }, Date3 new { Value appointmentTime.ToString(yyyy-MM-dd HH:mm) }, Phrase5 new { Value 预约提醒 } } }; var json JsonSerializer.Serialize(payload, new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase });data里的thing1、date3、phrase5不是随便起的必须和你在平台后台申请模板时看到的关键词编号一一对应。关键词类型也有讲究thing类型限制 20 个字以内date类型要求传标准日期时间格式number类型只能填数字。组装数据前先核对模板详情否则发送时返回 47003排查成本远大于提前两分钟看清楚。3.3 一个可以直接改的发送服务类把前面的细节串起来一个可用的发送服务大概是这个样子。核心流程查额度 - 拿 token - 组装 JSON - POST - 解析返回码 - 成功则扣减额度 - 失败则记录并处理特定错误码。public class SubscribeMessageService { private readonly IHttpClientFactory _httpClientFactory; private readonly IAccessTokenProvider _accessTokenProvider; private readonly IRepositorySubscribeRecord _recordRepository; private readonly ILoggerSubscribeMessageService _logger; public SubscribeMessageService( IHttpClientFactory httpClientFactory, IAccessTokenProvider accessTokenProvider, IRepositorySubscribeRecord recordRepository, ILoggerSubscribeMessageService logger) { _httpClientFactory httpClientFactory; _accessTokenProvider accessTokenProvider; _recordRepository recordRepository; _logger logger; } public async Taskbool SendAsync(string openid, string templateId, Dictionarystring, string keywordValues, string page , CancellationToken ct default) { // 1. 查可用订阅额度 var record await _recordRepository.GetAsync(openid, templateId, ct); if (record is null || record.SubscribeCount record.UsedCount) { _logger.LogWarning(用户 {OpenId} 无可用订阅额度template{TemplateId}, openid, templateId); return false; } // 2. 获取 access_token var token await _accessTokenProvider.GetAccessTokenAsync(ct); var data keywordValues.ToDictionary( kv kv.Key, kv new { value kv.Value } as object); var payload new { Touser openid, TemplateId templateId, Page string.IsNullOrEmpty(page) ? pages/index/index : page, MiniprogramState formal, Data data }; var client _httpClientFactory.CreateClient(wechat); var json JsonSerializer.Serialize(payload, new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase }); // 3. 发送 var resp await client.PostAsync( $https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token{token}, new StringContent(json, Encoding.UTF8, application/json), ct); var body await resp.Content.ReadAsStringAsync(ct); using var doc JsonDocument.Parse(body); var errcode doc.RootElement.GetProperty(errcode).GetInt32(); // 4. 结果处理 if (errcode 0) { await _recordRepository.IncrementUsedAsync(openid, templateId, ct); return true; } _logger.LogError( 订阅消息发送失败 openid{OpenId} templateId{TemplateId} errcode{Errcode} errmsg{Errmsg}, openid, templateId, errcode, doc.RootElement.GetProperty(errmsg).GetString()); if (errcode 43101 || errcode 40003) { // 授权已失效或用户不存在把剩余额度清零避免后续反复发送无效请求 await _recordRepository.ClearRestAsync(openid, templateId, ct); } return false; } }这个类把事务边界也考虑进去了发送成功后再扣额度发送失败不扣只有 43101 这类“授权本身失效”的情况才把剩余次数直接清零。我见过不少初版实现是先扣减再发送恰好赶上接口超时用户一条没收到额度却没了这个顺序千万别搞反。还要记得在启动时注册 HttpClientservices.AddHttpClient(wechat, c { c.Timeout TimeSpan.FromSeconds(10); });把超时时间控制在 10 秒左右微信接口偶尔会慢但大部分时候 3 秒内就能返回。超时太长会把线程池拖垮超时太短又可能误判10 秒是折中值。4. 高频失败码与“凭空消失”的消息我的排查思路4.1 先存一张错误码速查表微信接口的返回码很多但订阅消息场景真正高频的就那么几个我直接放一张速查表返回码含义实际处理建议0成功记得同步扣减已用次数40003openid 无效核对是不是当前小程序下的 openid40037template_id 无效去后台确认模板审核状态与 ID41030page 路径不存在或未发布确认跳转页面已上线且不是登录态页面43101用户拒收 / 未订阅 / 授权次数用完清除该用户该模板的余额47003参数格式错误重点检查 camelCase 和 data 字段类型45009调用接口频率超限加延时、加限流避免瞬时大量触发我不建议把全部错误码背下来但这 7 个应当做成代码里的枚举或静态错误字典打日志时直接输出中文说明运维同学拿到日志就能看懂不用每次翻文档。4.2 返回 0 但用户没收到两种最容易出现的真实情况比报错更难受的是接口返回errcode0但用户就是没收到。我排过不少这类问题经验上常出在两种情况。第一种是 openid 与 appid 不匹配比如从别处复制了测试 openid接口校验时可能仍然成功返回 0但消息根本不会真实下发。第二种是用户在一个模板下已经触发了平台侧发送上限后续消息会被静默丢弃不会明确报错。要验证这种“表面成功”唯一可靠的办法是拿真实用户、真实授权号做全链路测试并且测试时看用户手机端是否真实收到而不是只看服务端返回码。我自己就吃过这个亏服务端日志全绿业务方反馈用户收不到最后用自己微信号从授权到发送完整走了一遍才发现是测试 openid 用了别的环境的数据。4.3 排查链路里必须留痕的三样东西每次调用发送接口日志至少要留完整请求体脱敏后、微信返回的 errcode/errmsg、从发出到收到响应的耗时。openid 属于用户标识日志里可以打但建议做一层脱敏时间戳必须带时区前后端联动排查时最怕时间口径不统一。我在实际项目里会把发送记录落一张send_log表OpenId、TemplateId、RequestJson、ResponseCode、ResponseMsg、CostMs、SendTime。这张表是后面排查“用户说没收到”最有力的证据比任何监控都好使。前端如果反馈某个用户没收到我可以直接拉出这条记录看到底有没有调发送接口返回码是多少耗时多久三秒钟就能定位问题在哪一段。4.4 一个“用户明明订阅了却 43101”的典型排除过程分享一个我实际排查过的 case用户前一天点了订阅允许第二天运营触发通知接口返回 43101。第一反应是用户取消了订阅但查订阅记录并没有取消。后来核对平台规则才发现一次性订阅的下发机会不是永久保留的用户订阅后过了一段时间再发额度可能已经失效而且每个模板每个用户还有平台侧下发频控策略不是你有额度就一定能在任意时刻发出去。我的处理方式是拿到订阅授权后把发送动作和业务触发器尽量缩短间隔不要做“先攒着、某天统一推”的设计。如果业务上确实需要延迟推送比如预约提醒宁可把提醒触发点放在用户预约成功后的固定时间窗内也不要让订阅记录在库里躺太久。这条经验换来的教训是订阅授权是有保鲜期的当成即时资源用才靠谱。5. 从能用变成好用多订阅合并、节流与业务闭环5.1 订阅次数是资源按“余额”建模比按“记录”建模更顺手一次性订阅支持累计用户对一个模板点三次允许就有三次可用下发额度。如果按“一条记录一次授权”建模后面要在多条记录里找哪条用过、哪条没用非常痛苦。我建议从一开始就按余额建模同一 openid 同一 template_id 只保留一条汇总记录授权时SubscribeCount加一发送成功后UsedCount加一。这样“还有没有额度”是一个减法就能判断的事代码也简单很多。我第二次做类似需求时直接把这个逻辑抽成通用的“订阅额度服务”业务侧不用关心底层是几次授权只问一句“这个用户还能不能推”接口返回余额想推就推。5.2 用 Redis 做最短发送间隔防止同一用户同一模板被连续轰炸订阅消息有调频策略但业务侧自己也要做防洪。比如一个用户预约了三个服务恰好同一分钟触发三条提醒三条都发出去用户会觉得被打扰。我习惯在发送入口加一个 Redis 键做最短间隔控制var key $subscribe:{openid}:{templateId}; var isExisted await _redis.GetAsync(key) ! null; if (isExisted) { _logger.LogInformation(订阅消息发送频率受限跳过本次发送 openid{OpenId}, openid); return false; } await _redis.SetAsync(key, 1, TimeSpan.FromSeconds(60));间隔设多少取决于业务提醒类我一般设 60 秒以上。这个键同时也能起到部分限流作用同一用户同一模板短时间内的重复触发会被直接挡住而不是全部打到微信接口上。5.3 为什么要把发送动作往“异步”挪订阅消息发送通常不是主业务但微信接口偶尔会长耗时或超时。如果用户在支付流程里同步等待推送结果支付主流程会被拖慢。我建议把“查额度 发送 扣减”整个动作丢到后台任务队列里执行主流程只做记录入库。前端如果有“服务开始前半小时提醒”的需求就由后台任务调度在指定时间点触发发送逻辑。这样就算微信接口抖动也不会影响核心业务流程重试也能在队列里安全进行。我习惯用简单的内存队列或者独立的后台服务按业务量选择不一定非要上重型消息中间件。5.4 重试、补偿与用户关闭出口发送失败要不要重试我的经验是业务提醒类消息值得做最多 2 次重试采用指数退避比如 1 秒、3 秒。但是 43101、40037、47003 这类参数或授权类错误不要重试重试也是浪费。重试发送前要重新查一次额度防止并发环境下两笔任务把一份额度用掉。另外用户真的不想收到提醒时需要有主动关闭的出口比如小程序内“消息设置”页关闭后在业务侧标记不再下发。订阅消息本身已经够克制了但克制之上再加一层用户可控用户对产品的好感会明显不一样。我见过很多项目把订阅消息当免费触达工具用完全不尊重用户的关闭意愿最后模板的授权通过率一路下滑反而把正常业务提醒也连累了。在我的实践里订阅通知推送的代码量其实不大真正决定上线后顺不顺手的是授权额度管理和错误处理这两块。把这两块做扎实后面换模板、加业务都很顺。最后再提醒一句真正开始写发送逻辑前先去平台后台申请模板、确认关键词编号和类型这个前置工作做得越细后续的坑越少。