下一步

企业微信存档会话

免费试用
 
企业微信存档会话
发布日期:2025-06-07 21:41:07 浏览次数: 115 来源:Fantasy小平

企业微信可以使用会话存档,然后我们可以使用企业微信提供的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);        }    }

注意接口使用了限流。

对应的实体类:

@Datapublic class SessionArchiveChatDataVO{
private Integer errcode; private String errmsg;
private List<ChatData> chatdata;
@Data 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 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:

   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字符串。该内容对应的实体类为:

@Datapublic class SessionArchiveMsgVO {
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 } }}

枚举类:

@Getter@AllArgsConstructorpublic 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; }}
@Getter@AllArgsConstructorpublic 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 { //获取下次拉取需要使用的indexbuf indexbuf = 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() { @Override public Thread newThread(Runnable r) { return new Thread(null,r,"qw_session_archive_",1024 * 1024 * 50);//50M } };
RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();
            //核心线程和最大线程设置为1  ThreadPool = 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;    }
@Override 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企业

 
扫码咨询