
简介在合规审计与客服纠纷取证等场景中聊天记录的留存已成为企业数字化运营的基础要求。企业微信会话内容存档接口提供了一套完整的消息拉取与加密传输机制但开发者往往会被双重加密链路拦住脚步——每条消息都经过RSA非对称加密保护对称密钥再由AES-GCM加密消息体。理解这条加密链路是C#对接的关键突破口。从接口选型、可信IP配置到access_token缓存再到私钥解析与AES-GCM消息体还原整个流程环环相扣。seq游标的正确维护和msgid去重策略则确保了海量消息的可靠拉取与落库。本文以C#为例完整演示如何基于BouncyCastle库解决私钥格式兼容问题并规避填充模式错误、游标断档、媒体过期等高频踩坑点最终实现一套可运行于生产环境的会话内容存档方案。1. 企业微信会话内容存档C# 侧真正的拦路虎是双重加密做企业内部审计、客服纠纷取证、销售跟单留痕的 C# 开发者迟早会撞上「企业微信会话内容存档」这个功能。它解决的问题很明确把员工与客户、员工与员工之间的聊天记录按合规要求留存下来必要时能拿出来当证据。但真拿 C# 去调官方接口时你会发现拦路的不是 HTTP 请求而是两条加密链路——每条消息都被「RSA 非对称加密 AES 对称加密」包了两层。接口调通了返回的却是密文解不开等于白调。这份「企业微信会话内容存档 C# 调用接口源码」说白了就是一套能直接跑的 C# 对接实现token 缓存、分页拉取、RSA 私钥解密、AES-GCM 消息体还原、媒体下载整套流程都封装好了。适合要自己搭存档系统、又不想在加解密上反复翻车的人。2. 搭台子密钥对、可信 IP 与会话存档接口全景2.1 拉取与回调两种读取模式的选型企业微信会话内容存档的读取方式官方给了两条路。第一条是主动拉取服务端拿着 access_token 去调 getchatdata 接口按 seq 游标一页一页把聊天记录拖回来第二条是回调推送企业微信把加密的消息体推到你的回调 URL 上你在接收端做解密和落库。我一般建议直接用主动拉取。原因有三点。其一是游标可控getchatdata 返回的 next_seq 就是下一条消息的起点拉到哪存到哪服务重启接着上次的位置续拉即可其二是时序好处理回调推送会有乱序和重复投递的问题你得自己维护消息序号做排序而拉取模式天然有序其三是调试直观出问题时用 curl 或者 Postman 直接打一次接口看返回比排查推送链路省事得多。回调模式唯一的优势是实时性好消息发出后几秒内就能推给你但如果你不是要做实时风控拦截只是做审计留痕拉取模式完全够用。2.2 后台三件套加密公私钥、可信 IP、corpsecret在写代码之前后台配置有一步错、后面步步错。你需要准备三样东西加密公私钥、可信 IP、corpsecret。公钥填到企业微信管理后台的「会话内容存档」配置页私钥自己留在服务端corpsecret 在后台配置页能看到它是调用 gettoken 的凭证跟自建应用的 secret 不是同一个别混。密钥对用 openssl 生成命令很常规openssl genrsa -out archive_private.pem 2048 openssl rsa -in archive_private.pem -pubout -out archive_public.pem生成的 archive_public.pem 是 PKCS#8 公钥填到后台archive_private.pem 是 PKCS#1 私钥放到服务器受保护目录。这里有一个 C# 开发者经常踩的点官方给的私钥是 PEM 文本而 C# 原生的 RSACryptoServiceProvider 默认不认 PKCS#1 格式后面第 3 章会详细说怎么处理。可信 IP 配置容易被忽略。企业微信要求消息拉取必须来自你登记的可信 IP如果你的服务器走 NAT 出网出口 IP 和网卡 IP 不是一回事一定要把公网出口 IP 填进去。填错了getchatdata 会稳定返回 60020 错误而且这个错误从接口语义上根本看不出是白名单问题。2.3 接口全景与 access_token 缓存实现会话内容存档相关接口一共四个核心就一张表的事接口用途主要入参返回要点gettoken获取 access_tokencorpid、corpsecretaccess_token、expires_ingetchatdata拉取会话内容seq、limitchatdata、next_seq、has_moregetmediadata拉取语音/视频等媒体seq、limit、mediatypemedia_data 列表getpublickey获取会话存档公钥access_tokenpublickey、expire_timeaccess_token 是所有接口的通行证两小时过期且企业微信对同一个 secret 的 token 获取频率有限制所以必须做本地缓存。我习惯用双层判断加锁的方式实现保证并发下只发一次请求public class TokenProvider { private static readonly object _lock new object(); private static string _token; private static DateTime _expireAt DateTime.MinValue; public static string GetToken(string corpid, string corpsecret) { // 第一层判断没有过期就直接返回 if (!string.IsNullOrEmpty(_token) DateTime.Now _expireAt) return _token; lock (_lock) { // 第二层判断拿到锁后再确认一次避免重复请求 if (!string.IsNullOrEmpty(_token) DateTime.Now _expireAt) return _token; string url $https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid{corpid}corpsecret{corpsecret}; using var client new HttpClient(); var json client.GetStringAsync(url).Result; using var doc JsonDocument.Parse(json); var root doc.RootElement; // errcode 为 0 才算成功 if (root.GetProperty(errcode).GetInt32() ! 0) throw new Exception($gettoken failed: {root.GetProperty(errmsg).GetString()}); _token root.GetProperty(access_token).GetString(); // 提前 300 秒过期给网络抖动留余量 _expireAt DateTime.Now.AddSeconds(root.GetProperty(expires_in).GetInt32() - 300); return _token; } } }这段代码里有三个参数需要根据你项目的实际情况调。第一个是过期余量这里写了 300 秒如果你们网络到企业微信的延迟普遍偏高可以放到 600 秒原则是宁可提前刷新也不能用快过期的 token 去请求。第二个是 HttpClient 的实例化方式示例代码直接用 using 创建高频调用场景建议用 IHttpClientFactory 管理连接池避免端口耗尽。第三个是 corpsecret 来源不要硬编码在代码里从配置中心或者环境变量读后面换密钥不用改程序。3. 加密链路还原RSA 私钥解密 AES-GCM 消息体解码3.1 一条消息的加密链路拉取会话内容后chatdata 数组里每条消息长这样字段里有 seq消息序号、msgid消息唯一 ID、encrypt_random_keyRSA 加密的对称密钥、encrypt_chat_msgAES 加密的消息体。这两个 encrypt 字段就是双重加密的体现。解密顺序是先 RSA 后 AES不能反过来。encrypt_random_key 是用会话存档公钥加密过的一段随机 seed你的私钥能解开它解开后得到的是 AES-256 的密钥和 IV再用这把对称密钥去解 encrypt_chat_msg最后得到的才是真正可读的 JSON——里面有 msgid、actionsend/recall、roomid群聊场景、content文本内容等字段。为什么官方要多此一举套两层因为 RSA 加密慢不适合对大段聊天内容做加密只用来加密一小段对称密钥对称加密快适合处理消息体但密钥要安全传递。所以链路就变成了「RSA 保护对称密钥对称密钥保护消息内容」。理解了这个你就能猜到踩坑点在哪要么 RSA 解不开要么 AES 解出来是乱码。在 C# 里两个坑我都踩过。3.2 私钥加载与 RSA 解密用 BouncyCastle 而不是原生 RSACryptoServiceProviderC# 原生的 RSACryptoServiceProvider 处理 PEM 私钥非常别扭。它需要 XML 格式的密钥而企业微信下发的是 PEM——把私钥转成 XML 得先解析 ASN.1 结构纯手写解析容易出错而且 PKCS#1 和 PKCS#8 的格式解析还不一样。我不建议在这上面浪费时间直接用 BouncyCastlePortable.BouncyCastle NuGet 包加载两行代码解决。using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.OpenSsl; public class ArchiveCrypto { private readonly AsymmetricKeyParameter _rsaPrivateKey; public ArchiveCrypto(string rsaPrivateKeyPem) { using var reader new StringReader(rsaPrivateKeyPem); // 官方下发的是 PKCS#1 PEMPemReader 读完就是 RsaPrivateCrtKeyParameters _rsaPrivateKey new PemReader(reader).ReadObject() as AsymmetricKeyParameter; if (_rsaPrivateKey null) throw new Exception(RSA 私钥格式不正确确认是否是 PKCS#1 PEM); } }这个类实例化时传入的 rsaPrivateKeyPem 是后台配置页下载的私钥文本原样传进来就行。注意一定不要手动替换私钥里的换行符PEM 的格式解析依赖标准换行我用记事本打开后复制粘贴丢过一个坑——粘到代码里变成空格PemReader 直接解析失败。再往下是 RSA 解密本身。BouncyCastle 里要选对解密引擎和填充模式这里我吃过大亏using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Encodings; private byte[] RsaDecrypt(byte[] cipherBytes) { // Pkcs1Encoding 是 RSA PKCS#1 v1.5 填充企业微信加密时用的就是这个 var cipher new Pkcs1Encoding(new RsaEngine()); cipher.Init(false, _rsaPrivateKey); // false 表示解密 return cipher.ProcessBlock(cipherBytes, 0, cipherBytes.Length); }逻辑说明new RsaEngine() 创建裸 RSA 引擎Pkcs1Encoding 包一层填充模式。Init 方法的第一个参数传 false 明确是解密操作传 true 就是加密别写反。ProcessBlock 的入参是 encrypt_random_key 字段的 Base64 解码结果一次调用解密一整个块。参数说明填充模式必须和企业微信加密端一致官方用的是 PKCS#1 v1.5所以这边用 Pkcs1Encoding如果你用 OaepEncoding 去解结果通常是「invalid padding」异常。私钥参数类型是 RsaPrivateCrtKeyParameters这是 PKCS#1 私钥的典型类型BouncyCastle 用它才能完成私钥操作。3.3 AES-GCM 消息体还原与完整解密类解出 seed 之后剩下的就是拿对称密钥解密消息体。企业微信采用的对称算法是 AES-GCM密钥 32 字节、IV 12 字节、认证标签 128 bit。GCM 比 CBC 多一个好处解密同时校验完整性消息内容被篡改会直接抛异常而不是返回乱码。using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Parameters; public string Decrypt(string encryptRandomKey, string encryptChatMsg) { // 第 1 步RSA 解密出对称密钥 seed byte[] seed RsaDecrypt(Convert.FromBase64String(encryptRandomKey)); // 第 2 步按官方约定切分 key 和 iv byte[] aesKey new byte[32]; byte[] aesIv new byte[12]; Buffer.BlockCopy(seed, 0, aesKey, 0, 32); Buffer.BlockCopy(seed, 32, aesIv, 0, 12); // 第 3 步AES-GCM 解密 byte[] cipherBytes Convert.FromBase64String(encryptChatMsg); byte[] plainBytes AesGcmDecrypt(cipherBytes, aesKey, aesIv); return Encoding.UTF8.GetString(plainBytes); } private byte[] AesGcmDecrypt(byte[] cipherBytes, byte[] key, byte[] iv) { var cipher new GcmBlockCipher(new AesEngine()); // 128 是认证标签的 bit 长度 var parameters new AeadParameters(new KeyParameter(key), 128, iv); cipher.Init(false, parameters); int outputSize cipher.GetOutputSize(cipherBytes.Length); byte[] plainBytes new byte[outputSize]; int len cipher.ProcessBytes(cipherBytes, 0, cipherBytes.Length, plainBytes, 0); len cipher.DoFinal(plainBytes, len); // GetOutputSize 会把 GCM 的 16 字节 tag 算进去实际明文要截掉 Array.Resize(ref plainBytes, len); return plainBytes; }逻辑说明第 2 步的 Buffer.BlockCopy 是从 seed 里切对称密钥和 IV——前 32 字节是 AES-256 密钥紧接着 12 字节是 GCM 的 IV。这个长度约定要严格按照官方文档来你在别的语言例子里看到过 16 字节 IV 的那多半是 CBC 模式不是一回事。第 3 步的 AesGcmDecrypt 里密文最后 16 字节是 GCM 认证标签ProcessBytes 解密出主体内容DoFinal 负责校验标签并把剩余字节写入缓冲所以最后要 Array.Resize 一把把 tag 占用的长度去掉。参数说明AeadParameters 第二个参数是 macSizeBits写 128 表示 16 字节认证标签跟企业微信加密端对齐。如果这里写了 96DoFinal 会因为 tag 长度不匹配抛异常。Decrypt 方法返回的是 UTF-8 字符串你直接 JsonDocument.Parse 就能拿到可读消息体。4. 拉取与落库seq 游标、去重存储和媒体文件下载4.1 分页拉取的游标契约next_seq 与 has_moregetchatdata 的拉取逻辑一点都不复杂但 seq 游标有两个细节必须遵守。第一seq 是全局消息游标不是按会话维度的你每拉一次拿到 next_seq下次请求必须原样传回去不能自己在代码里加一。第二has_more 等于 1 时说明后面还有数据要接着拉等于 0 才算拉到头。有人把 has_more 和 next_seq 混着判断结果漏消息审计数据缺一条都不是小事。public class ArchivePuller { public (ListChatData list, long nextSeq, int hasMore) Pull(string token, long seq, int limit 100) { string url $https://qyapi.weixin.qq.com/cgi-bin/msgaudit/getchatdata?access_token{token}; using var client new HttpClient(); // 官方入参是 seq 和 limitlimit 建议控制在 100 左右 var payload new { seq seq, limit limit }; var json JsonSerializer.Serialize(payload); var resp client.PostAsync(url, new StringContent(json, Encoding.UTF8, application/json)).Result; using var doc JsonDocument.Parse(resp.Content.ReadAsStringAsync().Result); var root doc.RootElement; if (root.GetProperty(errcode).GetInt32() ! 0) throw new Exception($getchatdata failed: {root.GetProperty(errmsg).GetString()}); long nextSeq root.GetProperty(next_seq).GetInt64(); int hasMore root.GetProperty(has_more).GetInt32(); var list new ListChatData(); foreach (var item in root.GetProperty(chatdata).EnumerateArray()) { list.Add(new ChatData { Seq item.GetProperty(seq).GetInt64(), MsgId item.GetProperty(msgid).GetString(), EncryptRandomKey item.GetProperty(encrypt_random_key).GetString(), EncryptChatMsg item.GetProperty(encrypt_chat_msg).GetString() }); } return (list, nextSeq, hasMore); } }逻辑说明这个方法的返回值组里有三个东西——解密前的消息列表、下一次的游标、是否还有更多。调用方拿到的 nextSeq 要和 nextSeq 那个变量区分清楚方法返回的 nextSeq 是接口给的最新游标下一轮调用直接拿它当 seq 入参。参数说明limit 写 100 是我压测后的结果。官方上限是 1000但单次拉 1000 条时接口响应在弱网环境下很容易超过 5 秒超时阈值而且一旦超时重拉刚才那批数据里可能有一部分已经入库得靠 msgid 去重兜底。100 一批重试成本低总数 10 万条消息也就多几十次请求换来的是稳定。4.2 会话内容落库msgid 去重与 sqlite 落地拉回来的数据必须落库这里有个天然的去重键msgid。msgid 是消息的唯一 ID同一批数据因为超时重拉、或者 seq 游标回退都可能重复返回所以落库必须用幂等写入。我用 SQLite 做演示换成 MySQL 就是 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE 的区别。public void SaveMsg(ChatData raw, string plainText) { using var conn new SqliteConnection(_connStr); conn.Open(); using var cmd conn.CreateCommand(); // msgid 建了唯一索引重复插入会被 IGNORE 掉 cmd.CommandText INSERT OR IGNORE INTO chat_log (seq, msgid, content, msg_time) VALUES (seq, msgid, content, msg_time); cmd.Parameters.AddWithValue(seq, raw.Seq); cmd.Parameters.AddWithValue(msgid, raw.MsgId); cmd.Parameters.AddWithValue(content, plainText); cmd.Parameters.AddWithValue(msg_time, DateTime.Now); cmd.ExecuteNonQuery(); }逻辑说明INSERT OR IGNORE 的前提是 chat_log 表的 msgid 列有唯一索引。如果索引没建IGNORE 不会生效重复数据照样插进去。建表语句里记得把 msgid 定义成 UNIQUE否则这个写法没有意义。seq 列不用做唯一约束因为 seq 是游标不是业务主键不同会话的消息 seq 不会重复但同一条消息重拉时 seq 一样靠 msgid 兜底更稳妥。落库之后下一步是维护一张 cursor 表单独存上次拉到哪了。为什么不用 chat_log 表里 max(seq) 来推断因为消息存在乱序可能——不同会话的消息 seq 不是严格按写入时间排序的max(seq) 只能保证游标不倒退不能保证你都处理过了。实践上我习惯开一张只有一行数据的表字段就是 corpid、last_seq、update_time每次拉完事务更新。4.3 媒体文件下载的时序问题语音、视频、图片这类媒体不走 getchatdata走 getmediadata。入参和 getchatdata 类似也是 seq、limit外加一个 mediatype 指定媒体类型。这个接口的返回里有下载链接或者直接返回 Base64 编码的文件内容具体以官方文档当天的字段定义为准。媒体文件的下载有强时效性。链接通常有时效窗口过期再下就 41017 找不到文件。所以我的习惯是拉取消息后凡是 content 字段里带 media_id 的立刻调 getmediadata 拉媒体不要攒批。下载完按日期和会话 ID 落盘比如 20250612/roomid_1234/msgid_5678.jpg 这样的目录结构。文件命名不要用 media_idmedia_id 在不同消息里可能复用msgid 才是唯一锚点。踩得比较深的一个细节是getmediadata 和 getchatdata 的游标体系不是同一个媒体文件有自己的 seq 游标也要单独维护。别把两个接口的 next_seq 混着用这个错误会让媒体文件缺口特别难查。5. C# 对接避坑记录解密失败、seq 断档与媒体过期5.1 RSA 解密报「Padding is invalid」多半是私钥格式问题现象调用 RsaDecrypt 解密 encrypt_random_key 时抛异常消息是「invalid padding」或者「Padding is invalid and cannot be removed」程序直接崩。原因两种可能。第一种是私钥格式不对企业微信给的是 PKCS#1 PEM你如果转成了 XML 或者用错了 BouncyCastle 的读取对象解密引擎拿到的参数不一致第二种是填充模式写错了用 OaepEncoding 解 PKCS#1 v1.5 加密的数据必然报 padding 错误。解决私钥原样读不要格式转换PemReader 读出来直接用。填充模式固定用 Pkcs1Encoding确认代码里没有混入 OaepEncoding。如果私钥是从文件读的检查文件是否以-----BEGIN RSA PRIVATE KEY-----开头这是 PKCS#1 的标志如果是-----BEGIN PRIVATE KEY-----那是 PKCS#8需要用 Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory 转一下。5.2 seq 断档不是接口丢数据是游标没用好现象日志里 chatdata 的 seq 不连续比如上一批最后一条 seq 是 999下一批第一条 seq 变成 1100中间缺了 100 条。群里开始讨论是不是企业微信丢消息了。原因getchatdata 是全局游标不是按消息数递增的。同一时段的并发消息可能落到了不同的文件分片接口返回的 seq 本来就是跳着走的只要你有 has_more1 就继续拉最终能覆盖全量。真正的丢数据场景是你在循环里没接住 next_seq或者把 next_seq 当成了当前批次的最大 seq 加一。解决拉取循环严格按「请求带上一轮返回的 next_seq → 处理本批 → 再拿新的 next_seq → 判断 has_more」来写。最简单的自查方法是连续拉三批把每批的 next_seq 打出来确认它是单调递增且回传后能正常返回。不要自己给游标做任何加减运算。5.3 60011/60020 错误可信 IP 没拉进白名单现象gettoken 正常但一调 getchatdata 就返回 60020提示「not allowed to access from your ip」。原因企业微信会话内容存档接口对来源 IP 做了白名单校验。你把代码部署在新的服务器上或者服务器从国内某机房换到了另一家出口 IP 变了后台配置的可信 IP 还是旧的。解决管理后台 → 会话内容存档 → 可信 IP 配置把当前服务器的公网出口 IP 加进去。注意查出口 IP 一定要在服务器本机执行 curl ifconfig.me 之类命令不能看 NAT 内网地址。这个配置生效很快一般不用重启服务但加完最好等一两分钟再试。5.4 回调模式验证 URL 失败echostr 解密与应答现象如果你切到回调模式在企业微信后台填回调 URL 时点了保存直接提示「URL 验证失败」然后服务端日志里收到一个 GET 请求带了 msg_signature、timestamp、nonce、echostr但程序没正确应答。原因Callback URL 验证不是简单返回 echostr 原文。企业微信用 EncodingAESKey 对 echostr 做了加密要求接收方解密后原样返回明文。很多初接的人直接 return echostr签名校验不过后台自然判定验证失败。解决实现企业微信回调的 VerifyURL 逻辑——先按 timestamp、nonce、token 排序做 SHA1 签名校验 msg_signature 是否匹配再用 EncodingAESKey 解出 echostr 明文最后把明文返回。这三个步骤缺一个都不行。如果你主要走拉取模式这个坑不一定会踩但混用两种模式时逃不掉。5.5 大批量拉取超时limit 不是越大越好现象把 limit 设成 1000 想「一次拉完」结果请求频繁超时重试后消息倒是没丢但接口调用频率上去了反而触发企业的频率限制返回「too many requests」。原因getchatdata 的耗时和 limit 不是线性关系。每条消息在服务端要完成加密数据的组装limit 越大单次响应的体积越大网络传输时间也越长。1000 条的消息体可能动辄几十 MB任何中间网络设备都可能掐掉连接。解决limit 稳定用 100 到 200。如果你是首次全量拉取写一个串行循环慢慢拉同时记录每批耗时发现单批超过 3 秒就主动降级到 50。全量拉取是低频动作慢一点换来的是可靠不值得用超时重试去赌。6. 上线前自测双去重校验与增量拉取验证6.1 三分钟自测拿到这份源码别急着全量跑先造一条测试消息验证链路。让一个测试账号给另一个账号发一条文本消息加一张图片等 5 秒后从 seq0 开始拉把解密后的 content 和 msgid 打出来核对。验证点我用一张表列出来验证项期望结果不通过的排查方向文本消息解密content 字段能看到测试发的文字私钥加载、AES-GCM 参数图片消息存在content 里能找到 media_idgetmediadata 入参msgid 不重复连续拉三批msgid 集合不增落库去重索引游标不回退next_seq 严格递增cursor 表更新逻辑这条链路通了再开启全量拉取也不迟。我见过太多人全量跑一半才发现第 5 章那些坑回头重跑还得清库不如先花三分钟验证。6.2 增量拉取的持久化习惯上线后的增量拉取我强烈建议把 last_seq 落库而不是存在内存里。原因很简单服务重启、发布部署、容器漂移任何一个动作都可能让你的游标丢回起点。从 0 重拉倒是不会丢数据但全量重拉既慢又费接口配额还会和增量数据产生大量重复写入。long seq LoadLastSeq(); // 从 cursor 表读上一次的游标首次为 0 while (true) { var (list, nextSeq, hasMore) puller.Pull(token, seq, 100); foreach (var item in list) { string plain crypto.Decrypt(item.EncryptRandomKey, item.EncryptChatMsg); saver.SaveMsg(item, plain); } SaveLastSeq(nextSeq); // 每拉完一批就更新游标不丢进度 if (hasMore ! 1) break; seq nextSeq; Thread.Sleep(200); // 给接口留一点余量避免触发频率限制 }这段逻辑的要点是 SaveLastSeq 放在循环内每批拉完立刻持久化。如果程序在这批处理到一半时崩了重启后从上一批的游标继续最多重复处理一批靠 msgid 去重消化掉不会出现消息缺口。我最早做这个项目时只存了 msgid 没存 last_seq结果一次发布把游标冲掉了第二天发现从凌晨开始全量重拉把接口拉到限流线上数据延迟了快两个小时。从那以后我每次改拉取逻辑都强制走一遍「从 0 拉 → 比对条数 → 重启续拉」三步验证确认游标落库真的生效才放行。这个习惯不一定能帮你躲过所有坑但至少能保证最基础的数据不丢。希望帮到你。本文还有配套的精品资源点击获取