☰
C#小程序订阅消息推送实战:授权链路、额度管理与错误排查
2026/10/16 7:09:08 网站建设 项目流程

接到一个预约提醒需求时,我第一反应是轻敌了。用户在小程序里约了服务,服务开始前要提醒他,听起来就是后台一个定时任务、一次通知调用的事。真正动手做 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 { Task<string> 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 Task<string> 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_type=client_credential&appid={_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/send,POST 请求。核心参数有这些:

参数说明
touser用户在小程序下的 openid
template_id模板 ID,来自后台申请并通过审核的模板
page用户点击消息后跳转的小程序页面路径
miniprogram_state跳转版本,正式环境填 formal
data模板关键词数据,key 为后台定义的关键词编号
lang语言类型,默认 zh_CN

这里有一个纯 C# 开发者才会踩的坑:微信接口要求 JSON 字段全部是 camelCase(小驼峰),而 System.Text.Json 默认序列化匿名类是 PascalCase(首字母大写)。如果你直接new { Touser = openid, TemplateId = templateId }就序列化,发出去的 JSON 是{"Touser": "xxx"},微信端识别不了字段,直接报 47003 参数格式错误,而且报错信息很笼统,不细看根本想不到是大小写问题。

解决办法是显式配置JsonSerializerOptions:

var 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 IRepository<SubscribeRecord> _recordRepository; private readonly ILogger<SubscribeMessageService> _logger; public SubscribeMessageService( IHttpClientFactory httpClientFactory, IAccessTokenProvider accessTokenProvider, IRepository<SubscribeRecord> recordRepository, ILogger<SubscribeMessageService> logger) { _httpClientFactory = httpClientFactory; _accessTokenProvider = accessTokenProvider; _recordRepository = recordRepository; _logger = logger; } public async Task<bool> SendAsync(string openid, string templateId, Dictionary<string, 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 这类“授权本身失效”的情况才把剩余次数直接清零。我见过不少初版实现是先扣减再发送,恰好赶上接口超时,用户一条没收到,额度却没了,这个顺序千万别搞反。

还要记得在启动时注册 HttpClient:

services.AddHttpClient("wechat", c => { c.Timeout = TimeSpan.FromSeconds(10); });

把超时时间控制在 10 秒左右,微信接口偶尔会慢,但大部分时候 3 秒内就能返回。超时太长会把线程池拖垮,超时太短又可能误判,10 秒是折中值。

4. 高频失败码与“凭空消失”的消息:我的排查思路

4.1 先存一张错误码速查表

微信接口的返回码很多,但订阅消息场景真正高频的就那么几个,我直接放一张速查表:

返回码含义实际处理建议
0成功记得同步扣减已用次数
40003openid 无效核对是不是当前小程序下的 openid
40037template_id 无效去后台确认模板审核状态与 ID
41030page 路径不存在或未发布确认跳转页面已上线且不是登录态页面
43101用户拒收 / 未订阅 / 授权次数用完清除该用户该模板的余额
47003参数格式错误重点检查 camelCase 和 data 字段类型
45009调用接口频率超限加延时、加限流,避免瞬时大量触发

我不建议把全部错误码背下来,但这 7 个应当做成代码里的枚举或静态错误字典,打日志时直接输出中文说明,运维同学拿到日志就能看懂,不用每次翻文档。

4.2 返回 0 但用户没收到:两种最容易出现的真实情况

比报错更难受的是接口返回errcode=0,但用户就是没收到。我排过不少这类问题,经验上常出在两种情况。第一种是 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 这类参数或授权类错误不要重试,重试也是浪费。重试发送前要重新查一次额度,防止并发环境下两笔任务把一份额度用掉。

另外,用户真的不想收到提醒时,需要有主动关闭的出口,比如小程序内“消息设置”页,关闭后在业务侧标记不再下发。订阅消息本身已经够克制了,但克制之上再加一层用户可控,用户对产品的好感会明显不一样。我见过很多项目把订阅消息当免费触达工具用,完全不尊重用户的关闭意愿,最后模板的授权通过率一路下滑,反而把正常业务提醒也连累了。

在我的实践里,订阅通知推送的代码量其实不大,真正决定上线后顺不顺手的,是授权额度管理和错误处理这两块。把这两块做扎实,后面换模板、加业务都很顺。最后再提醒一句:真正开始写发送逻辑前,先去平台后台申请模板、确认关键词编号和类型,这个前置工作做得越细,后续的坑越少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询