> 技术文档 > 企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java


git初代版本地址

1.大家优化重构吧!
https://gitee.com/feixiangyyds666/qw-chat

一.开发时需要配置的东西

1.填写可信ip,公网的ip =>(https://ip.900cha.com/),如果不可信,重启光猫或者找个固定的ip做转发。

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java2.消息加密公钥,RSA,2048,PKCS#1,会话存档的消息需要解密,可以去这个网址获取公钥私钥(http://web.chacuo.net/netrsakeypair)

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
3.回调服务不是必须的,也可以自己定时任务来拉取。

package com.feixiang.springbootinit.controller;import cn.hutool.json.JSONUtil;import com.feixiang.springbootinit.utils.mp.aes.WXBizJsonMsgCrypt;import lombok.extern.slf4j.Slf4j;import org.apache.commons.lang3.StringUtils;import org.springframework.web.bind.annotation.*;import org.w3c.dom.Document;import org.w3c.dom.Element;import org.w3c.dom.NodeList;import org.xml.sax.InputSource;import javax.servlet.http.HttpServletRequest;import javax.xml.parsers.DocumentBuilder;import javax.xml.parsers.DocumentBuilderFactory;import java.io.StringReader;import java.util.HashMap;import java.util.Map;@RequestMapping(\"/wechat\")@RestController@Slf4j/** * 企微回调地址 */public class WeChatController { @GetMapping(\"/message/callback\") @ResponseBody() public String callback(HttpServletRequest request, @RequestParam(\"msg_signature\") String sVerifyMsgSig, @RequestParam(\"timestamp\") String sVerifyTimeStamp, @RequestParam(\"nonce\") String sVerifyNonce, @RequestParam(\"echostr\") String sVerifyEchoStr) throws Exception { String sToken = \"\"; String sCorpID = \"\"; String sEncodingAESKey = \"\"; WXBizJsonMsgCrypt wxcpt = new WXBizJsonMsgCrypt(sToken, sEncodingAESKey, sCorpID); String sEchoStr; //需要返回的明文 try { sEchoStr = wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,  sVerifyNonce, sVerifyEchoStr); return sEchoStr; // 验证URL成功,将sEchoStr返回 // HttpUtils.SetResponse(sEchoStr); } catch (Exception e) { //验证URL失败,错误原因请查看异常 e.printStackTrace(); } return \"error\"; } @PostMapping(\"/message/callback\") @ResponseBody() public void callbackData(HttpServletRequest request, @RequestBody() String sRespData, @RequestParam(\"msg_signature\") String sVerifyMsgSig, @RequestParam(\"timestamp\") String sReqTimeStamp, @RequestParam(\"nonce\") String sReqNonce) throws Exception { String sToken = \"\"; String sCorpID = \"\"; String sEncodingAESKey = \"\"; WXBizJsonMsgCrypt wxcpt = new WXBizJsonMsgCrypt(sToken, sEncodingAESKey, sCorpID); String sMsg = wxcpt.DecryptMsg(sVerifyMsgSig, sReqTimeStamp, sReqNonce, sRespData); System.out.println(\"解密decrypt企业微信推送的消息->sMsg:\" + sMsg); DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); DocumentBuilder db = dbf.newDocumentBuilder(); StringReader sr = new StringReader(sMsg); InputSource is = new InputSource(sr); Document document = db.parse(is.getByteStream()); //获取整个XML消息体,进行解析 Element root = document.getDocumentElement(); //返回消息的公共部分,内容,创建时间,消息ID,来源,去处 NodeList nodelistTime = root.getElementsByTagName(\"CreateTime\"); String CreateTime = nodelistTime.item(0).getTextContent(); NodeList nodelistFrom = root.getElementsByTagName(\"FromUserName\"); String fromUserName = nodelistFrom.item(0).getTextContent(); NodeList nodelistTo = root.getElementsByTagName(\"ToUserName\"); String toUserName = nodelistTo.item(0).getTextContent(); Map<String, String> msgMap = new HashMap<String, String>(); msgMap.put(\"CreateTime\", CreateTime); msgMap.put(\"fromUser\", fromUserName); msgMap.put(\"to\", toUserName); //消息类型,不同消息返回不同消息体 NodeList typeNodelist = root.getElementsByTagName(\"MsgType\"); String MsgType = typeNodelist.item(0).getTextContent(); msgMap.put(\"msgType\", MsgType); //文本直接返回 if (StringUtils.equals(MsgType, \"text\")) { NodeList nodelistMsgId = root.getElementsByTagName(\"MsgId\"); String MsgId = nodelistMsgId.item(0).getTextContent(); msgMap.put(\"msgId\", MsgId); NodeList nodelist = root.getElementsByTagName(\"Content\"); String Content = nodelist.item(0).getTextContent(); msgMap.put(\"text\", Content); //自定义返回消息类型,只支持字符串类型 Map<String, Object> getMsg = new HashMap<String, Object>(); getMsg.put(\"msg\", msgMap); //使用hutools String message = JSONUtil.toJsonStr(getMsg); //String message = JSONArray.toJSON(getMsg).toString(); log.info(\"发送消息:{}\", message); //返回给前端消息 //rabbitTemplate.convertAndSend(\"qywx_exange\", null, message); //图片返回图片需要增加url和mediaId //todo } }}

二.进入开发

1.引入SDK(它的SDK是链接库,不是pom的那种,我是本地开发win测试)

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

2.引入官方的初始化SDK代码

1:必须在com.tencent.wework 下
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
2 初始化代码

package com.tencent.wework;/* sdk返回数据typedef struct Slice_t { char* buf; int len;} Slice_t;typedef struct MediaData { char* outindexbuf; int out_len; char* data; int data_len; int is_finish;} MediaData_t;*/import cn.hutool.core.io.resource.ClassPathResource;import lombok.extern.slf4j.Slf4j;import org.apache.commons.compress.utils.IOUtils;import java.io.*;@Slf4jpublic class Finance { public native static long NewSdk(); /** * 初始化函数 * Return值=0表示该API调用成功 * * @param [in] sdkNewSdk返回的sdk指针 * @param [in] corpid 调用企业的企业id,例如:wwd08c8exxxx5ab44d,可以在企业微信管理端--我的企业--企业信息查看 * @param [in] secret聊天内容存档的Secret,可以在企业微信管理端--管理工具--聊天内容存档查看 * @return 返回是否初始化成功 * 0 - 成功 * !=0 - 失败 */ public native static int Init(long sdk, String corpid, String secret); /** * 拉取聊天记录函数 * Return值=0表示该API调用成功 * * @param [in] sdkNewSdk返回的sdk指针 * @param [in] seq从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0 * @param [in] limit一次拉取的消息条数,最大值1000条,超过1000条会返回错误 * @param [in] proxy使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081 * @param [in] passwd代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123 * @param [out] chatDatas返回本次拉取消息的数据,slice结构体.内容包括errcode/errmsg,以及每条消息内容。 * @return 返回是否调用成功 * 0 - 成功 * !=0 - 失败 */ public native static int GetChatData(long sdk, long seq, long limit, String proxy, String passwd, long timeout, long chatData); /** * 拉取媒体消息函数 * Return值=0表示该API调用成功 * * @param [in] sdkNewSdk返回的sdk指针 * @param [in] sdkFileid从GetChatData返回的聊天消息中,媒体消息包括的sdkfileid * @param [in] proxy使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081 * @param [in] passwd代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123 * @param [in] indexbuf媒体消息分片拉取,需要填入每次拉取的索引信息。首次不需要填写,默认拉取512k,后续每次调用只需要将上次调用返回的outindexbuf填入即可。 * @param [out] media_data返回本次拉取的媒体数据.MediaData结构体.内容包括data(数据内容)/outindexbuf(下次索引)/is_finish(拉取完成标记) * @return 返回是否调用成功 * 0 - 成功 * !=0 - 失败 */ public native static int GetMediaData(long sdk, String indexbuf, String sdkField, String proxy, String passwd, long timeout, long mediaData); /** * @param [in] encrypt_key, getchatdata返回的encrypt_key * @param [in] encrypt_msg, getchatdata返回的content * @param [out] msg, 解密的消息明文 * @return 返回是否调用成功 * 0 - 成功 * !=0 - 失败 * @brief 解析密文 */ public native static int DecryptData(long sdk, String encrypt_key, String encrypt_msg, long msg); public native static void DestroySdk(long sdk); public native static long NewSlice(); /** * @return * @brief 释放slice,和NewSlice成对使用 */ public native static void FreeSlice(long slice); /** * @return 内容 * @brief 获取slice内容 */ public native static String GetContentFromSlice(long slice); /** * @return 内容 * @brief 获取slice内容长度 */ public native static int GetSliceLen(long slice); public native static long NewMediaData(); public native static void FreeMediaData(long mediaData); /** * @return outindex * @brief 获取mediadata outindex */ public native static String GetOutIndexBuf(long mediaData); /** * @return data * @brief 获取mediadata data数据 */ public native static byte[] GetData(long mediaData); public native static int GetIndexLen(long mediaData); public native static int GetDataLen(long mediaData); /** * @return 1完成、0未完成 * @brief 判断mediadata是否结束 */ public native static int IsMediaDataFinish(long mediaData); static { String osName = System.getProperty(\"os.name\").toLowerCase(); String libraryPath; if (osName.contains(\"windows\")) { log.info(\"当前操作系统是Windows\"); libraryPath = \"lib/win/WeWorkFinanceSdk.dll\"; // //windows下运行需要给资源文件夹下四个注册表存放在下面打印的目录任何一个地方即可,linux直接打包丢上去运行即可 // System.out.println(System.getProperty(\"java.library.path\")); // System.loadLibrary(\"WeWorkFinanceSdk\"); } else if (osName.contains(\"linux\")) { log.info(\"当前操作系统是Linux\"); libraryPath = \"lib/linux/libWeWorkFinanceSdk_Java.so\"; } else { log.error(\"不支持的操作系统: \" + osName); throw new UnsatisfiedLinkError(\"不支持的操作系统: \" + osName); } try { loadLibraryFromClasspath(libraryPath); } catch (Exception e) { throw new RuntimeException(\"无法加载本地库\", e); } } /** * 从类路径加载本地库到临时文件并加载 */ private static void loadLibraryFromClasspath(String pathInClasspath) throws IOException { // 检查路径是否合法 if (pathInClasspath == null || !pathInClasspath.contains(\".\")) { throw new IllegalArgumentException(\"无效的库路径: \" + pathInClasspath); } // 创建临时文件 String fileName = pathInClasspath.substring(pathInClasspath.lastIndexOf(\'/\') + 1); String prefix = fileName.substring(0, Math.min(3, fileName.length())); // 临时文件前缀至少3个字符 String suffix = fileName.substring(fileName.lastIndexOf(\'.\')); File tempFile = File.createTempFile(prefix, suffix); tempFile.deleteOnExit(); // 复制类路径中的库到临时文件 try (InputStream in = new ClassPathResource(pathInClasspath).getStream(); OutputStream out = new FileOutputStream(tempFile)) { byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = in.read(buffer)) != -1) { out.write(buffer, 0, bytesRead); } } // 加载临时文件 System.load(tempFile.getAbsolutePath()); System.out.println(\"成功加载库: \" + tempFile.getAbsolutePath()); }}

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
3.建一个demo跑一下(可直接运行)

package com.tencent.wework;import cn.hutool.json.JSONArray;import cn.hutool.json.JSONObject;import com.feixiang.springbootinit.utils.RSAUtil;import com.feixiang.springbootinit.utils.SessionArchiveUtil;import java.io.File;import java.io.FileOutputStream;import java.security.PrivateKey;import java.util.Arrays;import java.util.List;@Deprecatedpublic class FinanceDemo { private static String priKey = \"-----BEGIN RSA PRIVATE KEY-----\\n\" +  \"MIIEogIBAAKCAQEApTDdP6E4g+IGYP1losxEA+o+3g9TMBUFJvQrse0ZMoG8SAs7\\n\" +  \"DIi0c0MmvE3gEBUADySLJ+7svDQrXpC2rU9fIwbwS56zyd79hWcv8usfZb2czkuE\\n\" +  \"15EsZwdzv7n33leRBgzvwqOqfA33VN0km5xjXK0IXkwytkhYwkjOo91iegg3Zt0k\\n\" +  \"iDWHxHQyy8vZhnbG3wt1u7NzDmaDAYubN7f5NwKLwH8Pxb8IODSxJEQroYmt9GWU\\n\" +  \"gER3n8XzlN8lkvTnm6HfOqY+jpVkunKkPlfTWfDd+3aZfv22dhmu7d6Qdza8B+hx\\n\" +  \"M9nXmCDbOrjYpElPOKReMZR48dV6/mZEIhzqdQIDAQABAoIBAAvkXqksBBOKfAcT\\n\" +  \"dvOnXOYjQO9fjw+RE9PgFB01AnFeYZ2WIWQfm04I8Yrw2EaQHHb6nIH9c1GBN/YQ\\n\" +  \"2PXVFvZAk5/+BtdrlDi8WvlT7+i1I8q+KQwTyWyJXs3Yp318NFaJK+GJQsa3gqy7\\n\" +  \"Vt1U/to3IrkXJzd0Md+aujWVxHcgbmOGU/pFIzu21KrpM0q4DW169M06dfkuKebN\\n\" +  \"6MxRf9wIskur84qZ/QhsWqgB/3DavQOTndyuX05+Qmyl3BNzGiBVDHErcPCtLVQm\\n\" +  \"Eb1oMP0O99buEGLEL93UwDNNST72ZkZpjgc0ID6w3JlRb9Pc5cFO3JNybZOXCVXd\\n\" +  \"kzmIoEECgYEA7SrKTj3fJeEmI4qwPOZ5dS22GofPO6i6zyHDS0A9UxUBGLBCi0tT\\n\" +  \"7yTWOnqXEOFCDFYdb05i6pd0MYaxENo1jfa/2NLXbl7DzE5x2HZ5e9r3Lqa47sGv\\n\" +  \"FL4EeAiRuR9RPRRzmC0mstGlw8XGKh9g9mYBeJtHYKtlWcoJuLyWl20CgYEAsk7q\\n\" +  \"0erHYRUA/UEBqhX8qPf1oDY13f7k8VpP/BFmPMjhTztOP8EzAz2d47TRfSIMD4hT\\n\" +  \"kgZaAmefvAmbUmYZcfu+fy1SVY1bao3k6Ys1kiNHHq27pGZ1qi7FV3Lyhbju3rsI\\n\" +  \"9FULE0JZ7EGB3d9EEUvtyEUSw4UFZ15PPvAdEikCgYAt7wyxrLAMcRfm6FSL5wN9\\n\" +  \"G3ZamxUa8+oCdgKSD4YREk9dDb5FpjhCZ/sJ5nZdsZMOOpuG1EK6Ns0tTm362phm\\n\" +  \"v3SWtPc+jCIV7teD2iZbQ6R+J5JHw8F6HfdVnCtFFHiygMSGo7AMECivyx0/SUaJ\\n\" +  \"UmZUipZ/dZKymjvZyMTN3QKBgCoyzxP68lChfehxuAkexvreI/1Ulw8AwgH0k7Ve\\n\" +  \"EMoV6Gyt5mjkOkRsEq/PHTz2D9ClPNH0Ot0S1RTVWTL5AtCc+YpzmWax246uIhy7\\n\" +  \"W2RIgQ5bo2rW7MXn58A4bfkDuVRxAz9/2O0n6Mwm/KsbqapGQ/H/lt863fqGsU59\\n\" +  \"2QNxAoGAQNRqfzU5QEoVhuiqzisb5gisSw21pu79TuPoT1sPh1fD5hVgoaFjZBgw\\n\" +  \"Sf7G4R9xDlKmWQzMMpJEjT/7aItoKYHcvyGSYMFC7a8dyIbpW/UXA/bv3P0TTWf8\\n\" +  \"ZMI4mNB3h4IhDAC0ooDby6qWTXPQjl15JTKMyPRJ3EGLjPN7zu0=\\n\" +  \"-----END RSA PRIVATE KEY-----\\n\"; public static void demo() { long sdk = Finance.NewSdk(); Finance.Init(sdk, \"\", \"\"); // 初始化 long ret = 0; int seq = 0; // 从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0(这个值需要记录下来,以便下一次的拉去) int limit = 60; long slice = Finance.NewSlice(); ret = Finance.GetChatData(sdk, seq, limit, null, null, 3, slice); if (ret != 0) { System.out.println(\"getchatdata ret \" + ret); return; } String getchatdata = Finance.GetContentFromSlice(slice); System.out.println(seq + \",拉去的聊天记录密文结果:\" + getchatdata); JSONObject jo = new JSONObject(getchatdata); JSONArray chatdata = jo.getJSONArray(\"chatdata\"); System.out.println(\"消息数:\" + chatdata.toArray().length); for (int i = 0; i < chatdata.toArray().length; i++) { JSONObject data = new JSONObject(chatdata.get(i).toString()); String encryptRandomKey = data.getStr(\"encrypt_random_key\"); String encryptChatMsg = data.getStr(\"encrypt_chat_msg\"); long msg = Finance.NewSlice(); try { // 聊天记录密文解密 String message = RSAUtil.decrypt(encryptRandomKey, priKey); ret = Finance.DecryptData(sdk, message, encryptChatMsg, msg); if (ret != 0) {  System.out.println(\"getchatdata ret \" + ret);  return; } String plaintext = Finance.GetContentFromSlice(msg); System.out.println(\"decrypt ret:\" + ret + \" msg:\" + plaintext); Finance.FreeSlice(msg); JSONObject plaintextJson = new JSONObject(plaintext); // 拉去媒体文件解密 String msgtype = plaintextJson.getStr(\"msgtype\"); if (\"mixed\".equals(msgtype)) {  // 混合消息  JSONArray array = new JSONArray();  JSONObject mixed = new JSONObject(plaintextJson.get(\"mixed\").toString());  JSONArray items = mixed.getJSONArray(\"item\");  for (int j = 0; j < items.toArray().length; j++) { JSONObject item = new JSONObject(items.get(j).toString()); JSONObject content = new JSONObject(item.get(\"content\")); String type = item.getStr(\"type\"); if (\"text\".equals(type)) { item.put(\"content\", content.get(\"content\")); } else { String url = pullMediaFiles(sdk, type, content); item.put(\"content\", url); } array.put(item);  }  JSONObject content = new JSONObject();  content.put(msgtype, array.toString());  plaintextJson.put(msgtype, content.toString()); } else {  pullMediaFiles(sdk, msgtype, plaintextJson); } // 会话内容写入数据库 //System.out.println(plaintextJson); // save(plaintextJson); } catch (Exception e) { e.printStackTrace(); return; } } } // 拉去媒体信息 private static String pullMediaFiles(long sdk, String msgtype, JSONObject plaintextJson) { String[] msgtypeStr = {\"image\", \"voice\", \"video\", \"emotion\", \"file\"}; List<String> msgtypeList = Arrays.asList(msgtypeStr); if (msgtypeList.contains(msgtype)) { String savefileName = \"\"; JSONObject file = new JSONObject(); if (!plaintextJson.isNull(\"msgid\")) { file = plaintextJson.getJSONObject(msgtype); savefileName = plaintextJson.getStr(\"msgid\"); } else { // 混合消息 file = plaintextJson; savefileName = file.getStr(\"md5sum\"); } System.out.println(\"媒体文件信息:\" + file); /* ============ 文件存储目录及文件名 Start ============ */ String suffix = \"\"; switch (msgtype) { case \"image\":  suffix = \".jpg\";  break; case \"voice\":  suffix = \".amr\";  break; case \"video\":  suffix = \".mp4\";  break; case \"emotion\":  int type = (int) file.get(\"type\");  if (type == 1) suffix = \".gif\";  else if (type == 2) suffix = \".png\";  break; case \"file\":  suffix = \".\" + file.get(\"fileext\");  break; } savefileName += suffix; String path = \"/var/data/workwx/\"; String savefile = path + savefileName; File targetFile = new File(savefile); if (!targetFile.getParentFile().exists()) //创建父级文件路径 targetFile.getParentFile().mkdirs(); /* ============ 文件存储目录及文件名 End ============ */ /* ============ 拉去文件 Start ============ */ int i = 0; boolean isSave = true; String indexbuf = \"\", sdkfileid = file.getStr(\"sdkfileid\"); while (true) { long mediaData = Finance.NewMediaData(); int ret = Finance.GetMediaData(sdk, indexbuf, sdkfileid, null, null, 3, mediaData); if (ret != 0) {  System.out.println(\"getmediadata ret:\" + ret);  Finance.FreeMediaData(mediaData);  return null; } System.out.printf(\"getmediadata outindex len:%d, data_len:%d, is_finis:%d\\n\", Finance.GetIndexLen(mediaData), Finance.GetDataLen(mediaData), Finance.IsMediaDataFinish(mediaData)); try {  // 大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。  FileOutputStream outputStream = new FileOutputStream(new File(savefile), true);  outputStream.write(Finance.GetData(mediaData));  outputStream.close(); } catch (Exception e) {  e.printStackTrace(); } if (Finance.IsMediaDataFinish(mediaData) == 1) {  // 已经拉取完成最后一个分片  Finance.FreeMediaData(mediaData);  break; } else {  // 获取下次拉取需要使用的indexbuf  indexbuf = Finance.GetOutIndexBuf(mediaData);  Finance.FreeMediaData(mediaData); } // 若文件大于50M则不保存 if (++i > 100) {  isSave = false;  break; } } /* ============ 拉去文件 End ============ */ if (isSave) { file.put(\"sdkfileid\", savefile); return savefile; } } return null; } public static void main(String[] args) { demo(); }}

对应的解密工具类

package com.feixiang.springbootinit.utils;import lombok.extern.slf4j.Slf4j;import org.apache.commons.codec.binary.Base64;import sun.security.util.DerInputStream;import sun.security.util.DerValue;import javax.crypto.Cipher;import java.math.BigInteger;import java.security.*;import java.security.spec.RSAPrivateCrtKeySpec;import java.util.Arrays;@Slf4j/** * RSA工具类 */public class RSAUtil { /** * RSA私钥解密 * * @param str 加密字符串 * @param privateKey 私钥 * @return 铭文 * @throws Exception 解密过程中的异常信息 */ public static String decrypt(String str, String privateKey) { //log.info(\"密文:{}\", str); //log.info(\"私钥:{}\", privateKey); try { // 调用getPrivateKey解析私钥字符串 PrivateKey key = getPrivateKey(privateKey); return getPrivateKeyByPKCS1(str, key); } catch (Exception e) { log.error(\"RSA解密失败\", e); throw new RuntimeException(\"RSA解密失败\", e); } } /** * 用此方法先获取秘钥 * RSAUtil_PKCS1 * * @param encrypt_random_key 加密的随机密钥 * @param privateKey 私钥对象 * @return 解密后的随机密钥 * @throws Exception 解密过程中的异常信息 */ public static String getPrivateKeyByPKCS1(String encrypt_random_key, PrivateKey privateKey) throws Exception { // 64位解码加密后的字符串 byte[] inputByte = Base64.decodeBase64(encrypt_random_key.getBytes(\"UTF-8\")); Cipher cipher = Cipher.getInstance(\"RSA\"); cipher.init(Cipher.DECRYPT_MODE, privateKey); // RSA解密 - 当长度过长的时候,需要分割后解密 String outStr = new String(getMaxResultDecrypt(inputByte, cipher)); //log.info(\"RSA私钥解密后的数据|outStr:{}\", outStr); return outStr; } /** * 过长的数据进行分组解密 * * @param encryptedBytes 加密的字节数组 * @param cipher 初始化好的Cipher对象 * @return 解密后的字节数组 */ private static byte[] getMaxResultDecrypt(byte[] encryptedBytes, Cipher cipher) throws Exception { int inputLength = encryptedBytes.length; //log.info(\"解密字节数|inputLength:{}\", inputLength); // 动态计算最大解密块大小(基于密钥长度) int maxBlockSize = cipher.getOutputSize(0); // 标识 int offSet = 0; byte[] resultBytes = new byte[0]; byte[] cache; while (inputLength - offSet > 0) { if (inputLength - offSet > maxBlockSize) { cache = cipher.doFinal(encryptedBytes, offSet, maxBlockSize); offSet += maxBlockSize; } else { cache = cipher.doFinal(encryptedBytes, offSet, inputLength - offSet); offSet = inputLength; } // 合并解密结果 resultBytes = Arrays.copyOf(resultBytes, resultBytes.length + cache.length); System.arraycopy(cache, 0, resultBytes, resultBytes.length - cache.length, cache.length); } return resultBytes; } /** * 从PKCS#1格式的PEM字符串解析私钥 * * @param privKeyPEM PEM格式的私钥字符串 * @return 解析后的PrivateKey对象 * @throws Exception 如果私钥格式无效 */ private static PrivateKey getPrivateKey(String privKeyPEM) throws Exception { // 清理PEM格式,移除页眉、页脚和换行符 String keyContent = privKeyPEM .replace(\"-----BEGIN RSA PRIVATE KEY-----\", \"\") .replace(\"-----END RSA PRIVATE KEY-----\", \"\") .replaceAll(\"\\\\s+\", \"\"); // Base64解码为DER格式的字节数组 byte[] encodedKey = Base64.decodeBase64(keyContent); // 解析PKCS#1格式的私钥 DerInputStream derReader = new DerInputStream(encodedKey); DerValue[] seq = derReader.getSequence(0); if (seq.length < 9) { throw new IllegalArgumentException(\"PKCS#1私钥格式不正确,至少需要9个字段\"); } // 提取RSA私钥组件 BigInteger modulus = seq[1].getBigInteger(); BigInteger publicExponent = seq[2].getBigInteger(); BigInteger privateExponent = seq[3].getBigInteger(); BigInteger prime1 = seq[4].getBigInteger(); BigInteger prime2 = seq[5].getBigInteger(); BigInteger exponent1 = seq[6].getBigInteger(); BigInteger exponent2 = seq[7].getBigInteger(); BigInteger coefficient = seq[8].getBigInteger(); // 生成私钥 RSAPrivateCrtKeySpec keySpec = new RSAPrivateCrtKeySpec( modulus, publicExponent, privateExponent, prime1, prime2, exponent1, exponent2, coefficient ); KeyFactory keyFactory = KeyFactory.getInstance(\"RSA\"); return keyFactory.generatePrivate(keySpec); }}

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

三.正式开发

1.设计库表

因为类型太多了,所以设计了一个底表(基本消息表),具体的消息内容存储在不同的表
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

2. 定义微信响应的实体类和VO类

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

package com.feixiang.springbootinit.model.entity.wechat;import com.fasterxml.jackson.annotation.JsonProperty;import lombok.Data;@Data/** * 消息加解密实体类 */public class ChatData { private Long seq; private String msgid; @JsonProperty(\"publickey_ver\") private int publickeyVer; @JsonProperty(\"encrypt_random_key\") private String encryptRandomKey; @JsonProperty(\"encrypt_chat_msg\") private String encryptChatMsg;}
package com.feixiang.springbootinit.model.entity.wechat;import lombok.Data;import java.util.List;/** * 消息拉取响应实体 */@Datapublic class MessagePullResponse { private int errcode; private String errmsg; private List<ChatData> chatdata;}

解密数据对应的VO类

package com.feixiang.springbootinit.model.vo.wechat;import com.feixiang.springbootinit.model.enums.SessionArchiveMsgActionEnum;import com.feixiang.springbootinit.model.enums.SessionArchiveMsgTypeEnum;import lombok.Data;import java.util.List;@Data/** * 会话存档消息解密数据 */public class MsgVO { private String msgid;//消息id,消息的唯一标识,企业可以使用此字段进行消息去重 /** * @see SessionArchiveMsgActionEnum */ private String action;//消息动作,目前有send(发送消息)/recall(撤回消息)/switch(切换企业日志)三种类型 private String from;//消息发送方id。同一企业内容为userid,非相同企业为external_userid。消息如果是机器人发出,也为external_userid private List<String> tolist;//息接收方列表,可能是多个,同一个企业内容为userid,非相同企业为external_userid。数组 private String roomid;//群聊消息的群id。如果是单聊则为空 private Long msgtime;//消息发送时间戳,utc时间,ms单位 /** * @see SessionArchiveMsgTypeEnum */ private String msgtype; /** * 机器人与外部联系人的账号都是external_userid,其中机器人的external_userid是以\"wb\"开头, * 例如:\"wbjc7bDwAAJVylUKpSA3Z5U11tDO4AAA\",外部联系人的external_userid以\"wo\"或\"wm\"开头。 * 如果是机器人发出的消息,可以通过openapi拉取机器人详情:如何获取机器人详情? * 如果是外部联系人发出的消息,可以通过openapi拉取外部联系人详情:如何获取外部联系人详情? * 如果是引用/回复消息,发消息的用户的语言设置是中文,消息内容前面会加上“这是一条引用/回复消息:”,如果发消息的用户的语言设置是英文,消息内容的前面会加上“This is a quote/reply:”。 */ private Long time;//action=switch才有 private String user;//action=switch才有 //msgtype = meeting_voice_call才有 private String voiceid; private meeting_voice_call meeting_voice_call; //msgtype=voip_doc_share 才有 private String voipid; private voip_doc_share voip_doc_share; //msgtype=external_redpacket才有 !! 注意,消息类型为redpacket也有这个 所以这里注释掉 留一个 //private redpacket redpacket; //msgtype=sphfeed 才有 private Long feed_type; private String sph_name; private String feed_desc; //msgtype=voiptext才有 private Long callduration; private Long invitetype; //msgtype=qydiskfile才有 private info info;// ? 文档不清晰 private Text text; private Image image; private Revoke revoke; private Disagree disagree; private Agree agree; private Voice voice; private Video video; private Card card; private Location location; private Emotion emotion; private File file; private Link link; private Weapp weapp; private Chatrecord chatrecord; private Todo todo; private Vote vote; private Collect collect; private Redpacket redpacket; private Meeting meeting; private meeting_notification meeting_notification; private docmsg docmsg; private markdown markdown; private news news; private calendar calendar; private mixed mixed; @Data public static class info { private String filename; } /*@Data public static class redpacket { private Long type; private String wish; private Long totalcnt; private Long totalamount; }*/ @Data public static class voip_doc_share { private String filename; private String md5sum; private Long filesize; private String sdkfileid; } @Data public static class meeting_voice_call { private Long endtime; private String sdkfileid; private List<demofiledata> demofiledata; private List<sharescreendata> sharescreendata; @Data public static class demofiledata { private String filename; private String demooperator; private Long starttime; private Long endtime; } @Data public static class sharescreendata { private String share; private Long starttime; private Long endtime; } } @Data public static class Text { private String content; } @Data public static class Image { private String md5sum; private Long filesize; private String sdkfileid; } @Data public static class Revoke { private String pre_msgid; } @Data public static class Disagree { private String userid; private Long agree_time;//文档不清晰,到底是这个字段 还是下面的字段 ? private Long disagree_time; } @Data public static class Agree { private String userid; private Long agree_time; } @Data public static class Voice { private Long voice_size; private Long play_length; private String sdkfileid; private String md5sum; } @Data public static class Video { private Long filesize; private Long play_length; private String sdkfileid; private String md5sum; } @Data public static class Card { private String corpname; private String userid;//名片所有者的id,同一公司是userid,不同公司是external_userid。String类型 } @Data public static class Location { private Double longitude; private Double latitude; private String address; private String title; private Long zoom; } @Data public static class Emotion { private Integer type;//1表示gif 2表示png private Integer width; private Integer height; private String sdkfileid; private String md5sum; private Long imagesize; } @Data public static class File { private String sdkfileid; private String md5sum; private String filename; private String fileext; private Long filesize; } @Data public static class Link { private String title; private String description; private String link_url; private String image_url; } @Data public static class Weapp { private String title; private String description; private String username; private String displayname; } @Data public static class Chatrecord { private String title; private List<Item> item; @Data public static class Item { private String type;//每条聊天记录的具体消息类型:ChatRecordText/ ChatRecordFile/ ChatRecordImage/ ChatRecordVideo/ ChatRecordLink/ ChatRecordLocation/ ChatRecordMixed …. private Long msgtime; private String content; private Boolean from_chatroom; } } @Data public static class Todo { private String title; private String content; } @Data public static class Vote { private String votetitle; private List<String> voteitem; private Integer votetype; private String voteid; } @Data public static class Collect { private String room_name; private String creator; private String create_time; private String title; private List<Detail> details; @Data public static class Detail { private Long id; private String ques; private String type;//有Text(文本),Number(数字),Date(日期),Time(时间)。String类型 } } @Data public static class Redpacket { private Long type;//1 普通红包、2 拼手气群红包、3 激励群红包 private String wish; private Long totalcnt; private Long totalamount; } @Data public static class Meeting { private String topic; private Long starttime; private Long endtime; private String address; private String remarks; private Long meetingid; } @Data public static class meeting_notification { private Long meetingid; private Long notification_type; private String content; } @Data public static class docmsg { private String title; private String link_url; private String doc_creator; } @Data public static class markdown { private String info;////? 啥格式 } @Data public static class news { private info info; @Data public static class info { private List<item> item; @Data public static class item { private String title; private String description; private String url; private String picurl; } } } @Data public static class calendar { private String title; private String creatorname; private List<String> attendeename; private Long starttime; private Long endtime; private String place; private String remarks; } @Data public static class mixed { private List<item> item; @Data public static class item { private String type;//是上面各类消息的type private String content;//type不同 content不同 json } }}

3.定义动作和消息类型枚举类

package com.feixiang.springbootinit.model.enums;import lombok.AllArgsConstructor;import lombok.Getter;@Getter@AllArgsConstructor/** * 会话存档消息动作枚举 */public enum SessionArchiveMsgActionEnum { SEND(\"send\", \"发送消息\"), RECALL(\"recall\", \"撤回消息\"), SWITCH(\"switch\", \"切换企业日志\"), ; private String code; private String msg; public static String getNameByCode(String code) { SessionArchiveMsgActionEnum[] enums = SessionArchiveMsgActionEnum.values(); for (int i = 0; i < enums.length; i++) { SessionArchiveMsgActionEnum anEnum = enums[i]; if (anEnum.getCode().equalsIgnoreCase(code)) { return anEnum.getMsg(); } } return null; }}
package com.feixiang.springbootinit.model.enums;import lombok.AllArgsConstructor;import lombok.Getter;@Getter@AllArgsConstructor/** * 会话存档消息类型枚举 */public enum SessionArchiveMsgTypeEnum { TEXT(\"text\", \"文本\"), IMAGE(\"image\", \"图片\"), REVOKE(\"revoke\", \"撤回\"), AGREE(\"agree\", \"同意\"), DISAGREE(\"disagree\", \"不同意\"), VOICE(\"voice\", \"语音\"), VIDEO(\"video\", \"视频\"), CARD(\"card\", \"名片\"), LOCATION(\"location\", \"位置\"), EMOTION(\"emotion\", \"表情\"), FILE(\"file\", \"文件\"),// LINK(\"link\", \"链接\"), WEAPP(\"weapp\", \"小程序\"), CHATRECORD(\"chatrecord\", \"会话记录\"), TODO(\"todo\", \"待办\"), VOTE(\"vote\", \"投票\"), COLLECT(\"collect\", \"填表\"), REDPACKET(\"redpacket\", \"红包\"), MEETING(\"meeting\", \"会议邀请\"), MEETING_NOTIFICATION(\"meeting_notification\", \"会议控制\"), DOCMSG(\"docmsg\", \"在线文档\"), MARKDOWN(\"markdown\", \"MarkDown\"), NEWS(\"news\", \"图文\"), CALENDAR(\"calendar\", \"日程\"), MIXED(\"mixed\", \"混合类型\"), MEETING_VOICE_CALL(\"meeting_voice_call\", \"音频存档\"), VOIP_DOC_SHARE(\"voip_doc_share\", \"音频共享文档\"), EXTERNAL_REDPACKET(\"external_redpacket\", \"互通红包\"), SPHFEED(\"sphfeed\", \"视频号\"), VOIPTEXT(\"voiptext\", \"音视频通话\"), QYDISKFILE(\"qydiskfile\", \"微盘文件\"), UNKNOWN(\"unknown\", \"未知\"), ; private String code; private String msg; public static String getNameByCode(String code) { SessionArchiveMsgTypeEnum[] enums = SessionArchiveMsgTypeEnum.values(); for (int i = 0; i < enums.length; i++) { SessionArchiveMsgTypeEnum anEnum = enums[i]; if (anEnum.getCode().equalsIgnoreCase(code)) { return anEnum.getMsg(); } } return null; } /** * 从字符串转换为 SessionArchiveMsgTypeEnum 枚举值 * * @param type 字符串类型 * @return 对应的 SessionArchiveMsgTypeEnum 枚举值 */ public static SessionArchiveMsgTypeEnum fromString(String type) { for (SessionArchiveMsgTypeEnum msgType : SessionArchiveMsgTypeEnum.values()) { if (msgType.code.equalsIgnoreCase(type)) { return msgType; } } return UNKNOWN; }}

4.拉取工具类

package com.feixiang.springbootinit.utils;import cn.hutool.core.util.ObjectUtil;import cn.hutool.json.JSONUtil;import com.feixiang.springbootinit.common.ErrorCode;import com.feixiang.springbootinit.exception.BusinessException;import com.feixiang.springbootinit.exception.ThrowUtils;import com.feixiang.springbootinit.model.entity.wechat.ChatData;import com.feixiang.springbootinit.model.entity.wechat.MessagePullResponse;import com.google.common.util.concurrent.RateLimiter;import com.tencent.wework.Finance;import lombok.extern.slf4j.Slf4j;import org.apache.commons.codec.binary.Base64;import sun.security.util.DerInputStream;import sun.security.util.DerValue;import javax.crypto.Cipher;import java.io.File;import java.io.FileOutputStream;import java.math.BigInteger;import java.security.KeyFactory;import java.security.PrivateKey;import java.security.spec.RSAPrivateCrtKeySpec;import java.util.Arrays;import java.util.List;@Slf4j/** * 企微会话存档工具类 */public class SessionArchiveUtil { // 会话存档SDK信息到线程中 private static ThreadLocal<Long> sdkLocal = new ThreadLocal<>(); private static void setSDK(Long sdk) { sdkLocal.set(sdk); } public static Long getSDK() { return sdkLocal.get(); } /** * 初始化SDK * * @return */ public static Long initSDK(String corpid, String secrectkey) { //判断是sdk已经存在 ThrowUtils.throwIf(ObjectUtil.isNotNull(getSDK()), ErrorCode.OPERATION_ERROR, \"sdk已经存在\"); long sdk = Finance.NewSdk(); setSDK(sdk); long ret = Finance.Init(sdk, corpid, secrectkey); if (ret != 0) { destroy(); } if (ObjectUtil.isNotNull(sdk)) { return sdk; } return sdk; } /** * 销毁SDK */ public static void destroy() { if (sdkLocal.get() != null) { Finance.DestroySdk(sdkLocal.get()); sdkLocal.remove(); } } //限制频率 private static RateLimiter limiter = RateLimiter.create(10);//每分钟不超过600次 ==> 每秒不超过10 /** * 获取存档会话数据 这里是未解密的数据(特定格式) * 不可超过600次/分钟。 * * @param sdk * @param seq * @param limit <=1000 * @param proxy 没有则为空 * @param passwd 没有则为空 * @param timeout s * @return */// public static List getChatData(Long sdk, Long seq, Long limit, String proxy, String passwd, Long timeout) {// if (timeout == null) {// timeout = 5L;// }// if (limit == null) {// limit = 20L;// }// ThrowUtils.throwIf(ObjectUtil.isNull(sdk), ErrorCode.NOT_FOUND_ERROR, \"sdk不能为空\");// ThrowUtils.throwIf(ObjectUtil.isNull(seq), ErrorCode.NOT_FOUND_ERROR, \"seq不能为空\");// //每次使用GetChatData拉取存档前需要调用NewSlice获取一个slice,在使用完slice中数据后,还需要调用FreeSlice释放。// long slice = Finance.NewSlice();// try {// limiter.acquire();// int ret = Finance.GetChatData(sdk.longValue(), seq.longValue(), limit.longValue(), proxy, passwd, timeout.longValue(), slice);// checkRet(ret, \"init sdk err ret\" + ret);// //获取原始未解密数据// String originalDataStr = Finance.GetContentFromSlice(slice);// SessionArchiveChatDataVO sessionArchiveOriginalDataVO = JSONUtil.toBean(originalDataStr, SessionArchiveChatDataVO.class); //解析原始数据// //todo 进行校验的判断// if (sessionArchiveOriginalDataVO.getErrcode() != 0) {// throw new BusinessException(ErrorCode.SYSTEM_ERROR, \"获取会话存档数据失败,错误码:\" + sessionArchiveOriginalDataVO.getErrcode() + \",错误信息:\" + sessionArchiveOriginalDataVO.getErrmsg());// }// List originalDataList = sessionArchiveOriginalDataVO.getChatdata();// return originalDataList;// } finally {// Finance.FreeSlice(slice);// }// } public static List<ChatData> getChatData(Long sdk, Long seq, Long limit, String proxy, String passwd, Long timeout) { if (timeout == null) { timeout = 5L; } if (limit == null) { limit = 20L; } ThrowUtils.throwIf(ObjectUtil.isNull(sdk), ErrorCode.NOT_FOUND_ERROR, \"sdk不能为空\"); ThrowUtils.throwIf(ObjectUtil.isNull(seq), ErrorCode.NOT_FOUND_ERROR, \"seq不能为空\"); //每次使用GetChatData拉取存档前需要调用NewSlice获取一个slice,在使用完slice中数据后,还需要调用FreeSlice释放。 long slice = Finance.NewSlice(); try { limiter.acquire(); int ret = Finance.GetChatData(sdk.longValue(), seq.longValue(), limit.longValue(), proxy, passwd, timeout.longValue(), slice); checkRet(ret, \"init sdk err ret\" + ret); //获取原始未解密数据 String originalDataStr = Finance.GetContentFromSlice(slice); MessagePullResponse messagePullResponse = JSONUtil.toBean(originalDataStr, MessagePullResponse.class); //解析原始数据 //todo 进行校验的判断 if (messagePullResponse.getErrcode() != 0) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, \"获取会话存档数据失败,错误码:\" + messagePullResponse.getErrcode() + \",错误信息:\" + messagePullResponse.getErrmsg()); } List<ChatData> originalDataList = messagePullResponse.getChatdata(); return originalDataList; } finally { Finance.FreeSlice(slice); } } /** * 专门校验Finance接口的 * * @param i * @param msg */ public static void checkRet(int i, String msg) { if (i != 0) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, msg); } } //获取到了消息,我们要对消息进行解密。还记得之前的RSA密钥对了吗,每次往企业微信里面填一次公钥,版本就加一。后面获取数据,解密的话,是需要用相应版本的私钥去解密encrypt_random_key,然后再用它用sdk去解密,得到明文消息。 /** * 读取pkcs1格式的private key * * @param privKeyPEM * @return * @throws Exception */ public static PrivateKey getPrivateKey(String privKeyPEM) { String privKeyPEMnew = privKeyPEM.replaceAll(\"\\\\n\", \"\").replace(\"-----BEGIN RSA PRIVATE KEY-----\", \"\").replace(\"-----END RSA PRIVATE KEY-----\", \"\"); //byte[] bytes = java.util.Base64.getDecoder().decode(privKeyPEMnew);//Illegal base64 character d byte[] bytes = org.apache.commons.codec.binary.Base64.decodeBase64(privKeyPEMnew); try { DerInputStream derReader = new DerInputStream(bytes); DerValue[] seq = derReader.getSequence(0); BigInteger modulus = seq[1].getBigInteger(); BigInteger publicExp = seq[2].getBigInteger(); BigInteger privateExp = seq[3].getBigInteger(); BigInteger prime1 = seq[4].getBigInteger(); BigInteger prime2 = seq[5].getBigInteger(); BigInteger exp1 = seq[6].getBigInteger(); BigInteger exp2 = seq[7].getBigInteger(); BigInteger crtCoef = seq[8].getBigInteger(); RSAPrivateCrtKeySpec keySpec = new RSAPrivateCrtKeySpec(modulus, publicExp, privateExp, prime1, prime2, exp1, exp2, crtCoef); KeyFactory keyFactory = KeyFactory.getInstance(\"RSA\"); PrivateKey privateKey = keyFactory.generatePrivate(keySpec); return privateKey; } catch (Exception e) { throw new RuntimeException(e); } } /** * 解密encrypt_random_key * * @param privateKey * @param encrypt_random_key * @return */ public static String decrptyRandomKey(PrivateKey privateKey, String encrypt_random_key) { Cipher cipher = null; try { cipher = Cipher.getInstance(\"RSA\"); cipher.init(Cipher.DECRYPT_MODE, privateKey); // 64位解码加密后的字符串 byte[] inputArray = Base64.decodeBase64(encrypt_random_key.getBytes(\"UTF-8\")); int inputLength = inputArray.length; // 最大解密字节数,超出最大字节数需要分组加密 int MAX_ENCRYPT_BLOCK = 256; // 标识 int offSet = 0; byte[] resultBytes = {}; byte[] cache = {}; while (inputLength - offSet > 0) { if (inputLength - offSet > MAX_ENCRYPT_BLOCK) {  cache = cipher.doFinal(inputArray, offSet, MAX_ENCRYPT_BLOCK);  offSet += MAX_ENCRYPT_BLOCK; } else {  cache = cipher.doFinal(inputArray, offSet, inputLength - offSet);  offSet = inputLength; } resultBytes = Arrays.copyOf(resultBytes, resultBytes.length + cache.length); System.arraycopy(cache, 0, resultBytes, resultBytes.length - cache.length, cache.length); } return new String(resultBytes); } catch (Exception e) { throw new RuntimeException(e); } } /** * 最后解密数据 * * @param sdk * @param encrypt_key * @param encrypt_chat_msg * @return */ public static String decryptData(long sdk, String encrypt_key, String encrypt_chat_msg) { //每次使用DecryptData解密会话存档前需要调用NewSlice获取一个slice,在使用完slice中数据后,还需要调用FreeSlice释放。 long msg = Finance.NewSlice(); try { int ret = Finance.DecryptData(sdk, encrypt_key, encrypt_chat_msg, msg); checkRet(ret, \"init sdk err ret:\" + ret); return Finance.GetContentFromSlice(msg); } finally { Finance.FreeSlice(msg); } } /** * 获取媒体文件 * * @param sdkfileid * @param proxy * @param passwd * @param timeout * @param savefile //绝对路径,一直到文件名 */ public static void GetMediaData(Long sdk, String sdkfileid, String proxy, String passwd, Long timeout, String savefile) { //拉取媒体文件 if (timeout == null) { timeout = 5L; } ThrowUtils.throwIf(ObjectUtil.isNull(sdkfileid), ErrorCode.NOT_FOUND_ERROR, \"sdkfileid不能为空\"); ThrowUtils.throwIf(ObjectUtil.isNull(savefile), ErrorCode.NOT_FOUND_ERROR, \"savefile不能为空\"); ThrowUtils.throwIf(ObjectUtil.isNull(sdk), ErrorCode.NOT_FOUND_ERROR, \"sdk不能为空\"); //媒体文件每次拉取的最大size为512k,因此超过512k的文件需要分片拉取。若该文件未拉取完整,sdk的IsMediaDataFinish接口会返回0,同时通过GetOutIndexBuf接口返回下次拉取需要传入GetMediaData的indexbuf。 //indexbuf一般格式如右侧所示,”Range:bytes=524288-1048575“,表示这次拉取的是从524288到1048575的分片。单个文件首次拉取填写的indexbuf为空字符串,拉取后续分片时直接填入上次返回的indexbuf即可。 String indexbuf = \"\"; while (true) { //每次使用GetMediaData拉取存档前需要调用NewMediaData获取一个media_data,在使用完media_data中数据后,还需要调用FreeMediaData释放。 long media_data = Finance.NewMediaData(); try { int ret = Finance.GetMediaData(sdk.longValue(), indexbuf, sdkfileid, proxy, passwd, timeout.longValue(), media_data); checkRet(ret, \"getmediadata ret:\" + ret); log.info(\"getmediadata outindex len:{}, data_len:{}, is_finis:{}\\n\", Finance.GetIndexLen(media_data), Finance.GetDataLen(media_data), Finance.IsMediaDataFinish(media_data)); //大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。 FileOutputStream outputStream = new FileOutputStream(new File(savefile), true); outputStream.write(Finance.GetData(media_data)); outputStream.close(); if (Finance.IsMediaDataFinish(media_data) == 1) {  //已经拉取完成最后一个分片  break; } else {  //获取下次拉取需要使用的indexbuf  indexbuf = Finance.GetOutIndexBuf(media_data); } } catch (Exception e) { throw new RuntimeException(e); } finally { Finance.FreeMediaData(media_data); } } }}

4.会话拉取处理程序(我是单线程拉取,多线程处理,这里还待完善)

package com.feixiang.springbootinit.processor;import cn.hutool.core.date.DateUtil;import cn.hutool.json.JSONUtil;import com.feixiang.springbootinit.common.ErrorCode;import com.feixiang.springbootinit.constant.WxConstant;import com.feixiang.springbootinit.exception.BusinessException;import com.feixiang.springbootinit.model.entity.wechat.ChatData;import com.feixiang.springbootinit.model.entity.wechat.QwMessage;import com.feixiang.springbootinit.model.enums.SessionArchiveMsgActionEnum;import com.feixiang.springbootinit.model.enums.SessionArchiveMsgTypeEnum;import com.feixiang.springbootinit.model.vo.wechat.MsgVO;import com.feixiang.springbootinit.service.QwMessageService;import com.feixiang.springbootinit.strategy.MessageProcessingStrategy;import com.feixiang.springbootinit.strategy.MessageStrategyFactory;import com.feixiang.springbootinit.utils.SessionArchiveUtil;import lombok.extern.slf4j.Slf4j;import org.apache.commons.lang3.StringUtils;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import org.springframework.transaction.support.TransactionTemplate;import javax.annotation.PostConstruct;import javax.annotation.PreDestroy;import java.security.PrivateKey;import java.util.ArrayList;import java.util.Collections;import java.util.Date;import java.util.List;import java.util.concurrent.*;import java.util.concurrent.atomic.AtomicInteger;import java.util.concurrent.atomic.AtomicLong;@Slf4j@Componentpublic class SessionArchiveProcessor { // 线程池配置 private static final int IO_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2; // IO密集型任务 private static final int CPU_POOL_SIZE = Runtime.getRuntime().availableProcessors(); // CPU密集型任务 private ExecutorService ioExecutor; private ExecutorService cpuExecutor; // SDK实例(使用AtomicLong确保原子性) private static final AtomicLong sdkInstance = new AtomicLong(0); private static PrivateKey privateKey; private static final Object initLock = new Object(); @Autowired private TransactionTemplate transactionTemplate; // 注入事务模板 /** * 获取全局SDK实例(线程安全) */ public static long getGlobalSdkInstance() { long instance = sdkInstance.get(); if (instance == 0) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, \"企微会话存档SDK未初始化\"); } return instance; } @Autowired private QwMessageService messageService; @Autowired private MessageStrategyFactory messageStrategyFactory; @PostConstruct public void init() { // 初始化线程池,使用更合理的配置 ioExecutor = new ThreadPoolExecutor( IO_POOL_SIZE, IO_POOL_SIZE, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new NamedThreadFactory(\"wx-io-pool\"), new ThreadPoolExecutor.CallerRunsPolicy() ); cpuExecutor = new ThreadPoolExecutor( CPU_POOL_SIZE, CPU_POOL_SIZE, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new NamedThreadFactory(\"wx-cpu-pool\"), new ThreadPoolExecutor.CallerRunsPolicy() ); // 确保SDK只被初始化一次 if (sdkInstance.get() == 0) { synchronized (initLock) { if (sdkInstance.get() == 0) {  try { long instance = SessionArchiveUtil.initSDK(WxConstant.CORP_ID, WxConstant.SECRET_KEY); sdkInstance.set(instance); privateKey = SessionArchiveUtil.getPrivateKey(WxConstant.PRIVKEY); if (privateKey == null) { sdkInstance.set(0); throw new BusinessException(ErrorCode.SYSTEM_ERROR, \"企微解密私钥初始化失败\"); } log.info(\"SessionArchiveProcessor初始化成功,SDK实例: {}\", instance);  } catch (Exception e) { log.error(\"SessionArchiveProcessor初始化失败\", e); sdkInstance.set(0); throw new BusinessException(ErrorCode.SYSTEM_ERROR, \"会话存档处理器初始化失败\");  } } } } } @PreDestroy public void destroy() { // 关闭线程池 shutdownExecutor(ioExecutor, \"IO线程池\"); shutdownExecutor(cpuExecutor, \"CPU线程池\"); // 释放SDK资源 long instance = sdkInstance.getAndSet(0); if (instance != 0) { SessionArchiveUtil.destroy(); log.info(\"SessionArchiveProcessor资源已释放\"); } } public List<QwMessage> processMessages(List<ChatData> chatDataList) { if (chatDataList == null || chatDataList.isEmpty()) { return Collections.emptyList(); } // 检查是否已处理// if (messageService.existsByMsgId(chatData.getMsgId())) {// log.info(\"消息已处理,跳过: {}\", msgVO.getMsgid());// return null;// } List<CompletableFuture<QwMessage>> futures = new ArrayList<>(chatDataList.size()); /** * 线程池会为每个任务分配线程,但同一个 ChatData 只会被一个任务链处理 */ for (ChatData chatData : chatDataList) { CompletableFuture<QwMessage> future = CompletableFuture  .supplyAsync(() -> decryptMessage(chatData), ioExecutor)  .thenApplyAsync(msgVO -> processMessage(msgVO, chatData), cpuExecutor); futures.add(future); } // 合并结果 List<QwMessage> results = new ArrayList<>(chatDataList.size()); for (CompletableFuture<QwMessage> future : futures) { try { QwMessage message = future.get(300, TimeUnit.SECONDS); if (message != null) {  results.add(message); } } catch (TimeoutException e) { log.error(\"消息处理超时,seq: {}\", chatDataList.get(futures.indexOf(future)).getSeq(), e); } catch (Exception e) { log.error(\"处理消息异常\", e); } } return results; } private MsgVO decryptMessage(ChatData chatData) { try { String randomKey = SessionArchiveUtil.decrptyRandomKey(privateKey, chatData.getEncryptRandomKey()); // 使用原子方式获取SDK实例 String decryptMsg = SessionArchiveUtil.decryptData(sdkInstance.get(), randomKey, chatData.getEncryptChatMsg()); return JSONUtil.toBean(decryptMsg, MsgVO.class); } catch (Exception e) { log.error(\"消息解密失败, seq: {}\", chatData.getSeq(), e); throw new BusinessException(ErrorCode.OPERATION_ERROR, \"消息解密失败\"); } } //todo 多线程情况下,考虑加锁,分布式锁或者本地锁 private QwMessage processMessage(MsgVO msgVO, ChatData chatData) { // 使用编程式事务,确保操作原子性 return transactionTemplate.execute(status -> { try { // 1. 构建并保存基础消息 QwMessage message = buildBaseMessage(chatData, msgVO, JSONUtil.toJsonStr(msgVO)); messageService.save(message); // 2. 调用策略处理(如图片/文件/视频/语音消息) MessageProcessingStrategy processor = messageStrategyFactory.getStrategy( SessionArchiveMsgTypeEnum.fromString(msgVO.getMsgtype())); if (processor != null) {  processor.process(msgVO, message); // 策略中的数据库操作会加入当前事务 } return message; } catch (Exception e) { // 发生异常时,手动标记事务回滚 status.setRollbackOnly(); log.error(\"消息处理失败, seq: {}\", chatData.getSeq(), e); return null; } }); } private void shutdownExecutor(ExecutorService executor, String poolName) { try { executor.shutdown(); if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); log.warn(\"{}强制关闭\", poolName); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } } private QwMessage buildBaseMessage(ChatData chatData, MsgVO msgVO, String decryptMsg) { // 1. 参数校验 if (chatData.getSeq() == null || StringUtils.isAnyBlank(msgVO.getMsgid(), msgVO.getAction(), decryptMsg)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, \"参数缺失: chatData.seq, msgVO.msgid, msgVO.action, decryptMsg 不能为空\"); } // 2. 初始化基础对象 QwMessage message = new QwMessage(); message.setSeq(chatData.getSeq()); message.setMsgId(msgVO.getMsgid()); message.setAction(msgVO.getAction()); message.setContent(decryptMsg); // 3. 根据action类型处理不同分支 String action = msgVO.getAction(); if (SessionArchiveMsgActionEnum.SWITCH.getCode().equals(action)) { handleSwitchMessage(message, msgVO); } else if (SessionArchiveMsgActionEnum.SEND.getCode().equals(action)) { handleSendMessage(message, msgVO); } else if (SessionArchiveMsgActionEnum.RECALL.getCode().equals(action)) { handleSendMessage(message, msgVO); // RECALL和SEND处理逻辑相同 } else { log.warn(\"未知的action类型: {}\", action); throw new BusinessException(ErrorCode.PARAMS_ERROR, \"非法的action类型: \" + action); } return message; } // 处理切换企业日志 private void handleSwitchMessage(QwMessage message, MsgVO msgVO) { message.setMsgtime(DateUtil.date(msgVO.getTime())); message.setMsgtype(SessionArchiveMsgActionEnum.SWITCH.getCode()); log.debug(\"构建企业切换消息: msgId={}\", msgVO.getMsgid()); } // 处理发送/撤回消息(SEND和RECALL共用) private void handleSendMessage(QwMessage message, MsgVO msgVO) { message.setFromId(msgVO.getFrom()); message.setMsgtype(msgVO.getMsgtype()); message.setTolist(JSONUtil.toJsonStr(msgVO.getTolist())); message.setRoomid(msgVO.getRoomid()); Date msgTime = DateUtil.date(msgVO.getMsgtime()); message.setMsgtime(msgTime); message.setCreatedTime(new Date()); log.debug(\"构建{}消息: msgId={}, 发送时间={}\", message.getAction(), message.getMsgId(), msgTime); }}/** * 线程工厂 */class NamedThreadFactory implements ThreadFactory { private static final Logger log = LoggerFactory.getLogger(NamedThreadFactory.class); private final String namePrefix; private final AtomicInteger threadNumber = new AtomicInteger(1); NamedThreadFactory(String namePrefix) { this.namePrefix = namePrefix; } public Thread newThread(Runnable r) { Thread thread = new Thread(r, namePrefix + \"-\" + threadNumber.getAndIncrement()); log.info(\"创建线程: {}\", thread.getName()); return thread; }}

5.消息策略处理

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

6.常量类

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

7.启动测试(单线程拉取,多线程处理消息)

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

7.启动测试(多线程拉取,多线程处理消息)

这里需要加锁,虽然数据库做了唯一约束
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java

8.拉取的媒体如下

企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java
企业微信会话存档保姆开发指南(拉取文本,图片,视频,语音等)_企业微信会话存档 java