C# .NET 企业微信主动推送:access_token 缓存与消息发送实战

发布时间:2026/10/29 2:23:17
C# .NET 企业微信主动推送:access_token 缓存与消息发送实战

简介:这份压缩包面向使用 C# 开发微信企业号消息推送的 .NET 开发者,围绕主动推送文本、图片、语音、视频与图文消息,以及事件监听、媒体上传和发送结果处理等常见需求,提供可参考的示例工程与代码结构。包内共 40 个文件,以 22 个 cs 源码为主,辅以 csproj 项目文件、sln 解决方案、config 配置、resx 资源及缓存文件,压缩包约 35KB,体量轻便,便于导入 Visual Studio 查看和调试。已有 874 人学习,说明该主题具备一定实践参考价值。读者可从中了解企业号 AppID 与 AppSecret 配置、消息对象构建、媒体上传与 SendMessage 调用之间的组织方式,并借助公共帮助类、模型层和主程序分层的示例结构,梳理主动推送功能从参数准备到返回状态判断的实现思路,适合需要快速搭建企业消息通知原型或查漏补缺的中初级 C# 开发者。

1. 后台日志里 errcode 全是 0,运维却说一条没收到

这是很多人第一次接微信企业号主动推送时的开局:接口返回成功,人却没收到,最后发现是接收人不在应用可见范围里。这个 C# 资源包解决的就是这件事——在你的 .NET 后台里,通过企业号(也就是现在的企业微信)接口,往指定成员、部门或标签主动推一条消息出去。它不做聊天机器人,也不处理被动回复,只跑通「业务系统 → 企业号 → 人」这条单向链路:取 access_token、拼消息体、发 HTTP、读返回码。适合手上有一堆 .NET Framework 老系统、又不想为了发条通知引一整套 SDK 的后端开发者。接下来从工程结构开始拆。

2. 项目定位与技术栈:一套 C# 主动推送的脚手架

2.1 主动推送与被动回复,先分清你要走哪条链路

企业号的消息能力分成两条完全独立的路径,很多人一开始就混在一起写,后面越改越乱。

一条是被动回复:企业号把用户发给应用的消息 POST 到你配置的回调 URL,你在 5 秒内返回一段加密后的响应。这条路要处理签名校验、AES 解密、XML 解析,本质上是在写一个 Web 服务端。

另一条是主动推送:你的系统主动调用/cgi-bin/message/send,带上 access_token 和消息体,把消息塞进去。凡是「系统产生事件 → 通知到人」的场景都走这条:工单派发、审批催办、告警、日报、巡检异常、定时提醒。

这个资源包只做后者。这个取舍很关键,因为两条链路的鉴权方式、限流配额、错误码体系完全不同。主动推送只需要三样东西:CorpID、应用的 Secret、应用的 AgentId。拿到这三样就能发消息,不需要公网回调地址,也不需要处理加解密。对于内网部署的后台系统,这一点几乎是决定性的——很多老系统根本没有对外的域名。

主动推送还有几条硬约束,必须在写代码前搞清楚,否则后面全是坑:

  • access_token 有效期 7200 秒,而且每天调用gettoken的次数有上限,必须缓存,不能每次发消息都去取一次。
  • message/send接口有频率限制,单应用的调用频次和接收人数都有配额,全量推送要分批。
  • 接收人必须落在这个应用的「可见范围」内,否则接口返回成功但实际投递不出去,返回体里的invaliduser字段会列出没送达的人。
  • 不同消息类型对客户端版本有要求,markdown类型只支持企业号客户端,微信插件里看不到。

2.2 技术栈清单与依赖取舍

项目是典型的 .NET Framework 时代的写法,没有引入任何第三方 SDK,所有 HTTP 和 JSON 都是手写的。这种「零依赖」的风格在老项目里很实用——你不需要为一个发通知的功能去升级整个解决方案的包版本,也不会有依赖冲突。

层次选型说明
语言/运行时C# / .NET Framework 4.0 及以上4.0 也能跑,不依赖 async/await
HTTP 客户端HttpWebRequest同步阻塞写法,兼容性最好
JSON 序列化System.Web.Script.Serialization或 Newtonsoft.Json前者 .NET 自带零依赖,后者功能全
配置管理App.config/web.config的appSettingsCorpID、Secret、AgentId 全部外置
字符编码UTF-8请求体和响应体都必须显式指定
测试入口控制台或 WinForm 按钮方便单条消息验证

