简介:面向需要对接微信企业号的C#开发者,这份示例工程围绕主动推送消息展开,覆盖文本、图片、语音、视频与图文等消息类型的构建与发送,并涉及AppID/AppSecret配置、素材上传获取媒体ID、SendMessage调用及事件监听等常见环节,适合已有一定C#基础、希望快速理解企业号消息接口与项目组织方式的中级开发者参考。
压缩包共40个文件,以22个cs源码为核心,辅以3个csproj工程文件、2个resx资源文件,以及sln解决方案、config配置和缓存等辅助文件,整体约35KB,体量轻便,便于直接解压查看工程结构与调用关系。
已有875人学习,说明该主题在办公通知、内部系统告警等场景中具有实际需求。代码预览可见公共帮助类、消息数据模型、消息发送封装与WinForm入口等模块,读者可据此梳理配置读取、消息对象组装、接口调用与返回结果判断的完整链路,并在此基础上改造成适配自身业务的通知服务,同时注意遵循企业号推送规范,避免过度打扰。
1. 从一条漏发的考勤提醒说起:C# 桌面端怎么把消息推进企业号
去年底某公司行政群出过一次不大不小的事故:月底的考勤异常名单要靠人工一条条发给各部门负责人,值班的人漏了三个部门,第二天绩效核对全乱。后来有人丢过来一个 C# 写的桌面小程序,一个输入框、一个部门下拉、一个发送按钮,点一下就把消息推到企业号里指定的部门。这份资源就是那个东西的可运行版本——WChat 这个 Demo 用 WinForm 包住了企业号主动推送的接口,剥掉界面,核心只有两件事:拿 access_token,往 message/send 塞一份 JSON。适合手上有一堆内部系统、又不想为了一条通知去接整套框架的 .NET 开发者,也适合刚学 C# 想拿真实接口练手的人。
2. 工程结构拆解:WinForm 外壳下的三层推送骨架
把 rar 解开,第一眼看到的多半是一个典型的 .NET Framework 解决方案:一个 WinForm 启动项,若干类文件,一个 packages 目录。按这类企业号 Demo 的常规拆法,目录大体是下面这个样子,不同人写的版本细节会有出入,但职责划分基本一致。
| 目录 / 文件 | 承担职责 | 二次开发时的改动频率 |
|---|---|---|
Program.cs | 程序入口,设置 TLS 协议版本、全局异常 | 低,一次改完不用动 |
MainForm.cs | 界面、按钮事件、结果回显 | 高,要加字段就往这里加 |
Config/CorpConfig.cs | 读取 corpId、secret、agentId、接口根地址 | 中,换应用时改配置不改代码 |
Service/TokenService.cs | 取 token、缓存、失效重取 | 低,但改错最容易出事 |
Service/PushService.cs | 组装消息体、POST 到 message/send | 高,业务都堆在这 |
Model/*.cs | 请求和响应的实体类,字段名跟接口一一对应 | 中,加消息类型时同步加 |
App.config | 凭证、超时、日志路径 | 高,部署环境不同值不同 |
这个分层不是洁癖。我见过不少人图省事,把所有逻辑塞进按钮的 Click 事件里,二十行代码确实能跑通。可一旦需求变成"每天早上八点自动推一次"或者"推送失败要重试三次",那段代码就完全没法复用——它绑死在 Form 上了,连个单元测试都写不了。
2.1 三层怎么切:UI 只管取值,业务只管拼装,协议只管收发
我一般把它切成三块。UI 层只做三件事:收集用户输入(收件人、消息类型、正文)、禁用按钮防止重复点击、把返回的 errcode 展示出来。业务层负责把"给销售部发一条考勤提醒"翻译成一个消息对象,它不关心 HTTP 怎么发。协议层负责 token、序列化、发请求、解析错误码,它不关心这条消息是考勤还是审批。
这么切的好处很直接:UI 换成控制台、Windows 服务、ASP.NET 的定时任务,业务层和协议层一行都不用改。Demo 里虽然是 WinForm,但真正值钱的是后面两层,界面反而是最不值钱的部分。
一个常见的反面写法长这样,很多人上手就是这么写的:
// 反例:逻辑全压在按钮事件里,想挪到定时任务就得整段重写 private void btnSend_Click(object sender, EventArgs e) { var http = new HttpClient(); // token 每次点都重新取,接口配额白白烧掉 var tokenJson = http.GetStringAsync( $"{CorpConfig.ApiBase}/cgi-bin/gettoken?corpid={CorpConfig.CorpId}&corpsecret={CorpConfig.CorpSecret}") .Result; // WinForm 里 .Result 很容易把 UI 线程锁死 var token = JsonConvert.DeserializeObject<TokenResponse>(tokenJson).access_token; var body = $"{{\"touser\":\"@all\",\"msgtype\":\"text\",\"agentid\":{CorpConfig.AgentId}," + $"\"text\":{{\"content\":\"{txtContent.Text}\"}}}}"; var resp = http.PostAsync($"{CorpConfig.ApiBase}/cgi-bin/message/send?access_token={token}", new StringContent(body, Encoding.UTF8, "application/json")).Result; MessageBox.Show(resp.Content.ReadAsStringAsync().Result); }这段代码能跑,但埋了三颗雷:.Result在 WinForm 的同步上下文里会死锁,界面直接卡住不返回;token 每次都重新获取,一天几百条消息就能把调用额度耗掉;字符串拼 JSON,正文里带个双引号整个请求体就废了。后面两章基本就是在拆这三颗雷。
2.2 为什么是 .NET Framework 加 HttpClient
技术栈上没有花活:.NET Framework 4.6.1 及以上、HttpClient、Newtonsoft.Json,加一个纯 WinForm 界面。选 .NET Framework 而不是 .NET 8,理由很现实——这类内部小工具要扔到一台装着老版本 Windows 的办公机或者服务器上跑,双击 exe 就能用,装运行时的成本比啥都高。如果你的目标机器都可控,换成 .NET 8 的自包含发布也没问题,代码几乎不用改。
选HttpClient而不是WebClient,主要是WebClient在处理编码和 multipart 上传时太别扭,上传素材那段尤其明显。但HttpClient有两个必须提前处理的问题:一是它默认不保证用哪个 TLS 版本,老框架上可能协商到 TLS 1.0 然后被服务端拒掉;二是不该每次请求都new一个,也别急着写成单例到处传。Demo 里的折中做法是每个服务类持有一个静态实例,配置一次超时。
public static class CorpConfig { // 凭证从 App.config 读,别硬编码进窗体,换应用时不用重新编译 public static string CorpId => ConfigurationManager.AppSettings["CorpId"]; public static string CorpSecret => ConfigurationManager.AppSettings["CorpSecret"]; public static int AgentId => int.Parse(ConfigurationManager.AppSettings["AgentId"] ?? "0"); // 接口根地址单独拎出来,方便以后做环境切换或走内网出口 public static string ApiBase => ConfigurationManager.AppSettings["ApiBase"]; public static int TimeoutSeconds => int.Parse(ConfigurationManager.AppSettings["TimeoutSeconds"] ?? "15"); } internal static class Program { [STAThread] private static void Main() { // 老框架不显式指定,很可能协商到 TLS 1.0,服务端直接断连 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } }TimeoutSeconds建议给 10 到 15 秒。企业号接口平时响应在几百毫秒,超时设太短会在网络抖动时误判失败,设太长又会让界面一直转圈。ApiBase是完整根地址(到域名那一层,不带路径),后面所有接口都拼在它后面,这样以后要走内网出口或者做灰度切流,只改配置就行,不用全局搜字符串。
3. access_token 这一关:获取、缓存与并发下的双检锁
几乎所有人第一次接企业号接口,第一个卡点都是 token。它不是一次拿永久有效,而是有明确有效期,过期后所有业务接口统一返回 42001。更麻烦的是,取 token 的接口本身有频率限制,你把它当成"每次发消息前顺手调一下"的廉价操作,很快就会撞墙。
3.1 先看请求:一个 GET,但返回结构必须建模
取 token 是最简单的接口,GET 请求、两个查询参数,返回一段 JSON。真正需要认真对待的是返回体的建模——errcode为 0 才算成功,非 0 时access_token字段根本不存在,直接反序列化会拿到 null,然后在下一行抛空引用,报错信息跟真实原因毫无关系,这是排查时最浪费时间的一种情况。
public class TokenResponse { public int errcode { get; set; } public string errmsg { get; set; } public string access_token { get; set; } public int expires_in { get; set; } // 单位秒,正常情况是 7200 } public static async Task<string> FetchTokenAsync() { using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(CorpConfig.TimeoutSeconds) }) { // corpid 与 corpsecret 是应用凭证,secret 泄露等于把推送权限交出去 var url = $"{CorpConfig.ApiBase}/cgi-bin/gettoken" + $"?corpid={CorpConfig.CorpId}&corpsecret={CorpConfig.CorpSecret}"; var json = await http.GetStringAsync(url).ConfigureAwait(false); var resp = JsonConvert.DeserializeObject<TokenResponse>(json); // 先判错再取值,别指望 access_token 一定存在 if (resp.errcode != 0) throw new InvalidOperationException($"取 token 失败 [{resp.errcode}] {resp.errmsg}"); return resp.access_token; } }这里ConfigureAwait(false)不是可有可无的装饰。在 WinForm 里,await默认会尝试回到 UI 线程继续执行,如果上层有人用.Result或.Wait()阻塞等待,UI 线程被占着,回调排不进去,两边互相等,界面就永久卡死。加上ConfigureAwait(false)之后,续体不再要求回 UI 线程,这类死锁基本可以绝迹。
3.2 缓存:7200 秒不是让你照抄的
expires_in给的是 7200 秒,很多人直接AddSeconds(7200),然后定时任务偶尔报一次 42001。原因是这个数字是"从服务端签发那一刻算起",你拿到响应的时刻已经晚了几百毫秒,再加上网络延迟、任务排队、时钟漂移,临界点上刚好过期非常正常。
我一般按expires_in - 200算失效时刻,留出两百秒的余量。这点余量在正常调用量下完全无感,但能把"偶发过期"这类玄学问题压下去。
缓存放哪里,看部署形态。单机桌面工具放静态字段就够;如果是多个进程或者多台机器一起发,就必须放共享缓存(Redis 之类),否则每台机器各拿一份,token 刷新次数翻倍,配额照样吃紧。Demo 是单机版,静态字段加文件兜底,重启后不用立刻重新取。
3.3 并发:双检锁加信号量,别让十个线程同时去取
界面上的群发按钮点下去,可能是循环推给几十个部门,如果每次推送都独立判断缓存是否有效,缓存刚过期的那个瞬间会有多个线程同时发现"过期了",然后一起冲去取 token——这就是缓存击穿,配额瞬间被打掉一大截。
public class TokenService { private static string _token; private static DateTime _expireAt = DateTime.MinValue; // 只允许一个线程进入刷新逻辑,其余排队等待 private static readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); public static async Task<string> GetTokenAsync(bool forceRefresh = false) { // 快路径:缓存有效就直接返回,绝大多数调用走这里 if (!forceRefresh && !string.IsNullOrEmpty(_token) && DateTime.Now < _expireAt) return _token; await _gate.WaitAsync().ConfigureAwait(false); try { // 双检:排队期间可能已经被前一个线程刷过了,别再刷一次 if (!forceRefresh && !string.IsNullOrEmpty(_token) && DateTime.Now < _expireAt) return _token; var resp = await FetchTokenAsync().ConfigureAwait(false); // 少活 200 秒,避开临界点过期 var seconds = Math.Max(resp.expires_in - 200, 60); _expireAt = DateTime.Now.AddSeconds(seconds); _token = resp.access_token; return _token; } finally { _gate.Release(); // 一定要在 finally 里放,否则异常一次就永久卡死 } } // 业务接口报 40001 / 42001 时调用,强制下一轮重新取 public static void Invalidate() => _expireAt = DateTime.MinValue; }forceRefresh这个参数看着多余,实际是给错误处理留的口子:消息发送接口返回 40001(凭证无效)或 42001(已过期)时,说明缓存的 token 已经不能用了,这时候应该清缓存、重取一次、再重试一次请求,而不是直接把错误弹给用户。_gate.Release()放在finally里也是硬要求,漏了它,只要FetchTokenAsync抛过一次异常,后续所有推送都会卡在WaitAsync上,表现是"程序没死但再也不发消息了",很难查。
4. 消息体组装:文本、卡片、图文三种 payload 的拼装细节
token 通了之后,剩下的活就是拼 JSON。企业号的发送接口统一是/cgi-bin/message/send,所有消息类型共用一个入口,靠msgtype字段区分,具体内容放在以msgtype命名的子对象里。这个设计很省事,但也意味着字段名必须精确匹配,大小写错一个字母,服务端就丢掉那个字段,然后返回 errcode 0——消息成功发出,但你期望的收件人一个都没收到。
4.1 公共字段:touser、toparty、totag 三选一或组合
一条消息的收件人由三个字段决定,touser是成员账号(多个用竖线分隔),toparty是部门 ID,totag是标签 ID。三者可以同时出现,接口取并集,@all表示应用可见范围内的所有人。agentid必须跟你拿 token 用的那个应用一致,这是最常见的错配点:token 是 A 应用取的,agentid填了 B 应用,接口返回 60020 之类看起来毫不相干的错误。
| 字段 | 含义 | 常见填法 |
|---|---|---|
touser | 成员账号列表 | zhangsan|lisi,全员用@all |
toparty | 部门 ID 列表 | 2|5,多个用竖线 |
totag | 标签 ID 列表 | 同上,适合按项目组群发 |
msgtype | 消息类型 | text/textcard/news/file |
agentid | 应用 ID | 与取 token 的应用保持一致 |
safe | 是否保密消息 | 0明文,1保密(不可转发、不可截屏) |
safe这个字段值得单独说一句。填 1 之后消息在客户端里会带水印、禁止转发,看起来很香,但保密消息的客户端渲染行为跟明文不完全一致,某些老版本客户端上富文本会退化。内部通知一般填 0,涉及薪酬、人事这类内容再开 1。
4.2 三种消息体的实际拼装
Demo 里覆盖了三种最常用的类型:纯文本、文本卡片、图文。类结构上我习惯让公共字段继承自一个基类,子类只挂自己的 payload 字段,序列化的时候NullValueHandling.Ignore一开,空的totag、空的news就不会传上去——接口对"字段存在但为空"和"字段不存在"的处理并不总是一致,少传胜过传空。
public abstract class BaseMessage { public string touser { get; set; } public string toparty { get; set; } public string totag { get; set; } public string msgtype { get; set; } public int agentid { get; set; } = CorpConfig.AgentId; public int safe { get; set; } = 0; } public class TextMessage : BaseMessage { public TextMessage(string content) { msgtype = "text"; text = new { content }; } public object text { get; set; } } public class TextCardMessage : BaseMessage { public TextCardMessage(string title, string description, string url, string btnText = "详情") { msgtype = "textcard"; textcard = new { title, description, url, btntxt = btnText }; } public object textcard { get; set; } } public class NewsMessage : BaseMessage { public NewsMessage() { msgtype = "news"; } // 图文最多 8 条,超出部分服务端直接丢,不会报错 public object news { get; set; } = new { articles = new List<object>() }; }textcard的description支持少量 HTML 标签(<div>、<br>之类),但不支持 CSS,别指望做复杂排版。news的articles数组最多 8 条,第 9 条往后服务端静默丢弃——这个"静默"很坑,你从返回值里看不出任何异常。url必须是带协议头的完整地址,填相对路径在客户端里点不开。
发送端就一行序列化加一次 POST,重点全在序列化配置上:
public static async Task<SendResult> SendAsync(BaseMessage message) { var token = await TokenService.GetTokenAsync().ConfigureAwait(false); var url = $"{CorpConfig.ApiBase}/cgi-bin/message/send?access_token={token}"; var body = JsonConvert.SerializeObject(message, new JsonSerializerSettings { // 空的 toparty / totag 直接不传,避免接口把空串当无效收件人 NullValueHandling = NullValueHandling.Ignore, // 中文不转成 \uXXXX,出问题时抓包能一眼看懂正文 StringEscapeHandling = StringEscapeHandling.Default }); using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(CorpConfig.TimeoutSeconds) }) using (var content = new StringContent(body, Encoding.UTF8, "application/json")) { var resp = await http.PostAsync(url, content).ConfigureAwait(false); var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); var result = JsonConvert.DeserializeObject<SendResult>(json); // token 失效就地重试一次,别把 40001 直接抛给用户 if (result.errcode == 40001 || result.errcode == 42001) { TokenService.Invalidate(); return await SendAsync(message).ConfigureAwait(false); } return result; } }SendResult里除了errcode、errmsg,还有invaliduser、invalidparty、invalidtag三个字段,记录哪些收件人无效。这三个字段的价值在于:errcode 为 0 只代表"请求被接受了",不代表"所有人都收到了",把invaliduser记到日志里,能省掉大量"为什么他没收到"的来回扯皮。
4.3 图片和文件:先上传素材换 media_id
发图片、语音、文件、视频不能直接塞二进制,得先调/cgi-bin/media/upload换一个media_id,再用这个 id 发消息。上传用的是 multipart/form-data,字段名固定为media,type取image、voice、video、file之一。这里WebClient就很难受了,HttpClient的MultipartFormDataContent干净得多。
public static async Task<string> UploadMediaAsync(string filePath, string type) { var token = await TokenService.GetTokenAsync().ConfigureAwait(false); var url = $"{CorpConfig.ApiBase}/cgi-bin/media/upload?access_token={token}&type={type}"; using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }) // 大文件要单独放宽超时 using (var form = new MultipartFormDataContent()) { var bytes = File.ReadAllBytes(filePath); var file = new ByteArrayContent(bytes); file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); // 字段名必须是 media,换别的名字服务端找不到文件 form.Add(file, "media", Path.GetFileName(filePath)); var resp = await http.PostAsync(url, form).ConfigureAwait(false); var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); var media = JsonConvert.DeserializeObject<MediaResponse>(json); if (media.errcode != 0) throw new InvalidOperationException($"上传失败 [{media.errcode}] {media.errmsg}"); return media.media_id; // 有效期三天,别当永久资源存进数据库 } }media_id三天过期这件事必须写进注释。我见过一个项目把media_id存进表里当附件永久引用,三天后所有人点开都是失效的,排查了半天才想起这个限制。正确做法是每次发送前重新上传,或者在上传时就记录时间戳,超过两天半就重新传一遍。另外大文件(视频、PPT)上传要单独放宽超时,用默认的 15 秒几乎必失败。
5. 排查手册:五类报错的现象、成因和改法
企业号接口的错误码文档写得很全,但真正难的不是查错误码含义,而是知道该往哪个方向想。下面五条是我这几年反复撞上的,按"现象 → 原因 → 解决"记一遍,下次遇到能少走几小时弯路。
5.1 认证与配额:昨天还好今天全挂
现象:定时任务昨晚运行正常,今早批量报 40001 或 42001,手动点一次发送又好了。
原因:缓存的有效期按expires_in原样计算,卡在临界点上刚好过期;或者有人重置了应用 secret,旧 token 立刻作废;多台机器各自缓存时,只要其中一台刷新过,其他机器手里的旧 token 就失效了。
解决:有效期减 200 秒;在发送逻辑里对 40001、42001 做"清缓存、重取、重试一次"的兜底;多实例部署必须用共享缓存,不要各存各的。
现象:前几十条正常,之后开始报 45009(接口调用超过限制)。
原因:主动推送有频次和日配额,循环群发时很容易一个上午打满。
解决:本地加令牌桶限速,把同一部门的连续消息合并成一条(touser用竖线拼多个账号),非紧急消息走队列延迟发送。这条没法靠重试绕过,只能从发送策略上改。
5.2 返回成功但人没收到
现象:errcode 是 0,界面提示发送成功,收件人反馈说没看到。
原因:toparty里的部门不在应用的可见范围;touser用了显示名而不是成员账号;agentid和取 token 的应用不是同一个;或者收件人根本没装客户端。这些情况接口都返回 0。
解决:检查返回体里的invaliduser、invalidparty,有值就记日志;账号必须用后台的成员账号而非中文名;应用可见范围在管理后台单独配,新部门加进来要记得同步。
现象:图文消息只收到前几条,或者卡片消息点进去是空白页。
原因:articles超过 8 条被静默丢弃;卡片里的url填了相对路径或内网地址,客户端打开为空白。
解决:拼装前先把数组截到 8 条;url用带协议头的完整公网地址,内网系统记得做外网映射。
5.3 编码和网络:乱码、超时、白名单
现象:长文本发出去只剩前半段;正文里的引号变成\u201c这类转义或者直接乱码。
原因:text.content有 2048 字节上限,中文按 UTF-8 计算是 3 字节一个字,700 字左右就到顶了,超出的部分服务端截断但不报错;还有些老代码用WebClient并把Encoding设成 GB2312 处理 JSON,中文必然出问题。
解决:发送前按字节数截断,末尾补省略号,完整内容放到图文或者用文件发;统一 UTF-8,StringContent显式指定Encoding.UTF8,不要依赖默认值。
现象:本机调试一切正常,部署到服务器就报"不合法的 IP"。
原因:应用配置里开了可信 IP 白名单,服务器的出口 IP 没加进去。
解决:把出口 IP 加进白名单,或者给服务器固定一个出口地址;换机房、上容器之后 IP 会变,这条要写进部署清单。
6. 二次开发:把 Demo 改成带队列和模板的推送服务
Demo 到生产之间差的不是功能,是稳定性。我的改法通常分四步走,每一步都能独立验证。
第一步,把PushService抽成接口。定义IMessageSender,只暴露Task<SendResult> SendAsync(BaseMessage),界面依赖接口而不是具体类。这样后面换成队列版本、加装饰器记录耗时、写单元测试都只是换实现。
| 改造点 | 具体动作 | 验证方式 |
|---|---|---|
| 抽接口 | IMessageSender+ 默认实现 | 界面里能无感替换实现 |
| 模板化 | 消息模板存配置或库,占位符按 key 替换 | 改模板不发版 |
| 加重试 | 只对 40001/42001 和网络超时重试,最多两次 | 手动改错 secret 看日志 |
| 加日志 | 每次记录 errcode、invaliduser、耗时、请求体 | 出问题能凭日志复盘 |
第二步,模板化。考勤提醒、审批待办、告警通知,正文结构其实是固定的,只有几个变量不同。把模板抽出去之后,运营改文案不用找你发版,这一点在真实项目里省下的沟通成本远超代码本身。
第三步,重试要有边界。只对凭证失效和网络超时重试,业务类错误(收件人无效、参数不合法)重试一万次也没用,反而会放大配额消耗。重试必须带退避,别写成紧凑循环。
第四步,日志必须落请求体。企业号返回的错误信息往往很含糊,真正定位问题靠的是"我到底发了什么"。日志里存一份脱敏后的 JSON,比任何断言都管用。
验证方式我一般不用界面点,而是直接跑一段针对自己的账号:
// 联调自检:先发给自己,再断言 errcode,别一上来就对全公司群发 var msg = new TextMessage($"推送自检 {DateTime.Now:HH:mm:ss}"); msg.touser = CorpConfig.CorpId == null ? null : "自己的成员账号"; msg.toparty = null; var result = await PushService.SendAsync(msg); Console.WriteLine($"[{result.errcode}] {result.errmsg} invalid={result.invaliduser}");先用touser精确发给自己,确认链路通了,再切toparty测部门,最后才敢用@all。这个顺序能避免 90% 的误发事故——我曾经图快跳过这步,直接对全员发了一条带测试字样的消息,那次之后我每次改推送代码都强制先走一遍"发给自己"的自检,再考虑扩大范围。希望帮到你。
本文还有配套的精品资源,点击获取