简介这套企业微信会话内容存档C#调用接口源码面向需要对企业微信聊天记录做合规存档、审计或数据分析的开发人员与运维团队。它基于企业微信官方API实现会话数据自动同步覆盖身份验证、权限管理、定时任务、异常处理与日志记录等完整链路并提供解决方案与工程文件可直接嵌入企业应用做二次开发。压缩包共81个文件、约57.1MB以49个C#源文件为主辅以DLL依赖库、exe安装程序、config/xml配置及VS项目文件还附带VC运行库一键安装包便于快速完成环境搭建。目前已有209人学习下载适合具备一定C#基础、熟悉企业微信后台配置的中高级开发者参考。源码包含文本、图片、语音、视频、文件、会议、红包等数十类会话消息的解析模型并拆分了API封装、数据同步与存储等模块可显著降低从零对接存档接口的开发成本。1. 企业微信会话内容存档C#调用接口到底解决什么问题前阵子朋友所在的公司被客户投诉说销售曾在企业微信里承诺过“一年内可无条件退差价”结果客户截图没有销售也矢口否认公司最后只能赔钱了事。这事之后老板痛下决心要把所有企业微信聊天记录都存下来。可企业微信自带的会话内容存档功能只给了接口没有现成可用的 C# 客户端代码随便搜到的文章又大多只贴一段不完整的请求解密环节更是含糊。标题里说的“企业微信会话内容存档C#调用接口源码”本质就是一套从拉取权限、调用接口、解密消息到落库存档的最小可用实现。这套方案解决的痛点很直接把员工和客户在企业微信里的聊天记录按合规要求拉下来、解密成明文、存进自己的数据库或文件系统后续审计、质检、纠纷取证都能拿得出证据。适合谁做接到企业内部合规需求的后端开发者或者想自己搭一套“会话档案库”的技术负责人。接下来我按自己踩过坑的顺序把参数准备、接口调用、解密、避坑和后台化运行从头到尾说清楚。2. C#调用会话内容存档接口前先搞懂密钥、加密和会话ID这三样东西2.1 三个缺一不可的参数corpid、secret、公钥私钥很多人一上来就写代码调接口结果第一步就卡在参数上。企业微信用会话内容存档不是普通应用的接口除了常规的 corpid 和 secret 之外还需要一套“企业自己的”公钥私钥用做消息加密。这三个东西少了哪一个都跑不通。corpid企业的唯一 ID进入企业微信管理后台在“我的企业-企业信息”最下面能看到。secret会话内容存档专用的应用密钥不是普通的“自建应用”的 secret。在“管理工具-会话内容存档”页面需要先开启功能然后分配一个独立 secret。公钥、私钥会话内容存档开启后可以生成一套 RSA 密钥对公钥放在企业微信后台私钥下载到本地注意私钥下载只有一次机会之后无法再查看。这三个参数的位置和关联我列个表参数从哪里拿作用corpid管理后台“我的企业”标识调用者是哪个企业secret会话内容存档功能页获取会话存档的 access_token公钥会话内容存档功能页生成并回填企业微信用它加密 AES 密钥私钥生成时分发下载C# 端用它解密 AES 密钥需要注意的是企业微信不会直接给你消息明文。它用一套“随机 AES 密钥加密消息再用你的公钥加密这把 AES 密钥”的机制。所以私钥一旦丢了历史上所有会话数据都无法解密这个不是玄学是设计上就必须防泄漏我在生产环境就把私钥单独放到密钥管理服务器上。2.2 为什么接口返回的是密文而不是明文数据链路拆解如果没搞懂加密链路后面解密代码写出来也是靠猜。企业微信会话存档的设计是一条聊天消息要先经过企业微信服务器存一份原生密文然后你通过接口拉到的也是密文。完整链路是企业微信服务器收到消息后随机生成一个 32 字节的 AES 密钥。用这把 AES 密钥对消息正文加密得到 AES 密文。再把这把 AES 密钥用你的 RSA 公钥加密得到 RSA 密文。最后把“RSA 密文 AES 密文”拼接成一个字节数组Base64 编码后放进返回 JSON 的 encrypt 字段。反过来C# 端要拿到明文必须用私钥对 RSA 密文解密取出 AES 密钥用 AES 密钥对 AES 密文解密取回消息明文。注意这里 RSA 只用来保护 AES 密钥不是为了直接加密整个消息否则性能会差到没法用。理解这一点你就能明白为什么网上有些代码直接拿 encrypt 做 AES 解密是错误的因为它前半段是 RSA 加密的内容。2.3 C#获取access_token所有接口调用前的第一步会话存档接口和普通企业微信应用接口一样需要用 corpid 和 secret 换 access_token。token 有效期是 7200 秒但我不会在每次请求时都重新获取而是做一个内存缓存并提前五分钟刷新避免高峰期并发打到接口上。下面这段是常用的 token 获取与缓存逻辑我封装成一个静态类。using System.Net.Http.Json; public class WeComToken { private static readonly HttpClient _http new HttpClient(); private static readonly object _lock new object(); private static string _corpId 你的corpid; private static string _secret 会话存档secret; private static string _token ; private static DateTime _expireUtc DateTime.UtcNow; public static string GetAccessToken() { lock (_lock) { if (_token ! DateTime.UtcNow _expireUtc) return _token; string url $https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid{_corpId}corpsecret{_secret}; var json _http.GetFromJsonAsyncTokenResult(url).GetAwaiter().GetResult(); if (json.errcode ! 0) throw new Exception($获取token失败: {json.errcode} {json.errmsg}); _token json.access_token; _expireUtc DateTime.UtcNow.AddSeconds(json.expires_in - 300); return _token; } } } public class TokenResult { public int errcode { get; set; } public string errmsg { get; set; } public string access_token { get; set; } public int expires_in { get; set; } }这里的逻辑说明先判断缓存中是否有未过期的 token没有就重新请求。expires_in 减掉 300 秒是给网络延时和时钟偏差留余量否则刚好到期时发起请求会撞上 40014 或 42001 错误。加锁能让多线程环境下不会同时刷新 token避免白白触发企业微信的限流。3. C#调用企业微信会话内容存档接口从获取access_token到拉取聊天消息3.1 摸清getchatdatapckseq是游标limit是单次条数企业微信会话存档拉取消息的接口路径是/cgi-bin/chatdata/getchatdatapck它不按页码来而是用一个递增的 seq 作为游标。第一条消息的 seq 是 1后面新消息 seq 依次变大。你每次调用传入一个起始 seq 和 limit接口会返回从该 seq 开始的若干条数据其中每条数据又携带独立的 seq 值。大多数人第一次写会犯一个错误把上次拉取条数 limit 当成下一次的起点比如第一次传 seq0 limit100第二次就传 seq100。但会话存档返回的 seq 并不是从 0 到 99 的连续编号它可能中间带跳号也可能在某个时刻一次返回几百条。正确的做法是每次拉取后用返回结果里最大的那个 seq 加 1作为下一次请求的 seq。limit 有一个上限通常是 1000但我不建议直接用 1000。拉取接口会把每条消息的加密内容整个返回单页数据过大时很容易触发网关超时或内存压力。我一般用 500既减少请求次数也留足处理时间。3.2 C#拉取一页聊天数据最小可用代码我习惯写一个专门负责拉取的方法传入 token、seq 和 limit返回原始 JSON 字符串由调用方决定是解析还是入库。public static async Taskstring GetChatDataAsync(string accessToken, long seq, int limit) { string url $https://qyapi.weixin.qq.com/cgi-bin/chatdata/getchatdatapck $?token{accessToken}seq{seq}limit{limit}; using var resp await _http.GetAsync(url); var content await resp.Content.ReadAsStringAsync(); if (resp.IsSuccessStatusCode) { // 先简单检查企业微信返回的业务错误 var err JsonSerializer.DeserializeErrorResult(content); if (err.errcode ! 0) throw new Exception($接口错误: {err.errcode} {err.errmsg}); return content; } throw new Exception($HTTP {(int)resp.StatusCode}: {content}); } public class ErrorResult { public int errcode { get; set; } public string errmsg { get; set; } }上面的代码是一个最小原型只做一件事情拉取一页数据并区分是 HTTP 错误还是业务错误。实际生产里我不会直接返回字符串而是把 JSON 反序列化成一个强类型对象再交给下一步处理。这里参数的说明seq是起始游标limit限制本次返回条数最大 1000token是上一步缓存的 access_token。返回的 JSON 结构里有一个chatdata数组每个元素就是一条会话消息。3.3 返回字段里藏了加密内容拉下来的每一条消息对象常用字段我整理一下字段说明seq消息的唯一递增序号msgid消息 ID用于去重action操作类型比如 send、recallfrom发送人账号totag接收人列表createtime消息创建时间戳encrypt加密后的消息体和媒体相关内容特别强调msgid和encrypt。msgid 是用来去重的因为企业微信会话存档接口存在重复推送的可能可能你没来得及处理 seq 又回调了同一批数据。encrypt 则是解密的关键它看起来是一长串 Base64 字符但你不能直接 Base64 解码后用 UTF-8 读必须先按上一章讲的步骤拆解。还有一个小细节action字段不能忽略。如果某人撤回了一条消息这条记录也会进入会话存档但 action 为 recall真实消息替罪羊其实是空内容你必须保留这条记录才能说明“某条消息在哪个时间点被撤回了”否则审计时会发现消息凭空消失。4. 解密与解析C#从企业微信返回的加密数据里还原聊天内容4.1 解密顺序先RSA拆钥匙再AES开正文到了这一步很多人就开始把网上抄来的代码一顿乱试。我先讲正确的解密顺序把 encrypt 字段做 Base64 解码得到一个字节数组。按固定结构切分前 16 个字节是随机串接着是 RSA 密文长度与密钥位数有关剩余部分才是 AES 密文。用私钥对 RSA 密文解密得到 32 字节的 AES 密钥。用这个 AES 密钥对 AES 密文做 AES-256-CBC 解密IV 使用前 16 个字节的随机串。解密后的内容就是消息明文 JSON。切分长度是关键。如果企业微信后台生成的是 2048 位 RSA 密钥那么 RSA 密文固定是 256 字节如果是 1024 位则是 128 字节。不要写死我在代码里用了一个常量但会在配置中根据实际情况修改。绝大多数企业微信后台默认是 2048 位所以下面的示例按 256 字节写同时加了注释提醒。4.2 C#解密代码基于BouncyCastle的RSA与AES工具类我推荐使用 BouncyCastle 库来处理 RSA 私钥因为 .NET 内置的RSACryptoServiceProvider导入 PEM 格式私钥非常麻烦尤其企业微信下载的私钥是标准 PEM头可能是-----BEGIN PRIVATE KEY-----也可能是-----BEGIN RSA PRIVATE KEY-----。前者是 PKCS#8后者是 PKCS#1直接导入容易报错。BouncyCastle 可以自动识别。using System.Security.Cryptography; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.OpenSsl; public static class ChatDataDecryptor { // 企业微信会话存档加密结构 // [16字节随机数] [RSA密文长度2048位则为256字节] [AES密文] private const int RandomPrefixLength 16; private const int RsaCipherLength 256; public static string DecryptChatData(string encryptBase64, string privateKeyPem) { byte[] full Convert.FromBase64String(encryptBase64); // 切出随机数和IV byte[] iv new byte[RandomPrefixLength]; Array.Copy(full, 0, iv, 0, RandomPrefixLength); // 切出RSA密文 byte[] rsaCipher new byte[RsaCipherLength]; Array.Copy(full, RandomPrefixLength, rsaCipher, 0, RsaCipherLength); // 切出AES密文 int aesCipherLength full.Length - RandomPrefixLength - RsaCipherLength; byte[] aesCipher new byte[aesCipherLength]; Array.Copy(full, RandomPrefixLength RsaCipherLength, aesCipher, 0, aesCipherLength); // RSA解密出AES密钥 byte[] aesKey RsaDecryptAesKey(rsaCipher, privateKeyPem); // AES-CBC解密 using var aes Aes.Create(); aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; aes.Key aesKey; aes.IV iv; using var decryptor aes.CreateDecryptor(); byte[] plainBytes decryptor.TransformFinalBlock(aesCipher, 0, aesCipher.Length); return Encoding.UTF8.GetString(plainBytes); } private static byte[] RsaDecryptAesKey(byte[] rsaCipher, string privateKeyPem) { AsymmetricKeyParameter privateKey; using (var reader new StringReader(privateKeyPem)) { PemReader pemReader new PemReader(reader); var obj pemReader.ReadObject(); if (obj is AsymmetricCipherKeyPair keyPair) privateKey keyPair.Private; else if (obj is AsymmetricKeyParameter keyParam) privateKey keyParam; else throw new InvalidOperationException(无法解析私钥格式请确认PEM内容是PKCS#1还是PKCS#8); } var engine new RsaEngine(); engine.Init(false, privateKey); byte[] aesKey engine.ProcessBlock(rsaCipher, 0, rsaCipher.Length); return aesKey; } }代码的逻辑说明DecryptChatData接受来自encrypt字段的 Base64 字符串和 PEM 私钥按固定结构拆成三段。RSA 解密的结果就是 32 字节 AES 密钥然后用它做 AES-CBC 解密。注意这里 IV 是随机前缀那 16 字节不是固定零向量这是最容易写错的地方。参数说明privateKeyPem是私钥文件里的完整字符串包括-----BEGIN...和-----END...的头部尾部。BouncyCastle 的PemReader能兼容两种常见格式所以只要私钥内容完整就能解析。如果解析报错多半是私钥文件被 Windows 记事本加了 BOM 或者换行符被改我建议在读取时把\r\n统一替换为\n。4.3 把解密的JSON变成消息记录解密后的明文是一个 JSON 字符串里面会有from、msgtype、roomid、text等字段。不同的msgtype对应的内容结构差别很大比如文本是text.content图片则是image.md5sum和图片 URL。public class ChatMessage { public string from { get; set; } public string msgtype { get; set; } public string text { get; set; } } public static ChatMessage ParseMessage(string plainJson) { using var doc JsonDocument.Parse(plainJson); var root doc.RootElement; var msg new ChatMessage(); msg.from root.TryGetProperty(from, out var fromEl) ? fromEl.GetString() : ; msg.msgtype root.TryGetProperty(msgtype, out var typeEl) ? typeEl.GetString() : ; if (msg.msgtype text root.TryGetProperty(text, out var textEl) textEl.TryGetProperty(content, out var contentEl)) { msg.text contentEl.GetString(); } return msg; }这里有一个坑不是所有消息都能从encrypt里解出完整内容。比如图片、语音、文件这类媒体消息通过会话存档接口拿到的encrypt解密后只有消息描述和媒体 ID真正的文件内容需要再调用一次媒体下载接口。处理时不要直接认为解密出来一定是文本要判断msgtype再决定是否继续下载资源。5. 避坑企业微信会话内容存档C#接口调用常见的5个坑5.1 token冲突代码没变拉取却时好时坏现象程序运行一段时间后突然连续报errcode: 40014或42001重启后恢复正常。重新运行代码后又坏。原因多个线程同时调用GetAccessToken()第一个线程还没写入新 token第二个线程又去请求了导致旧的 token 被企业微信判定失效。也可能是有多台服务器在跑各自缓存 token一台刷新后另一台还在用旧 token。解决token 获取方法必须加进程内锁我用的就是lock (_lock)保证同一时间只有一个请求去刷新多台服务器的话最好把 token 放进 Redis 或数据库所有实例共享一份有效期设为 7000 秒并加“提前 5 分钟刷新”的机制。5.2 RSA解密始终失败偶尔得到乱码现象调用DecryptChatData时抛 “Data length not correct” 或 “block incorrect”偶尔不抛异常但解出来的 AESKey 是错误的后续 AES 解密失败。原因最常见是私钥格式不匹配。企业微信下载的私钥如果是 PKCS#8而 BouncyCastle 读取时按 PKCS#1 解析或者反过来另一个原因是RsaCipherLength写死成 256但实际密钥是 1024 位RSA 密文只有 128 字节切分位置错了。解决先打印加密数据的full.Length正常情况下它应该等于16 RSA密文长度 AES密文长度。RSA 密文长度可以通过pubKey.Modulus.Length或用openssl rsa -in private.pem -text查看密钥位数。1024 位就设为 1282048 位设为 256。私钥格式问题我统一用 BouncyCastle 的PemReader自动判断不要自己硬拆 Base64。5.3 拉取时seq跳号导致漏数据现象第一次拉取 seq 从 0 返回 10 条数据seq 分别为 1、2、3、5、6、7、8、9、10、13第二次如果我按“数组长度 上次 seq”来推就会传 seq10 而不是 seq14那么 seq13 之后的某条数据可能永远拉不到或者重复拉取。原因会话存档接口的 seq 并不是连续递增它会在消息被撤销、会话结构变化时产生空洞。你只能用“当前已拉取到的最大 seq 1”作为下一次游标。解决处理返回结果时不要用chatdata.Count()来推进游标而是遍历所有item.seq取最大值然后maxSeq 1。我在生产代码里会把这个游标存到数据库每次启动读取并且每次拉取后立刻更新。long maxSeq 0; foreach (var item in chatDataList) { if (item.seq maxSeq) maxSeq item.seq; } long nextSeq maxSeq 1; // 下一次请求的seq5.4 解密成功但消息是空字符串或缺少媒体文件现象拉下来的记录文本消息能正常显示但图片、语音、文件的记录只有msgtype和媒体 ID看不到实际内容。原因会话存档接口和解密流程处理的是“消息描述”不是“媒体文件本身”。加密数据解密后对于图片消息内容里是一个image对象里面只有md5sum和filesize等元数据真正的图片字节需要调用/media/get接口用 msgid 去下载。解决先判断msgtype如果是image、voice、file就额外调用媒体下载接口。下载时注意归档文件命名最好用msgid 后缀否则同一图片在不同消息里可能有相同内容文件名冲突会覆盖历史数据。5.5 程序跑几天后内存暴涨最后被 OOM 杀掉现象刚开始跑没问题第三天开始接口响应越来越慢内存占用从几百 MB 涨到几个 GB最后进程崩溃。原因我把拉取返回的完整 JSON 和每一页解密后的字符串都暂时存在一个List里想攒够一批再写库。但会话存档的数据量比预想大得多一天可能几十万条撑到第三天就把内存打爆了。解决改成拉取一页、处理一页、写库一页不要囤数据。尤其解密后的ChatMessage立即插入数据库或写日志文件然后释放引用。还有一个细节HttpClient不要每次 new 一个长期运行的程序要复用同一个实例否则每创建一次连接TCP 端口和句柄泄漏内存也会跟着涨。6. 把存档做成一个持续运行的Windows服务C#后台拉取与入库的技巧前面实现的拉取和解密逻辑如果只跑一次意义不大。实际落地是要让它每周 7×24 小时自动运行。我一般先写一个控制台测试没问题后包装成 Windows 服务或挂到任务计划里。常见做法是加一个定时器每隔 30 秒拉取一次。有一个关键点拉取间隔不要固定到 30 秒后立刻又拉因为上一条可能还没处理完。我用一个更加保守的策略每次处理完上一页后再等 1 秒拉取下一页直到某一页返回的数据条数小于 limit就认为已经追平休息 30 秒再开始。public class ArchiveWorker { private readonly TimeSpan IdleInterval TimeSpan.FromSeconds(30); public async Task RunLoopAsync(CancellationToken cancellationToken) { long seq await LoadLastSeqAsync(); // 从数据库读取上次游标 while (!cancellationToken.IsCancellationRequested) { try { string token WeComToken.GetAccessToken(); string json await GetChatDataAsync(token, seq, 500); var result JsonSerializer.DeserializeChatDataResult(json); if (result.chatdata.Count 0) { await Task.Delay(IdleInterval, cancellationToken); continue; } long maxSeq seq; foreach (var item in result.chatdata) { string plainText ChatDataDecryptor.DecryptChatData(item.encrypt, _privateKeyPem); var message ParseMessage(plainText); await SaveToDbAsync(item, message); if (item.seq maxSeq) maxSeq item.seq; } seq maxSeq 1; await SaveLastSeqAsync(seq); } catch (Exception ex) { _logger.Error(ex, 拉取失败保留当前seq待重试); await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); } } } }逻辑说明RunLoopAsync启动后先从数据库读取上次保存的 seq然后进入循环。拉取一批、解密一批、入库一批最后把游标持久化。如果企业微信返回空数组说明没有新消息这时才休息 30 秒避免无意义轮询。catch 里保留 seq 不变下次循环会从相同位置重新拉取这样不会因为一次网络故障丢数据。我再补充一个运行习惯强制要求所有入库操作必须在同一个数据库事务里做seq 更新和消息插入一起提交。否则程序在“更新 seq 之前”崩溃了重启后又会拉同一批数据造成重复如果没崩溃但 seq 更新失败了又会出现跳过数据的风险。最后说一个个人教训我最早把 seq 存在一个单独的表里却没有备份。某天这个表被同事手滑清空而企业微信会话存档只能拉最近一段时间的数据历史序列号断了就再也补不回来了。自那以后我开始同步把 seq 写入一个备份文件每天随数据库一起备份。做这种数据保全的项目宁可多留一条保留来源也不要让游标成为单点。希望帮到你。本文还有配套的精品资源点击获取