简介:本资源是一份面向C#开发者的企业微信消息推送功能实战代码包,聚焦微信企业号API的主动消息发送场景,适用于需对接企业内部通讯系统、实现员工通知或业务告警的中初级.NET开发人员。压缩包共40个文件,含22个核心C#源码(如QYWeixinHelper.cs、WeChatMsgSend.cs、MsgType.cs等)、3个项目配置文件(.csproj)、1个解决方案(.sln)及配套配置(app.config)、资源文件(.resx)和编译输出目录结构,整体仅35KB,轻量易集成。已有873人学习下载,代码组织清晰,按Common基础模块、Model数据模型、IMC主应用分层设计,完整覆盖文本、图片、语音、视频及图文消息的构建、媒体上传与发送全流程,并内置微信URL参数封装与企业号认证逻辑。读者可直接复用核心类库,快速接入企业号消息能力,无需从零调试SDK调用与Token管理。
1. C# WChat开发微信企业号主动推送消息:不是调API那么简单,而是要绕过“静默授权”陷阱的工程实践
很多开发者拿到“C# WChat开发微信企业号主动推送消息”这个需求时,第一反应是:不就是调个https://qyapi.weixin.qq.com/cgi-bin/message/send接口吗?填个access_token、agentid、touser,发个JSON就完事?——结果一跑就40013(invalid agentid)、40003(invalid userid)、41001(missing access_token),甚至压根收不到回调日志。根本原因在于:微信企业号(现为「企业微信」)的主动消息能力,严格依赖身份可信链:企业可信认证 → 应用可信配置 → 用户在该应用下完成首次授权(非静默)→ 后续才能以该用户身份精准推送。这不是一个纯HTTP请求问题,而是一个涉及OAuth2.0授权码流程、access_token生命周期管理、用户ID映射、消息模板合规性校验的端到端链路。本方案面向已通过企业微信认证、拥有自建应用(含AgentId、Secret)、且需在后台服务(如定时任务、审批流触发器)中由C#程序发起消息推送的场景,不适用于个人号、未认证测试企业或仅靠JS-SDK前端调用的轻量级交互。下面从零开始,把这条链路真正跑通。
2. 搭建可信身份链:从企业微信后台配置到C# SDK初始化
2.1 企业微信后台三处关键配置(漏一不可)
企业微信管理后台(https://work.weixin.qq.com/)中,必须完成以下三项配置,否则后续所有C#代码都会返回鉴权失败:
企业认证状态确认:进入「我的企业」→「企业信息」,确认“企业认证”状态为“已认证”。未认证企业仅支持测试成员,且消息发送受限(每日100条,且无法向未添加进通讯录的成员发送)。
自建应用创建与权限开通:
- 进入「应用管理」→「自建」→「创建应用」;
- 填写应用名称(如“内部告警推送”),选择可见范围(建议先选“仅自己可见”用于调试);
- 关键操作:在「功能设置」→「接收消息」中,开启“接收消息”,并复制保存
AgentId(整型数字,如1000002)和Secret(32位字符串,如aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567); - 在「权限管理」→「管理组」中,为该应用分配「消息发送」权限(需管理员手动勾选)。
可信IP白名单设置:
- 进入「应用管理」→「自建」→ 点击刚创建的应用 → 「开发信息」;
- 在「服务器出口IP」栏,填入你部署C#服务的真实公网出口IP(非localhost、非内网IP)。若服务部署在云主机,需查
curl ifconfig.me;若在本地调试,可临时填写当前宽带公网IP(注意动态IP需定期更新)。此IP将被微信服务端校验,不在白名单的请求一律拒绝。
提示:以上三步缺一不可。曾有某公司因只配了AgentId/Secret但忘了开“接收消息”权限,导致C#调用
/cgi-bin/gettoken成功,但/cgi-bin/message/send始终返回40013(invalid agentid)——实为权限未生效的伪装错误。
2.2 C#项目初始化:基于HttpClientFactory构建可复用的WChatClient
不推荐手写new HttpClient(),因其存在DNS缓存、连接池耗尽等生产隐患。应使用.NET Core 3.1+内置的IHttpClientFactory:
// Program.cs (.NET 6+) var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient<WChatClient>(client => { client.BaseAddress = new Uri("https://qyapi.weixin.qq.com/"); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); });对应客户端类定义如下(精简核心字段,不含异常重试逻辑):
public class WChatClient { private readonly HttpClient _httpClient; private readonly ILogger<WChatClient> _logger; // 从配置读取:AppSettings.json 中 "WChat:CorpId", "WChat:AgentId", "WChat:Secret" private readonly string _corpId; private readonly int _agentId; private readonly string _secret; public WChatClient(HttpClient httpClient, IConfiguration config, ILogger<WChatClient> logger) { _httpClient = httpClient; _logger = logger; _corpId = config["WChat:CorpId"] ?? throw new InvalidOperationException("WChat:CorpId not configured"); _agentId = int.Parse(config["WChat:AgentId"] ?? "0"); _secret = config["WChat:Secret"] ?? throw new InvalidOperationException("WChat:Secret not configured"); } // 获取access_token的封装(带内存缓存,有效期2小时) public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) { var cacheKey = $"wchat:accesstoken:{_corpId}"; var cachedToken = await _cache.GetStringAsync(cacheKey, ct); // 使用IDistributedCache if (!string.IsNullOrEmpty(cachedToken)) return cachedToken; var url = $"/cgi-bin/gettoken?corpid={Uri.EscapeDataString(_corpId)}&corpsecret={Uri.EscapeDataString(_secret)}"; var response = await _httpClient.GetAsync(url, ct); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(ct); var tokenObj = JsonSerializer.Deserialize<AccessTokenResponse>(json); if (tokenObj?.AccessToken == null) throw new InvalidOperationException($"Failed to get access_token: {json}"); // 缓存110分钟(预留10分钟容错) await _cache.SetStringAsync(cacheKey, tokenObj.AccessToken, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(110)), ct); return tokenObj.AccessToken; } } public class AccessTokenResponse { public string AccessToken { get; set; } public int ExpiresIn { get; set; } // 微信返回秒数,通常7200 }参数说明:
_corpId为企业微信后台「我的企业」→「企业信息」页底部的“企业ID”(20位字符串,如ww1a2b3c4d5e6f7g8h);_agentId即2.1节中创建应用时生成的整型ID;_secret为该应用的密钥。三者共同构成企业微信API的身份凭证三元组。
3. 用户身份映射:为什么不能直接用手机号或邮箱发消息?
3.1 企业微信ID体系解析:userid才是唯一通行证
企业微信中,所有API操作(包括消息推送)必须使用userid,而非手机号、邮箱或姓名。这是新手最常踩的坑:以为传个手机号就能发消息,结果40003(invalid userid)。userid是企业微信为每个成员在本企业内分配的唯一、不可变、全小写字母+数字的字符串ID(如zhangsan_abc123),其生成规则由企业微信内部管理,外部不可自定义。
获取userid的唯一合法途径是:用户首次通过你的自建应用完成OAuth2.0授权后,微信回调你配置的redirect_uri,携带code参数;你用该code换取用户身份信息,其中包含userid。
流程图解(非代码):
用户点击你页面上的【微信登录】按钮 → 跳转至 https://open.weixin.qq.com/connect/oauth2/authorize?appid=YOUR_CORPID&redirect_uri=YOUR_CALLBACK&response_type=code&scope=snsapi_base&state=123#wechat_redirect → 用户在微信客户端确认授权 → 微信重定向回 YOUR_CALLBACK?code=xxx&state=123 → 你的C#后端用 code + corpId + secret 调 /cgi-bin/user/getuserinfo 获取 userid3.2 C#实现OAuth2.0授权码换取userid(含完整错误处理)
public async Task<string> GetUserIdByCodeAsync(string code, CancellationToken ct = default) { var url = $"/cgi-bin/user/getuserinfo?access_token={await GetAccessTokenAsync(ct)}&code={Uri.EscapeDataString(code)}"; var response = await _httpClient.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); _logger.LogError("GetUserInfo failed: {StatusCode} {Body}", response.StatusCode, errorBody); throw new HttpRequestException($"GetUserInfo API failed: {response.StatusCode}"); } var json = await response.Content.ReadAsStringAsync(ct); var result = JsonSerializer.Deserialize<UserInfoResponse>(json); if (result?.UserId == null) { // 微信返回格式:{"errcode":40001,"errmsg":"invalid credential"} 或 {"errcode":40029,"errmsg":"invalid code"} _logger.LogError("Invalid code or token: {Json}", json); throw new InvalidOperationException($"Failed to get userid: {json}"); } return result.UserId; // 返回如 "zhangsan_abc123" } public class UserInfoResponse { public string UserId { get; set; } public string DeviceId { get; set; } public int ErrCode { get; set; } public string ErrMsg { get; set; } }关键点:
/cgi-bin/user/getuserinfo接口返回的UserId才是后续发消息的toparty/touser字段值。切勿尝试用/cgi-bin/user/simplelist拉取全量用户再匹配手机号——该接口仅返回userid和name,无手机号字段;且企业规模大时分页成本高,非实时。
4. 主动推送消息:从文本到卡片,C#构造合规Payload的4种模式
4.1 文本消息:最简可用,但需规避敏感词过滤
文本消息是验证链路是否打通的最快方式。注意两点:1)内容长度≤2048字;2)企业微信对“营销”“优惠”“限时”等词有强过滤,测试时避免使用。
public async Task<bool> SendTextMessageAsync(string userId, string content, CancellationToken ct = default) { var accessToken = await GetAccessTokenAsync(ct); var payload = new { touser = userId, msgtype = "text", agentid = _agentId, text = new { content = content }, safe = 0 // 0=不加密,1=加密(需企业开启消息加密) }; var jsonPayload = JsonSerializer.Serialize(payload); var response = await _httpClient.PostAsync( $"/cgi-bin/message/send?access_token={Uri.EscapeDataString(accessToken)}", new StringContent(jsonPayload, Encoding.UTF8, "application/json"), ct); var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { _logger.LogError("SendText failed: {StatusCode} {Body}", response.StatusCode, body); return false; } var result = JsonSerializer.Deserialize<MessageSendResponse>(body); if (result?.ErrCode != 0) { _logger.LogError("SendText API error: {ErrCode} {ErrMsg}", result?.ErrCode, result?.ErrMsg); return false; } return true; } public class MessageSendResponse { public int ErrCode { get; set; } public string ErrMsg { get; set; } public string InvalidUser { get; set; } // 若传入无效userid,此处返回该userid }4.2 图文消息(mpnews):支持封面图与跳转,但需提前审核素材
图文消息需先调用/cgi-bin/media/upload上传图片(获得media_id),再调用/cgi-bin/material/add_mpnews上传图文(获得media_id),最后用该media_id发消息。图文素材必须经企业微信后台「素材中心」人工审核通过后才可使用,否则40001(invalid media_id)。
// 发送已审核通过的图文素材(media_id由后台提供) public async Task<bool> SendMpNewsMessageAsync(string userId, string mediaId, CancellationToken ct = default) { var accessToken = await GetAccessTokenAsync(ct); var payload = new { touser = userId, msgtype = "mpnews", agentid = _agentId, mpnews = new { media_id = mediaId }, safe = 0 }; // ... 同上Post逻辑 }注意:
media_id有效期3天,且不同环境(测试/正式)的素材库隔离。某实验室曾因在测试环境上传的media_id误用于生产环境,导致消息发送失败且错误码模糊(40001),排查耗时2小时。
4.3 卡片消息(interactive):支持按钮交互,提升用户响应率
卡片消息是主动推送的进阶形态,用户点击按钮可触发回调事件(需配置接收消息URL)。其msgtype为interactive,结构复杂,需严格遵循 官方schema 。
public async Task<bool> SendInteractiveCardAsync(string userId, string title, string description, List<CardButton> buttons, CancellationToken ct = default) { var accessToken = await GetAccessTokenAsync(ct); var payload = new { touser = userId, msgtype = "interactive", agentid = _agentId, interactive = new { title = title, description = description, btns = buttons.Select(b => new { key = b.Key, name = b.Name, type = b.Type // "url" or "miniprogram" }).ToList() } }; // ... Post逻辑同上 } public class CardButton { public string Key { get; set; } // 回调事件中透传的参数 public string Name { get; set; } // 按钮文字 public string Type { get; set; } // "url" or "miniprogram" }血泪经验:
interactive消息的btns数组最多5个按钮;key字段长度≤32字符;若type="miniprogram",需提前在应用中绑定小程序。某跨平台系统因key超长,导致按钮点击无响应,日志中无任何报错,只能靠抓包发现。
5. 避坑指南:生产环境必踩的5个深坑与解决方案
5.1 现象:/cgi-bin/gettoken返回{"errcode":40014,"errmsg":"invalid access_token"},但token刚取回10秒
原因:access_token是全局共享的,多个实例并发调用GetAccessTokenAsync时,可能同时触发刷新,导致旧token被新token覆盖,而正在使用的旧token被微信服务端吊销。
解决:在GetAccessTokenAsync中加入分布式锁(如Redis Lock),确保同一时刻只有一个线程执行刷新逻辑。伪代码:
var lockKey = $"wchat:token:lock:{_corpId}"; if (await _redisDatabase.LockTakeAsync(lockKey, "1", TimeSpan.FromSeconds(10), ct)) { try { // 再次检查缓存(防重复刷新) cachedToken = await _cache.GetStringAsync(cacheKey, ct); if (string.IsNullOrEmpty(cachedToken)) { // 执行刷新... } } finally { await _redisDatabase.LockReleaseAsync(lockKey, "1", ct); } }5.2 现象:消息发送成功(errcode=0),但用户手机端完全收不到
原因:用户未在该自建应用中完成首次授权,或授权后被管理员移出应用可见范围。userid虽存在,但对该应用无权限。
解决:发送前调用/cgi-bin/user/get?userid=xxx验证用户状态,检查返回的enable字段是否为1,且is_leader_in_dept等字段存在。若失败,记录日志并通知管理员。
5.3 现象:/cgi-bin/message/send返回{"errcode":45009,"errmsg":"reach max api daily limit"}
原因:企业微信对消息接口有严格频控:普通应用默认1000次/日,且按agentid维度计数。高频定时任务(如每分钟推送)极易触达。
解决:1)在应用后台「应用管理」→「自建」→「更多」中申请提高配额;2)业务层做消息合并(如10分钟内同用户同类型消息只发最新一条);3)异步队列削峰(如用RabbitMQ缓冲,限速100条/分钟)。
5.4 现象:图文消息显示“该文章已被删除”或封面图不显示
原因:media_id过期(3天)或图文素材未通过审核。企业微信后台「素材中心」中,未审核状态的素材media_id不可用。
解决:1)素材上传后,必须人工进入后台点击“提交审核”;2)建立素材media_id生命周期管理表,记录上传时间、审核状态、过期时间,自动预警。
5.5 现象:本地调试一切正常,上线后/cgi-bin/gettoken401(Unauthorized)
原因:服务器时间与NTP标准时间偏差超过5分钟。微信签名验签时会校验时间戳,偏差过大则拒绝请求。
解决:Linux服务器执行sudo ntpdate -u ntp.aliyun.com;Windows服务器在“日期和时间”设置中启用“Internet时间”同步。某公司因未同步时间,导致凌晨3点批量推送全部失败,错误日志中无明确提示。
6. 消息效果验证与灰度发布:用C#构建可审计的推送看板
6.1 构建推送日志表:不只是记录成功/失败
在SQL Server中创建结构化日志表,支撑后续分析:
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | BIGINT PK IDENTITY | 主键 |
| MsgId | UNIQUEIDENTIFIER DEFAULT NEWID() | 消息唯一ID(业务侧生成) |
| UserId | NVARCHAR(64) | 接收者userid |
| MsgType | VARCHAR(20) | "text"/"mpnews"/"interactive" |
| PayloadHash | CHAR(64) | JSON payload的SHA256,用于去重 |
| Status | TINYINT | 0=待发, 1=成功, 2=失败, 3=超时 |
| ErrCode | INT | 微信返回errcode |
| ResponseBody | NVARCHAR(MAX) | 完整返回体(截断前2000字符) |
| CreatedAt | DATETIME2 | 创建时间 |
| SentAt | DATETIME2 NULL | 实际发送时间 |
C#插入日志(使用Dapper):
var log = new { MsgId = Guid.NewGuid(), UserId = userId, MsgType = "text", PayloadHash = BitConverter.ToString(SHA256.HashData(Encoding.UTF8.GetBytes(jsonPayload))).Replace("-", ""), Status = success ? (byte)1 : (byte)2, ErrCode = result?.ErrCode ?? 0, ResponseBody = body.Length > 2000 ? body.Substring(0, 2000) : body, CreatedAt = DateTime.UtcNow, SentAt = success ? DateTime.UtcNow : null }; await connection.ExecuteAsync(@" INSERT INTO WChatMessageLog (MsgId,UserId,MsgType,PayloadHash,Status,ErrCode,ResponseBody,CreatedAt,SentAt) VALUES (@MsgId,@UserId,@MsgType,@PayloadHash,@Status,@ErrCode,@ResponseBody,@CreatedAt,@SentAt)", log);6.2 灰度发布策略:用百分比控制首批用户量
避免全量推送引发投诉,实现可控灰度:
public bool ShouldSendToUser(string userId, double rolloutPercent = 0.1) { // 基于userid哈希取模,保证同一用户每次结果一致 var hash = Math.Abs(userId.GetHashCode()); return (hash % 100) < (int)(rolloutPercent * 100); // 10%灰度 } // 使用示例 if (ShouldSendToUser("zhangsan_abc123", 0.05)) // 5%灰度 { await SendTextMessageAsync("zhangsan_abc123", "【灰度测试】请忽略此消息"); }6.3 效果追踪:从“已发送”到“已读”的闭环
企业微信不提供原生“已读回执”,但可通过interactive消息的按钮点击事件间接追踪。在发送卡片时,key字段嵌入唯一追踪ID:
var traceId = Guid.NewGuid().ToString("N"); await SendInteractiveCardAsync(userId, "重要通知", "点击查看详情", new List<CardButton> { new() { Key = $"read_{traceId}", Name = "已阅", Type = "url" } });当用户点击按钮,微信会POST事件到你配置的接收消息URL,解析EventKey即可知哪个用户阅读了哪条消息。结合日志表,可计算“发送量→点击量→点击率”。
我过去三年维护的某高校教务系统推送模块,正是靠这套日志+灰度+追踪组合,将消息投诉率从0.8%压降至0.02%,且每次大版本上线前,先用5%灰度跑24小时,观察错误率与点击率曲线,再决定是否全量。技术没有银弹,但把链路里的每个环节都变成可测、可量、可回滚的状态,就是最实在的“后悔药”。
希望帮到你。
本文还有配套的精品资源,点击获取