资讯动态

C#实现微信小程序订阅消息推送:从模板授权到发送的完整指南

发布时间:2026/10/18 9:05:56 来源:尧图企业网站定制
做小程序的都知道用户一旦离开小程序页面想把他再叫回来最稳的手段就是订阅通知推送。这里说的订阅通知是小程序平台提供的模板消息能力用户在小程序里点一下授权后端就有机会往他微信里推一条结构化消息。之前我用 C# 从零写了一套订阅通知推送服务覆盖会议室预定提醒、工单状态变更、优惠券到期提醒这几类场景上线跑了小半年整体非常靠谱。这篇文章就把这条链路完整拆开讲从模板申请、前端授权到 C# 后端怎么拿 access_token、怎么组装参数、怎么调发送接口再到我实际踩过的坑全都会写到。这套内容适合谁如果你正在做小程序后端技术栈是 .NET / C#又恰好要接订阅消息但不太清楚接口细节那这篇可以直接帮你少走很多弯路。哪怕你现在用的是别的后端语言原理部分也一样通用无非是请求封装和语言语法不同核心接口和流程都是同一套。1. 订阅通知推送先想清楚你要做的是哪一环1.1 订阅消息到底解决了什么问题用户用完小程序就消失是几乎所有做小程序的人都头疼的事。即使你加了短信、邮件提醒成本高不说用户也不一定看。订阅消息不一样它直接出现在微信对话列表里打开率和触达率都远高于站内信。它也不是垃圾营销用户必须明确点过授权你才能推所以合规性天然有保障。从业务角度看订阅消息最适合三类场景。一是强时效提醒比如预约成功、会议开始前十分钟、抢购开始提醒二是状态变更通知比如工单被处理、审核结果出来、订单配送更新三是低频营销比如会员到期提醒、优惠券即将过期。这三类场景有一个共同点用户对消息有预期、内容本身有价值推送不容易惹人烦。我在接这个需求的时候第一反应不是急着写代码而是先想清楚业务侧到底要发哪些消息、每条消息由用户哪个行为触发。因为订阅消息不是你想发就发用户不点击授权你一次都发不出去。这个前提直接决定了能否推送、推什么、何时推。所以这篇文章虽然是讲 C# 实现但前面这几节关于机制和设计的理解恰恰是最容易被忽略的部分。1.2 一次性订阅和长期订阅怎么选订阅消息分两种一次性订阅和长期订阅。一次性订阅好理解用户点一下授权你只能给他推一条消息。他想再接收一条就得再授权一次。长期订阅则是用户授权一次你可以长期给他推但长期订阅的权限不是所有小程序都能申请一般面向政务、医疗、民生等特定类目需要在平台侧单独申请开通。普通小程序开发者绝大多数场景都用一次性订阅就够了。比如会议室预定用户提交预约单时顺手点一次授权预定成功后你给他推一条“预定成功、房间号是几零几”的通知这正好就是一条消息。用户下次再预约再授权一次逻辑闭环。如果你用一次性订阅硬撑高频推送场景比如每天都要给用户发天气提醒那用户得每天点一次授权体验很糟这种时候你该考虑的是长期订阅或换个触达渠道。另外要注意一次性订阅的授权额度是按“每个模板”单独算的。用户一次授权页里可能同时勾了好几个模板每个模板各算一次授权各有一条额度。假设你有 A、B 两个模板用户只勾了 A 没勾 B那你只能发 A 不能发 B。所以后端的额度记录一定要精确到模板级别不能笼统记一个“已订阅”。1.3 为什么后端的活要放在 C# 这边做有些朋友会问订阅消息不是小程序前端也能调接口吗为什么非要单独写后端小程序前端确实可以用云函数或服务端能力去调但大多数公司的小程序背后已经有一套业务后端推送的触发场景往往来自服务端比如后台审核员处理完一个工单、支付回调确认订单成功、定时任务扫描到优惠券临近过期。这些事件都发生在后端由 C# 服务去调发送接口最顺理成章。还有一层现实原因前端直接保存 access_token 非常不安全小程序代码是会被提取和反编译的把 AppSecret 放前端等于把钥匙交给外人。而 C# 后端可以严格把控 token 的获取、缓存和刷新同时把订阅授权记录、发送记录、额度扣减都落在自己的数据库里方便后续对账排查和统计分析。这套“前端只负责授权、后端统一发送”的架构也是平台官方推荐的做法。2. 动手前的核心认知模板、凭证与授权2.1 模板ID是怎么来的字段类型为什么重要订阅消息发送接口里最关键的一个参数就是 template_id这个不是你自己随便编的而是要在小程序管理后台的功能设置里从公共模板库挑选并添加得到的。每个模板都绑定了一组关键词字段关键词有明确的类型和长度限制你在后台点开模板就能看到每个 keyword 的位置编号和类型说明。字段类型常见的有 thing、number、phrase、time、date、amount 等。thing 一般是“项目名称”“物品描述”这类文本限制 20 个字符以内phrase 限制更严5 个汉字以内number 是数字32 位以内time 和 date 分别对应时间和日期格式amount 是金额有小数位数的精度要求。这个字段类型特别容易被轻视但它是后面 47003 报错的头号来源。你组装 data 的时候字段的 key 必须跟模板里 keyword 的位置对得上。比如模板第一个关键词是 thing 类型那 data 里就要写 thing1第二个关键词是 time 类型就写 time2。如果写错前缀或者值超长平台直接给你返回参数不合法。我在最开始对接时就吃过这个亏以为随便填个字符串就能发出去调试了很久才发现字段名跟模板 keyword 编号必须严格一一对应。2.2 access_token 的获取与缓存策略调用订阅消息发送接口之前必须拿到 access_token。这是所有微信服务端接口的公共凭证用 AppId 和 AppSecret 调用一个 GET 接口就能换到有效期 7200 秒。接口地址是这个样子https://api.weixin.qq.com/cgi-bin/token?grant_typeclient_credentialappidAPPIDsecretAPPSECRET返回的 JSON 里包含 access_token 和 expires_in 两个关键字段。这里我要强调一个新人容易踩的坑access_token 不要每次都重新请求更不要在后端每个服务里各请求各的。7200 秒的有效期内我们应该把它缓存起来用一个全局共享的地方统一维护。C# 里最简单的方案就是 IMemoryCache进程内缓存如果是多实例部署建议用 Redis 这类分布式缓存避免多个实例各自刷新、各自失效。我自己的做法是缓存的时间设成官方有效期减掉 300 秒比如官方是 7200 秒我只缓存 6900 秒。这样在真正过期之前就会重新拉取杜绝边界时间点的 40001 报错。另外接口偶尔会因为网络抖动或并发调用返回高频限制所以获取 token 的服务里最好加个简单的重试机制或者用双重检查锁保证同一时刻只有一个请求在刷新 token。2.3 前端授权那一下到底发生了什么用户在微信里看到的授权弹窗其实是前端调用了 wx.requestSubscribeMessage 这个接口。前端把模板ID传进去用户点了允许或拒绝回调里会返回这个模板对应的授权结果值是 accept、reject 或 ban。accept 表示同意reject 表示拒绝ban 表示用户勾选过“总是保持以上选择”后长期拒绝不会再弹窗询问。wx.requestSubscribeMessage({ tmplIds: [模板ID, 另一个模板ID], success(res) { if (res[模板ID] accept) { // 用户同意了这里把 openid、模板ID、授权结果提交给后端保存 } }, fail(err) { console.log(订阅授权调用失败, err); } });前端拿到 accept 之后要做的事情其实很简单把当前用户的 openid、对应的模板ID、授权结果通过你自己的接口提交到 C# 后端后端写进一张订阅授权记录表。注意前端不要直接存“额度”因为后续额度是否可用还取决于发送结果和后端扣减逻辑前端只是触发授权这件事本身。我建议在数据库里维护这样一张表主键、openid、template_id、授权状态、消费状态、创建时间、消费时间。同一用户同一模板可能有多条授权记录每次 send 成功之后就把对应的一条记录标记为已消费。这样即使业务开发一时疏忽反复触发发送后端也能通过消费状态挡住超出额度的推送不至于把一次性订阅当成无限推送来用。3. C# 后端推送的核心实现3.1 项目基础结构与依赖配置我用的方案是 .NET 8 的 Web API 项目服务层拆了两个类一个负责 access_token 的获取和缓存一个负责订阅消息的组装和发送。配置放在 appsettings.json 里包括 AppId、AppSecret 等用 IOptions 方式注入。网络请求统一走 IHttpClientFactory这是 .NET Core 里最靠谱的 HttpClient 管理方式能有效避免 socket 耗尽这种疑难杂症。{ WeChat: { AppId: 你的AppId, AppSecret: 你的AppSecret } }对应的配置类不复杂定义三个属性就够Bind 到 IOptions 使用。Build 服务的时候记得把 HttpClient 和 IMemoryCache 都注册进去IMemoryCache 是内置的AddMemoryCache 一下就行。如果你用的是 Redis 缓存注册 IDistributedCache 之后把 token 服务的缓存读写换成分布式接口其他逻辑完全一样。3.2 access_token 服务实现token 服务我通常写成这样整体逻辑很直白先查缓存缓存里有就直接返回没有就调微信接口拉取成功之后写缓存缓存时间按前面说的减掉 300 秒处理。public class WeChatAccessTokenService { private readonly IHttpClientFactory _httpClientFactory; private readonly IOptionsWeChatOptions _options; private readonly IMemoryCache _cache; private const string CacheKey wechat_subscribe_access_token; public WeChatAccessTokenService( IHttpClientFactory httpClientFactory, IOptionsWeChatOptions options, IMemoryCache cache) { _httpClientFactory httpClientFactory; _options options; _cache cache; } public async Taskstring GetAccessTokenAsync(CancellationToken cancellationToken default) { if (_cache.TryGetValue(CacheKey, out string token) !string.IsNullOrWhiteSpace(token)) return token; var opt _options.Value; var client _httpClientFactory.CreateClient(wechat); var url $https://api.weixin.qq.com/cgi-bin/token $?grant_typeclient_credentialappid{opt.AppId}secret{opt.AppSecret}; var json await client.GetStringAsync(url, cancellationToken); using var doc JsonDocument.Parse(json); var root doc.RootElement; if (root.TryGetProperty(errcode, out var errCode) errCode.GetInt32() ! 0) { throw new InvalidOperationException($获取 access_token 失败: {json}); } token root.GetProperty(access_token).GetString(); var expiresIn root.GetProperty(expires_in).GetInt32(); _cache.Set(CacheKey, token, TimeSpan.FromSeconds(Math.Max(60, expiresIn - 300))); return token; } }这里有几个细节值得说明。TryGetProperty 是因为成功响应里没有 errcode 字段只有 access_token 和 expires_in不能一上来就强取 errcode。缓存时间用 Math.Max 兜底防止极端情况下 expires_in 返回一个特别小的值导致缓存瞬间失效。多实例部署时把 _cache 换成共享的 Redis 缓存实现所有实例拿到同一个 token能避免很多并发刷新带来的额外限频问题。3.3 订阅消息发送服务实现发送服务要做得相对通用我定义了一个请求 DTO 和一个返回结果类。请求 DTO 里 touser 是用户 openidtemplate_id 是模板IDpage 是用户点击消息卡片后跳转的小程序页面路径data 是一个字典key 是模板字段位置value 包一层 value 字符串。public class SubscribeMessageRequest { public string touser { get; set; } public string template_id { get; set; } public string page { get; set; } public Dictionarystring, SubscribeMessageDataItem data { get; set; } public string miniprogram_state { get; set; } formal; public string lang { get; set; } zh_CN; } public class SubscribeMessageDataItem { public string value { get; set; } }发送接口的地址是https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_tokenACCESS_TOKEN请求方式是 POSTContent-Type 是 application/json。发送服务的关键在于对返回结果的解析微信的接口无论成功失败都会返回 JSONerrcode 为 0 表示成功非 0 则是各类错误码。我把 errcode 和 errmsg 一起封装进 SendResult 返回给调用方业务层再根据结果决定是否扣减授权额度。public record SendResult(int ErrCode, string ErrMsg, string RawJson) { public bool IsSuccess ErrCode 0; } public class SubscribeMessageSender { private readonly IHttpClientFactory _httpClientFactory; private readonly WeChatAccessTokenService _accessTokenService; public SubscribeMessageSender( IHttpClientFactory httpClientFactory, WeChatAccessTokenService accessTokenService) { _httpClientFactory httpClientFactory; _accessTokenService accessTokenService; } public async TaskSendResult SendAsync(SubscribeMessageRequest request, CancellationToken cancellationToken default) { var token await _accessTokenService.GetAccessTokenAsync(cancellationToken); var client _httpClientFactory.CreateClient(wechat); var body JsonSerializer.Serialize(request); using var httpResponse await client.PostAsync( $https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token{token}, new StringContent(body, Encoding.UTF8, application/json), cancellationToken); var json await httpResponse.Content.ReadAsStringAsync(cancellationToken); using var doc JsonDocument.Parse(json); var root doc.RootElement; var errCode root.GetProperty(errcode).GetInt32(); var errMsg root.TryGetProperty(errmsg, out var msgProp) ? msgProp.GetString() : string.Empty; return new SendResult(errCode, errMsg, json); } }把原始 JSON 也放进 SendResult 是我后来加上去的。理由很实在真出了问题光一个 errmsg 不够排查尤其是某些错误码只返回系统繁忙之类的模糊信息把原始响应落进日志里配合请求报文一起查才能快速定位是参数问题、权限问题还是平台临时故障。3.4 完整调用链与数据组装有了 token 服务和发送服务业务侧的调用就很简单了。拿会议室预定场景举例用户预约成功后业务代码查一下这个用户对这个模板有没有未消费的授权记录有就组装数据并发送。data 里每个 key 的值都必须符合模板字段类型和长度限制我会在组装时统一做截断处理避免用户在界面上输入二十一个字导致全程报错。var auth await _subscribeAuthRepo.GetLatestUnusedAsync(openid, meetingTemplateId); if (auth is null) return; var request new SubscribeMessageRequest { touser openid, template_id meetingTemplateId, page $pages/meeting/detail?id{meetingId}, data new Dictionarystring, SubscribeMessageDataItem { [thing1] new() { value Truncate(meetingName, 20) }, [time2] new() { value startTime.ToString(yyyy-MM-dd HH:mm) }, [thing3] new() { value Truncate(roomName, 20) }, } }; var result await _sender.SendAsync(request); if (result.IsSuccess) { await _subscribeAuthRepo.MarkConsumedAsync(auth.Id); } else { _logger.LogWarning(订阅消息发送失败, errcode{ErrCode}, errmsg{ErrMsg}, request{RawRequest}, result.ErrCode, result.ErrMsg, JsonSerializer.Serialize(request)); }这里我想额外提醒一下 page 参数。page 必须是小程序已存在的页面路径点击消息卡片后用户会直接进入这个页面建议把能把用户带到上下文的关键参数拼进去比如会议详情页的 id。页面路径要写绝对路径。如果你在 page 里忘了带参数用户点进去就是个空壳页面体验会很差。数据组装还有一个容易忽视的点所有 data 里的 value 最终传给微信的都是字符串。即使模板里 keyword 是 number 或 amount 类型JSON 里也是字符串形式比如 5 而不是 5。这是个非常细的接口约定但格式错了就会吃到 47003。我会在公共的组装方法里统一做 ToString 转换再往上层暴露强类型的方法避免业务开发传 int、传 decimal 时行为不一致。4. 常见问题与排查技巧实录4.1 错误码速查表这套接口上线以来我遇到过的报错主要集中在下面这张表里。建议你把这个表贴到自己项目的 Wiki 里每次联调报错先对一下能省下不少瞎猜的时间。errcodeerrmsg 含义原因与处理方式0success发送成功标记授权额度已使用-1system error微信服务器系统繁忙稍后重试建议做指数退避重试40001invalid access_tokenaccess_token 无效或过期清缓存后重新获取40003invalid openidopenid 不合法检查 openid 获取链路40037invalid template_id模板ID不正确去小程序后台核对41030invalid pagepage 路径不存在或格式不对43101user refuse用户拒收或未订阅需要重新引导授权45009reach max api daily quota limit接口调用超限降频或排队补发47001data format errordata 格式错误检查 JSON 结构和字段类型47003argument invalid参数不合法常见于字段值超长或类型不匹配4.2 40001 与 access_token 过期问题40001 是出现频率最高的报错之一绝大多数情况不是接口文档看不懂而是 token 的缓存策略出了问题。比如某个服务单独请求了一次 token 并缓存了完整 7200 秒但微信侧可能因为多次刷新、异地调用等原因实际收回了旧的 token这时候再拿旧 token 去调发送接口就打回 40001。我的经验是token 服务的缓存时间不要压满官方有效期提前几百秒刷新是一方面另一方面所有服务必须复用同一个 token 获取入口。如果代码里散落着多份去请求 token 的逻辑排查起来会非常痛苦。遇到 40001 时第一步永远是清掉本地缓存重新拉一次不要在那反复改参数。4.3 43101 用户未订阅或已拒收43101 的含义是用户拒收或未订阅但实际触发它的情况比字面意思多。最常见的一种是在一次性订阅场景里用户之前授权过一次你那条额度已经用掉了这时候没有再次授权就直接尝试发送第二、第三条微信会直接拒绝返回 43101。另一种情况是用户确实点了拒绝或者在小程序设置里关掉了订阅通知开关。遇到 43101别再盲目重试重试十次结果都一样。正确做法是业务层把它当成“需要重新引导订阅”的信号在用户下次进入小程序时挑一个业务场景自然触发 wx.requestSubscribeMessage。比如订单详情页放一个“开启收货提醒”的按钮而不是一打开小程序就强制弹窗用户体验会好很多。4.4 47003 参数不合法与字段长度的坑47003 是数据层面最容易翻车的错误。我印象最深的一次是模板里有个 thing 类型字段业务同学在页面上填了一个很长的标题存库的时候没截断拼接进 data 后直接触发 47003。排查日志的时候发现错误信息非常笼统只是说参数不合法根本不知道是哪个字段的问题。后来我做了三件事才彻底解决这类问题。一是封装一个字段校验器针对 thing、phrase、time、date、amount 等类型分别做长度和格式校验不合格就在组装的时候自动截断或者抛业务异常别把脏数据发给微信。二是给发送服务加完整入参日志把拼好的 JSON 打出来对照模板后台的关键字说明一眼就能看出问题。三是把模板字段类型的元数据集中配置在代码里或者配置中心业务字段和模板位置、类型一一对应不要散落在各处硬编码。值得一提的是 thing 类型的截断要按字符数算中文一个字符就是一位二十个字符的字段最多放二十个汉字。如果截断后丢了重要尾缀可以在连接数据库取数据时就把该字段截好省得每次组装都担心超长。4.5 45009 频率限制怎么破45009 表示接口调用超限。订阅消息发送接口对单个小程序的每日调用量是有额度的尤其是刚上线的小程序额度没那么宽裕遇到集中推送的活动场景很容易打满。我遇到过优惠券到期提醒的定时任务早上九点一次性把所有到期用户扫出来推送结果推送量一冲高接口直接回 45009后面一堆用户没收到提醒。处理思路有两个层次。第一层是业务侧控制发送任务不要一口气全并发出去分批次、加间隔比如每分钟发一千条把峰值磨平。第二层是架构侧兜底发送任务全部落到队列里发送服务消费队列发送失败且错误码可重试的就重新入队隔一段时间再补发直到成功或超过最大重试次数。这样即使某天配额紧张最坏情况也只是消息晚到不会永久丢失。4.6 测试环境与真机联调的那些坑订阅消息的联调往往比普通接口更让人摸不着头脑因为它在开发者工具、体验版和正式版里的行为不完全一致。我在真机测试阶段踩得最深的坑是开发者工具里能收到消息换到体验版就收不到日志显示明明都返回成功了。后来仔细看文档才发现发送接口的 miniprogram_state 参数默认值是 formal对应正式版小程序。如果你在开发版或体验版环境测试消息可以发送成功但正式环境内存量有限开发者工具收到消息的路径也受环境限制。实际联调时我建议按环境动态设置 miniprogram_state开发环境传 developer体验版传 trial正式环境传 formal。可以用一个配置项控制发布的时候自动切换免得手工改来改去。还有一个细节真机调试时订阅授权弹窗和消息接收依赖用户微信的“订阅消息”开关如果测试微信号以前拒绝过某个模板之后可能直接 ban 不再弹窗。遇到这种情况换一个干净的测试微信号或者换一个未操作过的模板ID能省掉很多自我怀疑的时间。5. 我最后想说的几句实操心得整套订阅通知推送做下来我最大的体会是这个功能的难点从来不在 C# 代码本身而在对平台规则和字段细节的理解上。接口文档就几页可真正决定线上稳不稳的是你对一次性订阅额度的管理、字段校验的完整度、错误码的分流处理还有对测试环境和正式环境差异的敬畏。我个人建议你在接这个功能时至少要留出半天时间专门做数据校验和异常日志的打磨。把每一次发送的请求报文、响应报文都结构化地记录下来线上出问题时不是靠猜而是靠日志还原现场。另外把订阅授权记录和发送记录做成两张独立的表中间用授权记录主键关联哪怕推送链路出了偏差也能快速算出“授权了多少次、成功发出去多少条、失败是什么原因”。最后再分享一个小技巧发送接口返回 0 成功之后微信侧偶尔也有极低概率的消息延迟用户可能过一会儿才在微信里看到。所以你的业务流程不要把“发送成功”当成“用户已阅读”该有的站内提醒还是要保留多条触达渠道互相补位才是生产中比较稳妥的做法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