如果项目用的是System.Web.Script.Serialization.JavaScriptSerializer,要注意它对匿名对象的序列化支持是够的,但对DateTime和枚举的处理比较别扭,消息体里尽量只放 string、int、bool 和嵌套对象。用 Newtonsoft.Json 的话自由度更高,能直接JObject拼装,调试时打印出来也好看。

我个人的习惯是:如果解决方案里已经引了 Newtonsoft.Json,就直接复用;如果是全新的小工具,用自带的JavaScriptSerializer省事,少一个 DLL 就少一次版本对齐。

2.3 目录结构与文件职责

解压后的工程结构大致是这样,按职责分层,不复杂:

WChat/ ├── WChat.sln ├── WChat/ │ ├── App.config # CorpID / Secret / AgentId / ApiBase │ ├── WChatConfig.cs # 配置读取封装,缺失项启动即报错 │ ├── HttpHelper.cs # GET / POST 的 HttpWebRequest 封装 │ ├── JsonHelper.cs # 序列化与反序列化统一入口 │ ├── AccessTokenContainer.cs # token 的获取、缓存、刷新 │ ├── WChatClient.cs # 消息发送主入口,按 msgtype 分方法 │ ├── WChatException.cs # 带 errcode 的自定义异常 │ ├── Models/ │ │ ├── TokenResult.cs # gettoken 的响应体 │ │ ├── SendResult.cs # message/send 的响应体 │ │ └── TextCard.cs # 卡片消息体 │ └── Program.cs # 控制台测试入口 └── README.md

WChatConfig把ConfigurationManager.AppSettings里的值读出来做一次非空校验,缺配置时在启动阶段就抛异常,比等到发消息时才报 40013 好排查得多。HttpHelper只负责「发出去、拿回来」,不掺业务逻辑;AccessTokenContainer只管 token 的生命周期;WChatClient是唯一对外暴露的门面。这套分层的好处是后面换 JSON 库或者换 HTTP 实现,改动面都很小。

Models目录下的实体不建议做得太全,只建你实际用到的字段就行。企业号的响应体字段很多,全量映射反而会让反序列化在字段缺失时出问题,留几个关键字段(errcode、errmsg、access_token、expires_in、invaliduser)足够了。

3. access_token 链路:整条推送最容易被卡住的一环

3.1 请求封装:HttpWebRequest 的同步写法

所有请求最终都落到两个方法上。GET 用于取 token,POST 用于发消息。看起来很基础,但编码和超时这两处如果不写死,后面会莫名其妙地翻车。

// HttpHelper.cs public static class HttpHelper { // 默认 10 秒超时,内网到接口网关一般 2 秒内返回 public static string Get(string url, int timeoutMs = 10000) { var req = (HttpWebRequest)WebRequest.Create(url); req.Method = "GET"; req.Timeout = timeoutMs; req.ReadWriteTimeout = timeoutMs; req.KeepAlive = false; // 短连接更稳,避免复用失效连接 req.UserAgent = "WChat/1.0"; using (var resp = (HttpWebResponse)req.GetResponse()) using (var sr = new StreamReader(resp.GetResponseStream(), Encoding.UTF8)) { return sr.ReadToEnd(); } } public static string Post(string url, string jsonBody, int timeoutMs = 10000) { var req = (HttpWebRequest)WebRequest.Create(url); req.Method = "POST"; req.Timeout = timeoutMs; req.ReadWriteTimeout = timeoutMs; req.KeepAlive = false; // charset 必须显式声明,否则服务端可能按单字节解析中文 req.ContentType = "application/json; charset=utf-8"; var bytes = Encoding.UTF8.GetBytes(jsonBody); req.ContentLength = bytes.Length; // 必须是字节长度,不是字符数 using (var rs = req.GetRequestStream()) { rs.Write(bytes, 0, bytes.Length); } using (var resp = (HttpWebResponse)req.GetResponse()) using (var sr = new StreamReader(resp.GetResponseStream(), Encoding.UTF8)) { return sr.ReadToEnd(); } } }

