☰
C#封装微信企业号主动推送:从Token获取到消息发送的完整实现
2026/10/21 21:59:12 网站建设 项目流程

简介:这份rar压缩包提供了一套基于C#的微信企业号主动推送消息完整示例工程,适合需要快速对接企业号API的.NET开发者,尤其适用于企业向员工或客户推送通知、活动提醒等场景。压缩包体积约35KB,共40个文件,其中22个C#源代码文件构建了核心逻辑,另有3个工程文件、2个资源文件及解决方案文件等,目录结构清晰,便于直接打开工程查看。已有874人学习下载。工程内包含微信企业号辅助类、消息模型定义和主动发送调用逻辑,覆盖文本、图片、语音、视频、图文等消息类型的构造方式,并给出发送接口的参数组织与调用示例;同时附带WinForms演示界面,可输入相关信息后直接运行验证推送效果,方便二次开发与接入企业实际业务。学习这套代码可快速掌握企业号消息推送的基本流程,减少摸索微信API的时间。

1. 微信企业号主动推送:企业内部通知的最后一公里

企业内部系统的消息,往往死在最后一百米:审批通过、巡检告警、定时任务跑完,这些节点都需要把结果推到员工手机的企业号会话里。如果靠后台手动群发,既无法指定人,也没法和业务系统联动;如果从零调接口,又要自己处理Token缓存、消息构造、错误解析这一堆细节。这个名为WChat的C#工程,把微信企业号主动推送功能完整封装了一遍:拿CorpID和Secret换access_token、按类型组装消息体、调用发送接口并解析回执,封成类库后一行代码就能触发推送。适合要把通知能力嵌进OA审批流、运维监控、报表分发系统的C#开发者,也适合第一次接触企业号API的新手按Demo跑通链路。

2. 核心链路设计:从CorpID到消息送达的完整路径

写第一行代码之前,先把企业号的调用模型理清楚。这部分不涉及具体语法,但搞懂它,后面看代码就不会绕弯。

2.1 三个关键参数:CorpID、Secret与AgentID

企业号主动推送的三要素。CorpID是企业在企业微信体系里的唯一编号,相当于企业身份证,所有接口请求都要携带。Secret是自建应用的密钥,创建应用时后台生成,它的作用是配合CorpID去换取access_token,也就是“用身份证明加钥匙换一张两小时有效的临时通行证”。AgentID是应用ID,发送消息时必须指定——消息由哪个应用发出、能发给哪些人,都受这个参数约束。

新手最容易把Secret和AgentID搞混。Secret是一长串随机字符,AgentID是一个整数,两者在应用详情页的不同位置展示。我见过有人把AgentID填进Secret字段,结果gettoken接口直接返回40001,排查了半天才反应过来是配置串了。这三个配置项建议集中放到一个静态类里管理,这个工程里的QyConfig就是这个作用:

