微信扫码,关注“企微云”公众号完成申请
扫码加专属顾问企业微信,我们将协助您进行线上体验!
我要投稿
企业微信可以使用会话存档,然后我们可以使用企业微信提供的sdk把会话数据获取过来。
首先去下载对应版本的jdk,我选用linux版下面的java实现。
会话存档的开启,需要填写一个RSA公钥,因此我们先要生成一个RSA密钥对,它的要求是:使用模值为2048bit的秘钥,使用PKCS1,输出格式选择PEM/Base64。
这一句话很关键,因为java默认生成的是1024位的PKCS8。
这里我在这个网站直接生成密钥对:http://web.chacuo.net/netrsakeypair
接下来是开发环节。
在服务器上新建一个lib,如/usr/local/xxx/lib,把libWeWorkFinanceSdk_Java.so放入该目录下
springboot项目启动参数加上:
-Djava.library.path=/usr/local/xxx/lib
把Finance.java放入com.tencent.wework包下。
sdk在每个线程中可以保留一个实例,一直使用,所以将初始化sdk和销毁sdk的工作放在代码最外层,即:
long sdk = SessionArchiveUtil.initSDK();try{ xxx 存档会话接口调用和业务处理}finally{ SessionArchiveUtil.destroy();}SessionArchiveUtil:
private static ThreadLocal<Long> sdkLocal = new ThreadLocal<>();private static void setSDK(Long sdk){sdkLocal.set(sdk);}public static Long getSDK(){return sdkLocal.get();}private static Long initSDK(){ParamCheckUtil.objectNotNull(sdkLocal.get(),"sdk已经存在");long sdk = Finance.NewSdk();setSDK(sdk);return sdk;}public static void destroy(){if(sdkLocal.get()!= null) {Finance.DestroySdk(sdkLocal.get());sdkLocal.remove();}}
这里使用ThreadLocal来做,简单方便。
获取聊天数据(也是在SessionArchiveUtil):
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<SessionArchiveChatDataVO.ChatData> getChatData(Long sdk, Long seq, Long limit, String proxy, String passwd, Long timeout){ if(timeout == null){ timeout = 5L; } if(limit == null){ limit = 20L; } ParamCheckUtil.objectNull(sdk,"sdk不能为空"); ParamCheckUtil.objectNull(seq,"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 = JSON.parseObject(originalDataStr, SessionArchiveChatDataVO.class); QYWXUtil.checkError(sessionArchiveOriginalDataVO); List<SessionArchiveChatDataVO.ChatData> originalDataList = sessionArchiveOriginalDataVO.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(ResponseEnum.FAIL.getCode(),msg); } }注意接口使用了限流。
对应的实体类:
public class SessionArchiveChatDataVO{private Integer errcode;private String errmsg;private List<ChatData> chatdata;public static class ChatData {private Long seq;private String msgid;//消息id,消息的唯一标识,企业可以使用此字段进行消息去重 msgid以_external结尾的消息,表明该消息是一条外部消息。msgid以_updown_stream结尾的消息,表明该消息是一条上下游消息。private Integer publickey_ver;private String encrypt_random_key;private String encrypt_chat_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 dbyte[] 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:
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);}}
最后解密数据:
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); } }获得的这个解密内容是一个json字符串。该内容对应的实体类为:
public class SessionArchiveMsgVO {private String msgid;//消息id,消息的唯一标识,企业可以使用此字段进行消息去重/*** @see SessionArchiveMsgActionEnum*/private String action;//消息动作,目前有send(发送消息)/recall(撤回消息)/switch(切换企业日志)三种类型private String from;//消息发送方id。同一企业内容为userid,非相同企业为external_userid。消息如果是机器人发出,也为external_useridprivate 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;public static class info {private String filename;}/*@Datapublic static class redpacket {private Long type;private String wish;private Long totalcnt;private Long totalamount;}*/public static class voip_doc_share {private String filename;private String md5sum;private Long filesize;private String sdkfileid;}public static class meeting_voice_call{private Long endtime;private String sdkfileid;private List<demofiledata> demofiledata;private List<sharescreendata> sharescreendata;public static class demofiledata {private String filename;private String demooperator;private Long starttime;private Long endtime;}public static class sharescreendata {private String share;private Long starttime;private Long endtime;}}public static class Text {private String content;}public static class Image {private String md5sum;private Long filesize;private String sdkfileid;}public static class Revoke {private String pre_msgid;}public static class Disagree {private String userid;private Long agree_time;//文档不清晰,到底是这个字段 还是下面的字段 ?private Long disagree_time;}public static class Agree {private String userid;private Long agree_time;}public static class Voice {private Long voice_size;private Long play_length;private String sdkfileid;private String md5sum;}public static class Video {private Long filesize;private Long play_length;private String sdkfileid;private String md5sum;}public static class Card {private String corpname;private String userid;//名片所有者的id,同一公司是userid,不同公司是external_userid。String类型}public static class Location {private Double longitude;private Double latitude;private String address;private String title;private Long zoom;}public static class Emotion {private Integer type;//1表示gif 2表示pngprivate Integer width;private Integer height;private String sdkfileid;private String md5sum;private Long imagesize;}public static class File {private String sdkfileid;private String md5sum;private String filename;private String fileext;private Long filesize;}public static class Link {private String title;private String description;private String link_url;private String image_url;}public static class Weapp {private String title;private String description;private String username;private String displayname;}public static class Chatrecord {private String title;private List<Item> item;public static class Item {private String type;//每条聊天记录的具体消息类型:ChatRecordText/ ChatRecordFile/ ChatRecordImage/ ChatRecordVideo/ ChatRecordLink/ ChatRecordLocation/ ChatRecordMixed ….private Long msgtime;private String content;private Boolean from_chatroom;}}public static class Todo {private String title;private String content;}public static class Vote {private String votetitle;private List<String> voteitem;private Integer votetype;private String voteid;}public static class Collect {private String room_name;private String creator;private String create_time;private String title;private List<Detail> details;public static class Detail {private Long id;private String ques;private String type;//有Text(文本),Number(数字),Date(日期),Time(时间)。String类型}}public static class Redpacket {private Long type;//1 普通红包、2 拼手气群红包、3 激励群红包private String wish;private Long totalcnt;private Long totalamount;}public static class Meeting {private String topic;private Long starttime;private Long endtime;private String address;private String remarks;private Long meetingid;}public static class meeting_notification {private Long meetingid;private Long notification_type;private String content;}public static class docmsg {private String title;private String link_url;private String doc_creator;}public static class markdown {private String info;////? 啥格式}public static class news {private info info;public static class info {private List<item> item;public static class item {private String title;private String description;private String url;private String picurl;}}}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;}public static class mixed {private List<item> item;public static class item {private String type;//是上面各类消息的typeprivate String content;//type不同 content不同 json}}}
枚举类:
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","微盘文件"),;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;}}
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;}}
获取媒体文件:
/*** 获取媒体文件* @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;}ParamCheckUtil.stringEmpty(sdkfileid,"sdkfileid不能为空");ParamCheckUtil.stringEmpty(savefile,"savefile不能为空");ParamCheckUtil.objectNull(sdk,"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 {//获取下次拉取需要使用的indexbufindexbuf = Finance.GetOutIndexBuf(media_data);}} catch(Exception e){throw new RuntimeException(e);}finally {Finance.FreeMediaData(media_data);}}}
对于使用sdk,可能会存在报错,甚至直接导致jvm崩溃:
A fatal error has been detected by the Java Runtime Environment:## SIGSEGV (0xb) at pc=0x00007f65e5bdcdcd, pid=19004, tid=0x00007f65e75fd700
首先是要确保每个初始化获得的sdk,不存在并发调用的情况。也就是比如初始化一个sdk=100的量,在线程A和线程B之间都在并发调用,这是不允许的,只允许一个线程去使用和释放。
排除了这个,依旧报错,可以增大jvm堆内存和线程栈大小:
#!/bin/bashnohup java -Xmx512m -Xmn256m -Xss20m -jar -Dspring.profiles.active=prd -Dlogging.config=./logback-prd.xml -Djava.library.path=/usr/local/xxx/lib xxx-0.0.1-SNAPSHOT.jar > console.log 2>&1 &tail -f console.log
特别是线程栈大小。猜测是由于一次要处理多条数据,且是2048位的RSA,对内存占用很大。
但是如果继续上调线程栈大小,也不可取。因为tomcat本身有很多线程、spring有内置的很多线程、还有redis连接线程、数据库连接等等。这些线程栈都变成30m或者50m的话,内存很快就会消耗光。因此最好是使用线程池,只要是跟会话存档sdk打交道的地方,都用线程池去执行。
定义线程池:
private synchronized static ThreadPoolExecutor getThreadPool(){if(ThreadPool == null){//使用线程池的目的,是由于企业微信的sdk,需要设置线程栈大小 但是统一调大了太耗费内存ArrayBlockingQueue runnableTaskQueue = new ArrayBlockingQueue(4096);ThreadFactory threadFactory = new ThreadFactory() {public Thread newThread(Runnable r) {return new Thread(null,r,"qw_session_archive_",1024 * 1024 * 50);//50M}};RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();//核心线程和最大线程设置为1ThreadPool = new ThreadPoolExecutor(1, 1, 50,TimeUnit.MINUTES,runnableTaskQueue,threadFactory, rejectedExecutionHandler);}return ThreadPool;}
封装任务:
public class SessionArchiveChatDataDecryptCallable implements Callable<String> {private SessionArchiveChatData encryptData;private Long sdk;public SessionArchiveChatDataDecryptCallable(SessionArchiveChatData encryptData,Long sdk){this.encryptData = encryptData;this.sdk = sdk;}public String call() throws Exception {SessionArchiveMapper sessionArchiveMapper = ApplicationContextHolder.getBean(SessionArchiveMapper.class);SessionArchive sessionArchive = sessionArchiveMapper.selectOne(new LambdaQueryWrapper<SessionArchive>().eq(SessionArchive::getCorpId,encryptData.getCorpId()).eq(SessionArchive::getVersion,encryptData.getPublickeyVer()));ParamCheckUtil.objectNull(sessionArchive, "版本" + encryptData.getPublickeyVer() + "的对应公钥私钥没有" );String privateKeyStr = sessionArchive.getPrivateKey();ParamCheckUtil.stringEmpty(privateKeyStr,"私钥为空");PrivateKey privateKey = SessionArchiveUtil.getPrivateKey(privateKeyStr);String encrypt_key = SessionArchiveUtil.decrptyRandomKey(privateKey, encryptData.getEncryptRandomKey());String decryptMsg = SessionArchiveUtil.decryptData(sdk,encrypt_key,encryptData.getEncryptChatMsg());return decryptMsg;}}
提交到线程池:
public static Future<String> submit(SessionArchiveChatDataDecryptCallable callable){ return getThreadPool().submit(callable); }WeSCRM专注2B场景的SCRM系统
产品:企微SCRM系统+微信机器人+私域陪跑服务
承诺:产品免费试用七天,验证效果再签署服务协议。零风险落地企微SCRM,已交付6000+ 2B企业
2025-04-17
2025-07-18
2025-09-15
2025-07-18
2025-04-12
2025-05-22
2025-09-05
2025-09-12
2025-02-11
2025-02-12