ContentLength这一行是血泪经验。写成jsonBody.Length在纯英文下看不出问题,一旦消息内容里有中文,字符数和字节数就对不上,服务端读到的 body 被截断,返回的是40008 invalid message type或者干脆 400 错误。正确做法是先把字符串转成 UTF-8 字节数组,用数组长度。

KeepAlive = false也是踩坑之后的默认值。老版本 .NET 的连接池在某些网关下会复用已经被对端关闭的连接,表现是间歇性的「基础连接已经关闭」,很难复现。推送这种低频场景,短连接的开销可以忽略。

3.2 缓存、并发与提前过期

token 的缓存策略决定了整条链路的稳定性。原则只有一条:进程内只允许有一个 token,所有线程共用。

// AccessTokenContainer.cs public static class AccessTokenContainer { private static readonly object _sync = new object(); private static string _token = null; private static DateTime _expireAt = DateTime.MinValue; public static string GetToken(string corpId, string corpSecret) { // 锁外快路径:绝大多数调用直接返回,不进入排队 if (!string.IsNullOrEmpty(_token) && DateTime.Now < _expireAt) return _token; lock (_sync) { // 锁内二次检查,防止并发时重复请求 if (!string.IsNullOrEmpty(_token) && DateTime.Now < _expireAt) return _token; var url = string.Format( "{0}/cgi-bin/gettoken?corpid={1}&corpsecret={2}", WChatConfig.ApiBase, corpId, corpSecret); var json = HttpHelper.Get(url); var result = JsonHelper.Deserialize<TokenResult>(json); if (result.errcode != 0) throw new WChatException(result.errcode, result.errmsg); _token = result.access_token; // 提前 300 秒过期,避开边界时刻拿到刚失效的 token _expireAt = DateTime.Now.AddSeconds(result.expires_in - 300); return _token; } } public static void Refresh() { lock (_sync) { _expireAt = DateTime.MinValue; } } }

双重检查锁在这里不是为了炫技。推送场景往往是定时任务触发,一个批次可能并发几十个线程同时要发消息,如果每次都进锁,等于把并发压成串行。锁外快路径让 99% 的调用直接返回字符串,只有 token 真正过期的那一瞬间才会有一次网络请求。

提前 300 秒过期是为了解决边界问题。假设 token 在 12:00:00 整点失效,某个请求在 11:59:59.8 拿到 token 缓存,等它真正打到服务端时已经 12:00:00.5,返回 42001。这种错误在压测里几乎复现不出来,上线后偶发一次,排查成本极高。提前 5 分钟换新,用一点点多余的 gettoken 调用换掉这类玄学问题,很划算。

Refresh()方法留给上层在收到40014或42001时主动调用,配合重试逻辑使用。

3.3 完整代码:从配置到第一次取 token

配置文件里放这几项,注意 Secret 不要提交到代码仓库:

<!-- App.config --> <appSettings> <add key="WChat.CorpId" value="ww********" /> <add key="WChat.Secret" value="填应用的Secret" /> <add key="WChat.AgentId" value="1000002" /> <add key="WChat.ApiBase" value="https://qyapi.weixin.qq.com" /> </appSettings>

WChatConfig负责读取并做一次校验:

public static class WChatConfig { public static string CorpId { get; private set; } public static string Secret { get; private set; } public static long AgentId { get; private set; } public static string ApiBase { get; private set; } // 静态构造,应用启动时执行一次,缺配置直接抛 static WChatConfig() { CorpId = Require("WChat.CorpId"); Secret = Require("WChat.Secret"); AgentId = long.Parse(Require("WChat.AgentId")); ApiBase = Require("WChat.ApiBase").TrimEnd('/'); } private static string Require(string key) { var v = ConfigurationManager.AppSettings[key]; if (string.IsNullOrWhiteSpace(v)) throw new ConfigurationErrorsException("缺少配置项: " + key); return v; } }

参数含义:CorpId是企业身份标识,在后台「我的企业」里能看到;Secret是具体某个应用的凭证,一个应用一个 Secret,互相不能混用;AgentId是应用 ID,发消息时必须带上,系统靠它判断消息属于哪个应用、走哪个可见范围;ApiBase是接口网关地址,放在配置里而不是硬编码,方便以后切换测试环境。

取 token 的调用就这么一行:

var token = AccessTokenContainer.GetToken(WChatConfig.CorpId, WChatConfig.Secret);

如果这一步就抛了40001(不合法的 secret)或者40013(不合法的 CorpID),先别急着查代码,八成是复制凭证时多了空格,或者拿的是别的应用的 Secret。

4. 消息发送模块:把 text、textcard、news 统一成一个入口

4.1 消息体的建模方式

企业号支持的消息类型有十来种,实际项目里高频使用的就四种:text纯文本、textcard带跳转按钮的卡片、news图文列表、markdown富文本。

每种类型的消息体结构不同,但外层字段是一致的:touser、toparty、totag、msgtype、agentid、safe,外加一个与msgtype同名的对象。用匿名对象拼最省事,不用为每种类型建一个类。

msgtype适用场景关键字段
text简单通知、告警正文content
textcard需要跳回系统处理title / description / url / btntxt
news多条内容聚合articles 数组,每条含 title / url / picurl
markdown富文本排版content,仅客户端可见

纯文本发送:

public SendResult SendText(string content, string toUser = "", string toParty = "", string toTag = "") { var body = new { touser = toUser, toparty = toParty, totag = toTag, msgtype = "text", agentid = WChatConfig.AgentId, text = new { content = content }, safe = 0 }; return Post("/cgi-bin/message/send", body); }

卡片发送,多一个跳转地址:

public SendResult SendTextCard(string title, string description, string url, string toUser = "", string toParty = "", string toTag = "") { var body = new { touser = toUser, toparty = toParty, totag = toTag, msgtype = "textcard", agentid = WChatConfig.AgentId, textcard = new { title = title, description = description, url = url, // 必须是公网可达的完整地址 btntxt = "查看详情" } }; return Post("/cgi-bin/message/send", body); }

btntxt最多 4 个字,超过会被截断;description支持换行,用\n就行。safe = 0表示不开启加密,如果消息内容涉及敏感信息可以设成 1,但设成 1 之后只有企业号客户端能看,微信插件端收不到,这个取舍要提前跟业务方确认。

4.2 发送方法与返回码判定

所有类型最终都汇到一个私有的Post方法,token 拼接、异常识别、失效重试都在这里做:

private SendResult Post(string path, object body) { var token = AccessTokenContainer.GetToken(WChatConfig.CorpId, WChatConfig.Secret); var url = string.Format("{0}{1}?access_token={2}", WChatConfig.ApiBase, path, token); var json = JsonHelper.Serialize(body); var result = JsonHelper.Deserialize<SendResult>(HttpHelper.Post(url, json)); // token 失效的两个经典错误码,刷新后重试一次即可 if (result.errcode == 40014 || result.errcode == 42001) { AccessTokenContainer.Refresh(); token = AccessTokenContainer.GetToken(WChatConfig.CorpId, WChatConfig.Secret); url = string.Format("{0}{1}?access_token={2}", WChatConfig.ApiBase, path, token); result = JsonHelper.Deserialize<SendResult>(HttpHelper.Post(url, json)); } if (result.errcode != 0) throw new WChatException(result.errcode, result.errmsg); return result; }

重试只做一次,不做循环。因为40014和42001都属于「token 状态不对」,刷新一次必然能解决;如果刷新后还是同一个错误,说明 Secret 本身出了问题,再重试一百次也没用,直接抛出去让人看见更好。

返回体里还有三个容易被忽略的字段:invaliduser、invalidparty、invalidtag。这三个字段列出的是「格式合法但不在可见范围内」的接收人。errcode依然是 0,但这些人实际收不到消息。所以判定发送是否成功,不能只看errcode。

if (!string.IsNullOrEmpty(result.invaliduser)) { // 这些人没收到,落日志或告警,通常是账号离职或不在应用可见范围 Log.Warn("推送未送达: " + result.invaliduser); }

4.3 接收人参数:touser、toparty、totag 的取舍

三个参数至少填一个,多个接收人用|分隔。touser填的是成员账号(UserID),不是姓名也不是手机号;toparty填部门 ID;totag填标签 ID。

选择的原则很实际:

小范围、点名式的通知,比如「这条工单派给了张三」,用touser精确投递,代码里直接拼账号。缺点是人员变动时账号要跟着改,硬编码的人名映射表迟早会失效。

大范围、按组织架构的通知,比如「运维部全员」,用toparty。部门 ID 在后台能看到,人员调入调出不用改代码。

跨部门、按角色聚合的通知,比如「所有值班人员」,用totag。标签是由管理员手工维护的,业务代码里只认标签 ID,人员变更全部在后台操作,这是长期维护成本最低的一种。

全员推送用touser = "@all",但这个要慎用。一次推给几千人,很容易撞上频率限制,而且接口调用会返回成功、实际投递可能排队。真要发全员通知,我一般会拆成按部门循环发送,每批之间睡 200 毫秒,把压力摊平。

touser单次最多 1000 个账号,部门单次最多 100 个,标签单次最多 100 个。超了不会报错,多出来的部分会被静默丢弃,这也是「errcode 是 0 但有人没收到」的常见原因之一。

5. 常见问题排查:上线后才暴露的五个坑

5.1 60020 不合法 IP:本机通了,服务器不通

现象:在自己电脑上跑得好好的,一部署到测试服务器就返回60020 not allow to access from your ip,errmsg里还带着一串 IP。

原因:企业号应用有「企业可信 IP」配置,只有白名单里的出口 IP 才能调用接口。本机开发时用的是家庭或办公网 IP,服务器是另一套出口。

解决:把errmsg里提示的 IP 加到应用设置的可信 IP 列表里。这里要注意加的是服务器的出口IP,不是内网 IP。如果服务器在 NAT 后面,或者经过负载均衡、专线出口,看到的往往跟ipconfig的结果完全不是一回事。最省事的办法就是直接看接口报错里返回的那个 IP,照着加。

多机房部署的话,每个机房的出口 IP 都要加,别只加一个。以前遇到过一次,主机房加上了,灾备机房忘了加,切换过去之后整条推送链路静默失败,监控也没覆盖到,第二天才发现。

5.2 42001 access_token 超时:白天正常,半夜必挂

现象:白天手动测试一切正常,凌晨的定时任务一跑就报42001 access_token timeout。

原因有两个。一是缓存写了但没设过期,进程跑了两小时之后 token 早就失效了,代码还在拿旧的用。二是多进程或多机部署时,每个进程各自缓存一份 token,各自去刷新,互相顶掉。

解决:缓存必须带过期时间,并且提前几分钟刷新。如果应用是多进程的(比如 IIS 多工作进程、或者多个服务实例),token 要么放到共享存储里(Redis、数据库表),要么干脆固定由一个进程负责刷新。

判断是不是被顶掉,可以看gettoken的调用频率。如果日志里发现几乎每次发消息前都触发了一次取 token,说明缓存根本没生效,先查静态字段是不是被写在了实例类里。

5.3 中文乱码与 40008:英文正常,中文必炸

现象:发纯英文消息一切正常,消息里只要出现中文,要么返回40008 invalid message type,要么客户端显示一串问号。

原因:POST 请求没有声明charset=utf-8,或者ContentLength用了字符串长度而不是字节长度。还有一种情况是用了StreamWriter包装请求流但没指定编码,默认走了系统区域设置里的 GB2312。

解决:ContentType显式写成application/json; charset=utf-8,请求体用Encoding.UTF8.GetBytes转成字节数组再写入,ContentLength取数组长度。读取响应时也用Encoding.UTF8,两边编码对齐。改完之后先把消息内容打日志看一眼,确认中文在序列化之后是正常字符而不是\uXXXX。

5.4 大批量推送:返回成功但一半人没收到

现象:一次推给 800 个人,接口返回errcode: 0,但业务方反馈只收到一部分。

原因:touser单次上限 1000 个账号,看起来 800 是够的,但实际拼装时如果混了部门或标签,或者账号字符串总长度过长,会被截断。另一种情况是短时间内高频调用触发了频率限制,超出配额的部分被丢弃。

解决:分批发送,每批控制在 100 到 200 个接收人,批与批之间Thread.Sleep(200)。发送结果要落库,把每批的errcode、errmsg、invaliduser都记下来,失败批次用定时任务补偿重试。别指望一次调用搞定所有事,推送这种链路,宁可慢一点也要保证可追溯。

补偿任务的幂等性也要考虑。重试的时候如果消息已经发出去了,用户就会收到两条一样的通知。我一般的做法是给每条业务消息生成一个唯一 key,落一张发送记录表,重试前先查状态。

5.5 textcard 点不开:卡片收到了,按钮没反应

现象:卡片消息正常显示,但点「查看详情」跳 404 或者白屏。

原因:url字段必须是完整地址,带http://或https://,不能写相对路径。如果是内网地址,手机端根本访问不到。另外域名如果没在企业号后台配置过,部分客户端会拦截。

解决:url用公网可达的完整地址,参数做 URL 编码(尤其是带中文或者特殊符号的时候)。如果是内网系统,中间加一层跳转页,或者用带一次性票据的短链。另外,textcard的url参数在部分老客户端上不支持#之后的 hash 路由,SPA 页面要改成 query 传参。

6. 二次开发改造:把推送能力剥出来接进自己的业务

这个包本身是个能跑的骨架,但直接拿去用,往往会被业务代码缠住。我的做法是先抽一个接口出来,把「发什么」和「怎么发」彻底分开。

public interface IWChatSender { SendResult SendText(string content, Recipient to); SendResult SendTextCard(string title, string desc, string url, Recipient to); } public class Recipient { public string UserIds { get; set; } public string PartyIds { get; set; } public string TagIds { get; set; } }

上层业务只依赖IWChatSender,测试的时候可以换成一个打印日志的假实现,不用连真实接口就能把流程跑通。这一层抽象带来的最大好处是,后面要换成别的通知渠道(邮件、短信、站内信),业务代码一行不用改。

第二件事是削峰。推送不该在业务事务里同步做。工单创建成功后调一次 HTTP,网络抖一下就把整个请求拖慢了。常见做法是往数据库的消息表里插一条待发送记录,另一个定时任务扫表发送;量大一点就上消息队列。表结构至少要这几个字段:接收人、消息类型、内容、状态、重试次数、下次重试时间、创建时间。状态机就三态:待发送、成功、失败。

CREATE TABLE wchat_outbox ( id BIGINT PRIMARY KEY IDENTITY, msg_type VARCHAR(20) NOT NULL, content NVARCHAR(MAX) NOT NULL, to_user VARCHAR(500), to_party VARCHAR(200), to_tag VARCHAR(200), status TINYINT NOT NULL DEFAULT 0, -- 0待发 1成功 2失败 retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NULL, err_code INT NULL, err_msg NVARCHAR(500), created_at DATETIME NOT NULL DEFAULT GETDATE() );

第三件事是重试策略。不是所有错误码都值得重试。-1(系统繁忙)和45009(接口调用超过限制)可以退避重试,间隔按 30 秒、2 分钟、10 分钟往上翻;40001、40013、60020、81013这类配置和权限问题重试多少次都一样,直接标记失败并告警,让人去看配置。我见过一个项目对所有错误一律重试三次,结果一个 IP 白名单没配的问题,硬是刷了三天日志。

验证方法按这个顺序走:先发给自己,确认账号和可见范围没问题;再发给一个测试部门,确认toparty和批量逻辑;然后用小流量灰度,比如只推给当天值班的几个人,跑一周看日志里的errcode分布;最后才放开全量。每一步都要看日志,尤其是invaliduser字段,它能提前暴露人员账号变动的问题。

日志这一块我吃过亏。早期只记了errcode,出问题的时候根本不知道发出去的消息体长什么样,只能靠猜。后来改成请求 URL、请求体、响应体原文全部落日志,注意把access_token这一段打码,不然日志文件本身就是个安全隐患。有了原文,排查40008这类问题基本是看一眼的事。

从那以后我每次接新的通知渠道,都强制先写一个「只发给自己」的冒烟测试,配置、凭证、网络、可见范围一次性验证完,再动业务代码。这个习惯帮我省掉的排查时间,比任何调试技巧都多。希望帮到你。

本文还有配套的精品资源,点击获取