namespace WeiXin.QiYe.Config { public static class QyConfig { /// <summary>企业ID,在管理后台“我的企业”页面复制</summary> public static string CorpId = "ww1234567890abcdef"; /// <summary>应用密钥,在应用详情页查看</summary> public static string Secret = "把这里换成你自己的Secret"; /// <summary>应用ID,发送消息时必填</summary> public static int AgentId = 1000002; } }

这段代码的价值在于收敛配置。生产环境不要像这样明文写死Secret,常见做法是放到环境变量或配置中心里,代码里只保留非敏感字段;测试阶段直接改这四个值就行。后面的所有类都会引用这个配置类,保证一处修改、处处生效。

2.2 一次主动推送的三步链路:换Token、拼JSON、Post请求

主动推送的完整链路只有三步,和普通REST接口调用没有本质区别。

第一步,用CorpID加Secret请求gettoken接口,换取access_token:

GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=CORPID&corpsecret=SECRET

接口返回的JSON包含access_token和expires_in,expires_in固定为7200秒,也就是两小时。第二步,按照接口文档组装消息体,明确指定接收人、消息类型和应用ID:

{ "touser": "user01", "msgtype": "text", "agentid": 1000002, "text": { "content": "这是一条来自C#工程的主动推送" }, "safe": 0 }

第三步,把组装好的JSON作为请求体,POST到message/send接口,access_token放在URL查询参数里:

POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN

返回体同样是JSON,errcode为0表示企业号服务器受理了这次推送,非0则说明参数或权限有问题。注意一个概念:发送接口返回成功代表“受理成功”,不代表“手机已收到”。真正的送达还受可见范围、客户端状态等因素影响,这两者的差别第5章会详细说。

2.3 工程目录结构:类库加Demo的分层方式

拆开压缩包,解决方案分为核心类库和演示工程两层。类库封装所有企业号API细节,Demo提供最小可运行示例,这种结构方便直接引用类库到自己的项目中,也方便先用Demo验证配置:

WeiXin.QiYe.sln ├── WeiXin.QiYe 核心类库 │ ├── Config │ │ └── QyConfig.cs 企业ID、Secret、AgentID配置 │ ├── Core │ │ ├── HttpHelper.cs 基于HttpClient的GET/POST封装 │ │ └── TokenManager.cs access_token获取、缓存与刷新 │ ├── Message │ │ ├── TextMessage.cs 文本消息构造 │ │ ├── TextCardMessage.cs 文本卡片消息构造 │ │ └── MarkdownMessage.cs Markdown消息构造 │ ├── Send │ │ ├── MessageSender.cs 消息发送统一入口 │ │ └── SendResult.cs 返回结果解析与状态判断 │ └── WeiXin.QiYe.csproj ├── WeiXin.QiYe.Demo 控制台演示工程 │ └── Program.cs 最小闭环:发送一条文本消息 └── README.md 配置步骤与接口说明

类库把“HTTP通信”“Token管理”“消息构造”“结果解析”四个职责拆成了独立文件。只想用文本消息时,依赖Send和Core两层即可;要扩展新消息类型,在Message目录里新增构造类就行,发送主逻辑不用动。Demo工程是整个接入过程的试验田——先在控制台把链路跑通,确认配置无误,再迁移到WebApi或Windows服务里,会省掉很多联调噪音。

2.4 消息类型选型:文本、文本卡片与Markdown怎么选

工程覆盖了三种最常用的消息类型。纯文本消息适合“构建成功”“定时任务完成”这类一句话通知;文本卡片消息带标题、摘要和跳转按钮,适合审批结果、工单状态这类需要点击查看详情的场景;Markdown消息支持标题、引用、颜色标注等轻量排版,适合运维告警、日报汇总。三种类型在代码层面的差别只是消息体字段不同,但用户体验差异明显,选型时优先考虑接收方“看完之后要不要做下一步动作”,要动作就用卡片,纯提醒用文本就行。

3. 关键代码实现:Token自动刷新、消息构造与发送封装

这一章直接拆核心代码,讲解每个关键类的设计意图和参数含义。完整源码在RAR包里,这里只挑最值得讲的部分。

3.1 TokenManager:两小时有效期下的本地缓存与并发控制

access_token的有效期只有两小时,每次发送都重新获取一次不仅浪费网络开销,还可能触发企业号的频率限制。工程的做法是把Token缓存在内存里,过期前复用,失效后自动重新获取:

using System; using System.Text.Json; using WeiXin.QiYe.Config; namespace WeiXin.QiYe.Core { public class TokenManager { private static readonly object Locker = new object(); private static string _token = string.Empty; private static DateTime _expireTime = DateTime.MinValue; public static string GetAccessToken() { lock (Locker) { // 提前5分钟过期,避免Token在边界失效 if (!string.IsNullOrEmpty(_token) && _expireTime > DateTime.Now.AddMinutes(5)) { return _token; } string url = string.Format( "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={0}&corpsecret={1}", QyConfig.CorpId, QyConfig.Secret); string json = HttpHelper.Get(url); using (JsonDocument doc = JsonDocument.Parse(json)) { JsonElement root = doc.RootElement; if (root.GetProperty("errcode").GetInt32() == 0) { _token = root.GetProperty("access_token").GetString(); int expiresIn = root.GetProperty("expires_in").GetInt32(); _expireTime = DateTime.Now.AddSeconds(expiresIn); } else { throw new Exception("获取access_token失败:" + root.GetProperty("errmsg").GetString()); } } return _token; } } } }

三个设计点值得关注。第一,过期判断留了5分钟余量,_expireTime > DateTime.Now.AddMinutes(5),宁可提前刷新也不在临界点用旧Token。第二,整个方法用lock包住,防止多线程同时请求gettoken——企业号接口单位时间内有调用次数限制,锁住之后只有一个线程去换新Token,其他线程直接复用缓存值。第三,HttpHelper.Get是工程里对HttpClient的薄封装,替换成RestSharp或原生HttpWebRequest都不影响上层逻辑。

这套单机静态缓存的方案在绝大多数企业内部应用里够用。如果系统是多实例部署,本地缓存会出现“各持一个Token”的情况,常见做法是引入Redis缓存Token并设置过期时间,或者用一个独立服务统一管理Token,所有实例都从公共存储取。RAR包内是单机版本,二次开发时可以直接按第6章的改造思路扩展。

3.2 MessageSender:消息发送的统一入口与touser参数细节

发送模块是对外开放的核心接口,设计上让调用方只关心“发给谁、发什么、用哪个应用”,把JSON序列化、URL拼接、返回解析全部收敛在内部:

using System; using System.Text.Json; using WeiXin.QiYe.Core; namespace WeiXin.QiYe.Send { public class MessageSender { public static SendResult SendText(string toUser, int agentId, string content) { var request = new { touser = toUser, msgtype = "text", agentid = agentId, text = new { content = content }, safe = 0 }; string payload = JsonSerializer.Serialize(request); string url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + TokenManager.GetAccessToken(); string response = HttpHelper.Post(url, payload); return SendResult.Parse(response); } } }

touser字段支持三种写法:传单个成员登录账号,比如user01;传多个成员用竖线分隔,比如user01|user02;如果要按部门发送,用toparty字段传部门ID。touser和toparty都不传时,消息会发送给应用可见范围内的全部成员——这个兜底行为容易造成误会,第5章会展开讲。safe字段表示消息是否保密,0为普通消息可转发可截屏,1为保密消息客户端禁止转发和截屏。

这个SendText方法看起来简单,但已经把Token缓存、序列化、异常返回解析全部串起来了。调用方拿到SendResult后只要判断IsOk属性就能知道推送是否被企业号受理。

3.3 SendResult:返回码解析与invaliduser的二次处理

企业号发送接口的返回体结构固定,除errcode和errmsg之外,还经常带invaliduser、invalidparty这类字段。把它们解析成强类型对象,业务代码才能判断“这次推送是否全员成功”:

using System.Text.Json; namespace WeiXin.QiYe.Send { public class SendResult { public int ErrCode { get; set; } public string ErrMsg { get; set; } public string InvalidUser { get; set; } public bool IsOk => ErrCode == 0; public static SendResult Parse(string json) { using (JsonDocument doc = JsonDocument.Parse(json)) { JsonElement root = doc.RootElement; var result = new SendResult(); if (root.TryGetProperty("errcode", out JsonElement code)) result.ErrCode = code.GetInt32(); if (root.TryGetProperty("errmsg", out JsonElement msg)) result.ErrMsg = msg.GetString(); if (root.TryGetProperty("invaliduser", out JsonElement user)) result.InvalidUser = user.GetString(); return result; } } } }

最容易被忽略的是invaliduser字段。errcode等于0只代表企业号受理了推送,不代表所有目标都收到。返回体里出现invaliduser时,说明部分用户ID不存在或不在可见范围内。正确做法是发送前先过滤目标用户,发送后检查InvalidUser,把失败的用户ID记录下来剔除或重发。企业号不像普通Web API那样有“全量送达确认”,只能靠这种半结果字段做补偿处理。

3.4 TextCard与Markdown消息构造:从纯文本升级为可交互通知

审批类场景适合用文本卡片,点击卡片能直接跳转业务详情页,比纯文本多一步交互。构造代码如下:

namespace WeiXin.QiYe.Message { public class TextCardMessage { public static object Build(string toUser, int agentId, string title, string description, string url) { return new { touser = toUser, msgtype = "textcard", agentid = agentId, textcard = new { title = title, description = description, url = url, btntxt = "查看详情" } }; } } }

这里唯一的坑是url字段。textcard的跳转地址必须使用企业微信后台配置过的可信域名,否则员工在客户端点击时看到的是风险提示页面,通知体验直接大打折扣。开发阶段建议先指向可信域名下的临时页面,上线前再换真实业务地址。

Markdown消息的构造更简单,但内容语法有专门限制:

namespace WeiXin.QiYe.Message { public class MarkdownMessage { public static object Build(string toUser, int agentId, string content) { return new { touser = toUser, msgtype = "markdown", agentid = agentId, markdown = new { content = content } }; } } }

Markdown内容支持企业微信专用语法,比如<font color="warning">给文字标色、>引用块、##标题。这些语法只在企业微信手机客户端内完整渲染,网页版和PC管理端不保证兼容。如果推送对象经常在网页版收消息,建议优先用纯文本或文本卡片。

4. 跑起来之前的必修课:企业号后台配置与联调环境搭建

代码逻辑清楚了,还要让消息真正送达手机。这一章从环境准备开始,按顺序走到最小闭环验证。

4.1 环境准备:目标框架、依赖与编译方式

工程基于标准类库写的C#代码,用的都是System.Net.Http、System.Text.Json这些BCL能力,没引入第三方依赖。目标框架覆盖.NET Framework 4.5和.NET Core/.NET 5以上版本,本机装的是.NET 6 SDK时直接编译:

dotnet build WeiXin.QiYe.sln

编译通过后,需要一个能访问公网的运行环境。跑控制台Demo不依赖任何服务,只要本机网络能访问qyapi.weixin.qq.com就行;接入WebApi时再考虑部署到内网服务器,同时确保出口IP能被企业号后台的白名单识别。

4.2 企业号后台配置三步走:创建应用、复制参数、设置可见范围

后台配置是整个联调最容易翻车的环节,步骤不多,但每个参数都要对应准确。

第一步,管理员登录企业微信管理后台,在“应用管理”中创建自建应用,填写应用名称和Logo后进入详情页,页面上直接展示AgentID和Secret,复制到QyConfig对应字段。第二步,在应用详情页的“企业可信IP”配置中,填入运行Demo的服务器公网IP。这一步经常被跳过,结果一调接口就报60020错误,详见第5章。第三步,设置应用可见范围——新建应用默认可见范围是空的,如果touser指向的成员不在范围内,接口返回errcode=0但消息并不会发出。

三个参数和后台位置的对照关系,直接看这张表:

参数值示例获取位置关键说明
CorpIdww1234567890abcdef管理后台-我的企业企业唯一ID,ww开头
Secret随机字符串应用详情-Secret换access_token的密钥
AgentId1000002应用详情-基本信息发送消息必须指定
可信IP203.0.113.10应用详情-企业可信IP不填会报60020

表格里每一项都是联调时真正会用到的。CorpId和Secret配错是最低级的错误,但也最容易发生,复制前先看字符长度,CorpId以ww开头,Secret通常是一段较长的混合字符串,格式不对基本就是复制错了。

4.3 控制台Demo:一个命令看到推送效果

后台配置完成后,先跑Demo工程。它的逻辑很简单:取一次Token,解析参数,发送一条文本消息,然后报告结果:

using System; using WeiXin.QiYe.Config; using WeiXin.QiYe.Send; namespace WeiXin.QiYe.Demo { class Program { static void Main(string[] args) { if (args.Length == 0) { Console.WriteLine("用法: Demo <要发送的文本>"); return; } SendResult result = MessageSender.SendText( "user01", QyConfig.AgentId, args[0]); if (result.IsOk) { Console.WriteLine("推送成功,请在手机企业微信中查看"); } else { Console.WriteLine("推送失败:errcode=" + result.ErrCode + ", errmsg=" + result.ErrMsg); } } } }

这里的touser写死了user01,测试前需要替换成你企业号后台可见范围内的成员登录账号。成员账号在管理后台“通讯录”里能看到,不是手机号也不是姓名,是账号ID那一列。跑起来后如果控制台输出“推送成功”,立刻看手机企业微信里有没有这条消息;没看到就跳去第5章查可见范围的问题。

4.4 接入WebApi:把推送能力封装成HTTP接口

Demo验证通过后,下一步是把推送能力嵌进业务系统。最常见的方式是包一个WebApi接口,让其他子系统通过HTTP调用,而不是直接引用类库:

namespace WeiXin.QiYe.WebApi.Controllers { [ApiController] [Route("api/[controller]")] public class PushController : ControllerBase { [HttpPost("send")] public IActionResult Send([FromBody] PushRequest req) { SendResult result = MessageSender.SendText( req.ToUser, QyConfig.AgentId, req.Content); if (result.IsOk) { return Ok(new { success = true }); } return BadRequest(new { success = false, error = result.ErrMsg }); } } public class PushRequest { public string ToUser { get; set; } public string Content { get; set; } } }

注意这里面有个性能隐患:控制器里直接调用同步的MessageSender,网络IO会占住线程池线程。建议改造时给MessageSender增加SendTextAsync异步版本,或者把推送任务丢进后台队列,接口先返回200,再异步完成发送。生产环境我一般选后者——调用方不感知推送延迟,企业号接口响应慢也不会拖垮业务接口的吞吐。

5. 避坑笔记:企业号推送联调中踩过的5个真实问题

这一章是实际操作里反复试错攒下的记录。每条按现象、原因、解决三个步骤写,遇到同类问题可以直接照方抓药。

先给一个通用排查技巧:遇到报错别急着改代码,先用一条命令验证基础配置是否正确:

curl "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=你的CorpId&corpsecret=你的Secret"

把CorpID和Secret替换成后台真实值,看返回的JSON里是access_token还是errcode。这一步能快速区分“配置错了”和“代码错了”,节省大量排查时间。

5.1 报错类:errcode非0时的三种典型情况

第一条,gettoken接口返回40013。

现象:用配置好的CorpID和Secret调用gettoken,返回errcode: 40013, errmsg: invalid corpid。原因:CorpID填成了企业简称、企业邮箱或者其他自定义字符串,而不是后台“我的企业”里的ww开头企业ID。解决:点开管理后台“我的企业”,复制真正的企业ID替换配置,重启进程重新获取Token。

第二条,send接口返回40001或42001。

现象:Token能正常获取,但发送消息时报invalid credential。原因:access_token在缓存中过期,或者多实例同时刷新Token导致其中一个被覆盖,也有可能Secret在后台被重置但代码还是旧值。解决:先检查是否有多实例各自缓存Token的情况,统一走TokenManager的静态缓存;再清掉本地缓存强制刷新一次,排除Secret被改动的可能。如果常驻进程长时间运行,建议加一个定时任务在Token过期前主动刷新。

第三条,接口返回60020。

现象:测试环境推送一切正常,部署到生产服务器后调用message/send接口返回not allow to access from your ip。原因:企业可信IP白名单里只配置了测试环境的地址,生产服务器的出口IP没有加入。解决:把当前运行环境的公网IP添加到应用详情页的“企业可信IP”中。这条我翻车过两次,一次是测试机到生产机迁移时忘了同步白名单,另一次是服务器换出口IP后白名单没更新,排查了半个多小时才反应过来是网络侧的问题。

5.2 送达类:errcode为0但消息没到手机怎么办

第四条,接口返回成功,手机却没收到。

现象:SendResult.IsOk为true,但目标成员手机的企业微信里找不到这条消息。原因:目标成员不在应用可见范围内,企业号会静默丢弃这部分消息;另一种可能是touser填错了,比如传了成员的姓名而不是登录账号。解决:回到应用详情页确认可见范围已包含该成员,再核对通讯录里的账号ID。注意修改可见范围后需要等待一两分钟生效,别刚改完就急着重测。

第五条,文本卡片跳转页面提示域名风险。

现象:员工收到textcard消息,点击“查看详情”后浏览器弹出风险提示,页面无法正常打开。原因:textcard的url字段使用的是未配置可信域名的外网地址,企业微信客户端无法确认页面安全性。解决:先把业务页面的域名加入应用的“可信域名”配置,按提示将校验文件放到域名根目录完成验证,再把textcard的url替换成验证通过的地址。这条的经验是:开发阶段的域名不要频繁变动,提前把测试域名也配进白名单,省得每次联调都在域名校验上耗时间。

这5条是我在这个项目上实实在在交过的学费。第4章和第5章配合起来看,先按规范配置,再对照排查,大多数问题半小时内能定位。

6. 二次开发:把纯发送模块改造成你的内部消息中心

基础发送跑通之后,这个模块的价值只发挥了一半。这一章给两个能直接落地的改造方向。

6.1 用接口统一消息类型,消除重复的发送方法

现在的MessageSender对文本、文本卡片、Markdown各提供一个方法,调用方不用关心细节,但方法内部逻辑高度相似。继续加消息类型的话,方法会越来越多。一个建议:抽象出IMessage接口,让每种消息自己负责JSON构造:

namespace WeiXin.QiYe.Message { public interface IMessage { string MsgType { get; } object BuildBody(); } public class TextMessage : IMessage { public string Content { get; set; } public string MsgType => "text"; public object BuildBody() => new { text = new { content = Content } }; } }

改造后MessageSender只保留一个入口方法,公共链路——取Token、拼URL、发请求、解析结果——收在模板方法里,新增消息类型时只加新类。业务侧调用从“知道该调哪个方法”变成“传入一个消息对象”,代码量明显下降,也不容易出现复制粘贴改字段的错误。

6.2 加一层重试与补偿队列,从“能推送”升级为“可靠推送”

企业号消息接口偶尔会因为网络抖动、频率限制返回失败,对通知类场景不能接受“失败就丢了”。常见的做法是在MessageSender外层包一个带重试的策略:

namespace WeiXin.QiYe.Send { public class MessageRetry { public static SendResult SendWithRetry( string toUser, int agentId, string content, int maxRetry = 3) { int retry = 0; while (retry < maxRetry) { SendResult result = MessageSender.SendText(toUser, agentId, content); if (result.IsOk) { return result; } if (result.ErrCode == 40014 || result.ErrCode == 42001) { TokenManager.ForceRefresh(); } retry++; Thread.Sleep(500 * retry); } return null; } } }

重试3次仍失败后,把消息落库,稍后由定时任务补偿发送。配合一个简单的消息表和一个后台线程,这个模块就从“调用接口发消息”升级成了“保证消息最终送达”的推送中心。生产环境里的通知链路,拼到最后拼的不是发送速度,而是重试和补偿的可靠性。

我的习惯是每接一个新的企业号应用,都先把后台配置、Token缓存、最小闭环发送这条路完整走一遍再写业务代码。RAR包里的类库和Demo正好按这个顺序组织,后来者照着README改几个参数就能把推送接起来。这套标准流程已经帮我省了好多次排障,希望帮到你。

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

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

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

立即咨询