Java开发企业微信会话存档功能笔记小结(企业内部开发角度)

🏷️个人主页浅沫云归

🏷️系列专栏Java源码解读-专栏

🏷️个人学习笔记,若有缺误,欢迎评论区指正

目录

1.前言

2.环境配置

2.1.开启会话存档功能

2.2.设置服务人员范围

2.3.配置接受事件服务器

2.3.1服务回调接口示例

2.4.添加可信ip地址

2.5.配置加解密公钥

2.6.获取会话存档应用的Secret

3.系统开发

3.1.架构设计

3.2.库表设计

3.3.核心代码实现

3.2.1.会话存档SDK

3.2.2.会话存档Finance类

3.2.3.使用前初始化Finance实例

3.2.4.使用Finance拉取存档消息内容

3.2.5.解密拉取到的内容

3.2.6.媒体资源文件拉取

4.常见问题汇总


1.前言

为保障服务质量、提升协作效率、并满足日益严格的监管合规要求,企业微信提供的「会话内容存档」功能已成为众多企业进行客户服务管理和内部风控的重要工具。该功能允许企业基于预设规则,通过官方API获取指定员工的工作沟通内容。

笔者近期在企业应用开发中,从企业内部开发角度完整实践了该功能从环境搭建到系统集成的整个流程。这并非一项开箱即用的功能,涉及到相对复杂的配置、回调接收、数据解密以及与企业自身业务系统的整合,过程中也遇到了一些值得记录的**"坑"和优化点**,这里和大家分享一下。

参考文档:

使用前帮助 - 文档 - 企业微信开发者中心

企业微信会话存档详细搭建以及问题处理_企业微信会话归档 设计-CSDN博客

2.环境配置

在进行存档系统的项目开发前,需要在企业微信管理后台进行一系列的配置,包括存档功能的成员开启范围,可信ip地址,回调服务设置,加解密公私钥

2.1.开启会话存档功能

登录管理员账户,在企业微信管理后台配置开启会话内容存档

会话存档服务并不是免费的,所以开启后需要付费购买人数

2.2.设置服务人员范围

然后打开会话内容存档应用,设置服务人员范围,被配置的设置服务人员的企业微信内部聊天以及和企业外部联系人(同意会话存档)的聊天内容都会被企业微信官方存档

2.3.配置接受事件服务器

接下来配置接受事件服务器,接受事件服务器负责接收来自企业微信的消息通知,每当存档范围内的人员有新聊天信息产生时,企业微信会发起回调请求去通知我们的服务。

配置接受事件服务器需要URL,Token,EncodingAESKey三个参数,其中URL为我们自己的服务回调接口,Token和EncodingAESKey都可以随便生成(要保留,程序验签会用到)。

2.3.1服务回调接口示例

以下为回调接口的代码示例,仅供参考,加解密工具类:

java 复制代码
public class WXBizMsgCrypt {
	static Charset CHARSET = Charset.forName("utf-8");
	Base64 base64 = new Base64();
	byte[] aesKey;
	String token;
	String receiveid;

	/**
	 * 构造函数
	 * @param token 企业微信后台,开发者设置的token
	 * @param encodingAesKey 企业微信后台,开发者设置的EncodingAESKey
	 * @param receiveid, 不同场景含义不同,详见文档
	 * 
	 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
	 */
	public WXBizMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException {
		if (encodingAesKey.length() != 43) {
			throw new AesException(AesException.IllegalAesKey);
		}

		this.token = token;
		this.receiveid = receiveid;
		aesKey = Base64.decodeBase64(encodingAesKey + "=");
	}

	// 生成4个字节的网络字节序
	byte[] getNetworkBytesOrder(int sourceNumber) {
		byte[] orderBytes = new byte[4];
		orderBytes[3] = (byte) (sourceNumber & 0xFF);
		orderBytes[2] = (byte) (sourceNumber >> 8 & 0xFF);
		orderBytes[1] = (byte) (sourceNumber >> 16 & 0xFF);
		orderBytes[0] = (byte) (sourceNumber >> 24 & 0xFF);
		return orderBytes;
	}

	// 还原4个字节的网络字节序
	int recoverNetworkBytesOrder(byte[] orderBytes) {
		int sourceNumber = 0;
		for (int i = 0; i < 4; i++) {
			sourceNumber <<= 8;
			sourceNumber |= orderBytes[i] & 0xff;
		}
		return sourceNumber;
	}

	// 随机生成16位字符串
	String getRandomStr() {
		String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
		Random random = new Random();
		StringBuffer sb = new StringBuffer();
		for (int i = 0; i < 16; i++) {
			int number = random.nextInt(base.length());
			sb.append(base.charAt(number));
		}
		return sb.toString();
	}

	/**
	 * 对明文进行加密.
	 * 
	 * @param text 需要加密的明文
	 * @return 加密后base64编码的字符串
	 * @throws AesException aes加密失败
	 */
	String encrypt(String randomStr, String text) throws AesException {
		ByteGroup byteCollector = new ByteGroup();
		byte[] randomStrBytes = randomStr.getBytes(CHARSET);
		byte[] textBytes = text.getBytes(CHARSET);
		byte[] networkBytesOrder = getNetworkBytesOrder(textBytes.length);
		byte[] receiveidBytes = receiveid.getBytes(CHARSET);

		// randomStr + networkBytesOrder + text + receiveid
		byteCollector.addBytes(randomStrBytes);
		byteCollector.addBytes(networkBytesOrder);
		byteCollector.addBytes(textBytes);
		byteCollector.addBytes(receiveidBytes);

		// . + pad: 使用自定义的填充方式对明文进行补位填充
		byte[] padBytes = PKCS7Encoder.encode(byteCollector.size());
		byteCollector.addBytes(padBytes);

		// 获得最终的字节流, 未加密
		byte[] unencrypted = byteCollector.toBytes();

		try {
			// 设置加密模式为AES的CBC模式
			Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
			SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
			IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16);
			cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);

			// 加密
			byte[] encrypted = cipher.doFinal(unencrypted);

			// 使用BASE64对加密后的字符串进行编码
			String base64Encrypted = base64.encodeToString(encrypted);

			return base64Encrypted;
		} catch (Exception e) {
			e.printStackTrace();
			throw new AesException(AesException.EncryptAESError);
		}
	}

	/**
	 * 对密文进行解密.
	 * 
	 * @param text 需要解密的密文
	 * @return 解密得到的明文
	 * @throws AesException aes解密失败
	 */
	String decrypt(String text) throws AesException {
		byte[] original;
		try {
			// 设置解密模式为AES的CBC模式
			Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
			SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES");
			IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
			cipher.init(Cipher.DECRYPT_MODE, key_spec, iv);

			// 使用BASE64对密文进行解码
			byte[] encrypted = Base64.decodeBase64(text);

			// 解密
			original = cipher.doFinal(encrypted);
		} catch (Exception e) {
			e.printStackTrace();
			throw new AesException(AesException.DecryptAESError);
		}

		String xmlContent, from_receiveid;
		try {
			// 去除补位字符
			byte[] bytes = PKCS7Encoder.decode(original);

			// 分离16位随机字符串,网络字节序和receiveid
			byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);

			int xmlLength = recoverNetworkBytesOrder(networkOrder);

			xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);
			from_receiveid = new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length),
					CHARSET);
		} catch (Exception e) {
			e.printStackTrace();
			throw new AesException(AesException.IllegalBuffer);
		}

		// receiveid不相同的情况
		if (!from_receiveid.equals(receiveid)) {
			throw new AesException(AesException.ValidateCorpidError);
		}
		return xmlContent;

	}

	/**
	 * 将企业微信回复用户的消息加密打包.
	 * <ol>
	 * 	<li>对要发送的消息进行AES-CBC加密</li>
	 * 	<li>生成安全签名</li>
	 * 	<li>将消息密文和安全签名打包成xml格式</li>
	 * </ol>
	 * 
	 * @param replyMsg 企业微信待回复用户的消息,xml格式的字符串
	 * @param timeStamp 时间戳,可以自己生成,也可以用URL参数的timestamp
	 * @param nonce 随机串,可以自己生成,也可以用URL参数的nonce
	 * 
	 * @return 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串
	 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
	 */
	public String EncryptMsg(String replyMsg, String timeStamp, String nonce) throws AesException {
		// 加密
		String encrypt = encrypt(getRandomStr(), replyMsg);

		// 生成安全签名
		if (timeStamp == "") {
			timeStamp = Long.toString(System.currentTimeMillis());
		}

		String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt);

		// System.out.println("发送给平台的签名是: " + signature[1].toString());
		// 生成发送的xml
		String result = XMLParse.generate(encrypt, signature, timeStamp, nonce);
		return result;
	}

	/**
	 * 检验消息的真实性,并且获取解密后的明文.
	 * <ol>
	 * 	<li>利用收到的密文生成安全签名,进行签名验证</li>
	 * 	<li>若验证通过,则提取xml中的加密消息</li>
	 * 	<li>对消息进行解密</li>
	 * </ol>
	 * 
	 * @param msgSignature 签名串,对应URL参数的msg_signature
	 * @param timeStamp 时间戳,对应URL参数的timestamp
	 * @param nonce 随机串,对应URL参数的nonce
	 * @param postData 密文,对应POST请求的数据
	 * 
	 * @return 解密后的原文
	 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
	 */
	public String DecryptMsg(String msgSignature, String timeStamp, String nonce, String postData)
			throws AesException {

		// 密钥,公众账号的app secret
		// 提取密文
		Object[] encrypt = XMLParse.extract(postData);

		// 验证安全签名
		String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt[1].toString());

		// 和URL中的签名比较是否相等
		// System.out.println("第三方收到URL中的签名:" + msg_sign);
		// System.out.println("第三方校验签名:" + signature);
		if (!signature.equals(msgSignature)) {
			throw new AesException(AesException.ValidateSignatureError);
		}

		// 解密
		String result = decrypt(encrypt[1].toString());
		return result;
	}

	/**
	 * 验证URL
	 * @param msgSignature 签名串,对应URL参数的msg_signature
	 * @param timeStamp 时间戳,对应URL参数的timestamp
	 * @param nonce 随机串,对应URL参数的nonce
	 * @param echoStr 随机串,对应URL参数的echostr
	 * 
	 * @return 解密之后的echostr
	 * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
	 */
	public String VerifyURL(String msgSignature, String timeStamp, String nonce, String echoStr)
			throws AesException {
		String signature = SHA1.getSHA1(token, timeStamp, nonce, echoStr);

		if (!signature.equals(msgSignature)) {
			throw new AesException(AesException.ValidateSignatureError);
		}

		String result = decrypt(echoStr);
		return result;
	}

}

接口controller类代码:

java 复制代码
    @GetMapping("")
    public void verify(@RequestParam("msg_signature") String msgSignature,
                       @RequestParam("timestamp") String timestamp,
                       @RequestParam("nonce") String nonce,
                       @RequestParam("echostr") String echostr,
                       HttpServletResponse response) throws AesException, IOException {
        System.out.println("msgSignature:" + msgSignature);
        System.out.println("timestamp:" + timestamp);
        System.out.println("nonce:" + nonce);
        System.out.println("echostr:" + echostr);
        WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
                刚刚配置事件服务器配置的Token,
                刚刚配置事件服务器配置的AESKEY,
                你的企业id);
        response.getWriter().write(wxcpt.VerifyURL(msgSignature, timestamp,
                nonce, echostr));
    }

2.4.添加可信ip地址

可信ip主要是限制有哪些ip可以调用企业微信的会话内容存档功能相关的接口,这里填成你服务所在的机器外网ip或者域名即可

2.5.配置加解密公钥

自己生成一下加解密的公私钥,可以在这个工具网站生成netrsakeypair

把生成的公钥复制到后台配置好

2.6.获取会话存档应用的Secret

需要记住这里的会话存档应用的Secret,我们后续程序去拉取会话存档数据就需要用到这个Secret

3.系统开发

3.1.架构设计

系统分成了业务接口服务和数据拉取服务两部分,其中业务接口服务主要负责企业微信的验签和通知回调接口,以及针对企业内部服务开放的一些聊天记录查询接口。而数据拉取服务这里主要通过定时任务实现,定期拉取存档内容进行本地持久化。

我们企业微信回调网关接收到企业微信的消息通知回调请求时,会转发请求到业务接口服务,业务接口服务会向消息拉取事件表中插入拉取事件,随后定时任务通过定时轮询查询未处理的拉取事件,如果存在未处理的拉取事件,则主动去拉取会话存档内容(从上次拉取数据的最后序号seq开始),拉取完成后再更新拉取事件的状态。需要注意这里消息拉取服务是单例的,不能起多个任务实例来并发拉取,可能会出现重复拉取消息的情况。

对外接口服务主要就是提供一些对内接口供公司内部服务使用以及对外的回调接口,这里就不多赘述了,接下来主要讲解数据拉取服务的设计思路,我们在设计初期就认为其核心挑战在于 **"消息可靠性",**也就是不能丢消息,围绕这一核心目标,数据拉取服务的拉取逻辑设计如下:

  1. 判断是否存在拉取事件
  2. 获取系统seq记录信息
  3. 拉取存档消息内容
  4. 存档内容解密
  5. 数据预处理(排序+去重)
  6. 消息处理
  7. 更新拉取事件状态,更新seq记录

因为后续存档消息内容处理以及记录seq都是在一个事务中,当发生异常时会进行事务回滚,系统下次再次执行会重新从同样的seq位置尝试,如果仍然存在异常,那么消息拉取会一直堵塞在这里。这样做的好处就是不会丢失消息,出现异常程序不会继续往后拉取新的消息,舍弃掉了部分及时性,出现问题需要开发人员介入修复。

3.2.库表设计

库表主要分为消息拉取事件表,基础消息表,以及具体到各类型的消息表(如文本消息,图片消息,文件消息,表情包消息,视频消息等等)。

其中基础消息表存储了系统所有存档的消息,可作为聊天记录查询的分页信息表,而消息的具体消息内容则分散存储在不同表,当我们分页获取到基本消息后,可并发去查询不同类型的消息表来获取到消息的具体内容。

3.3.核心代码实现

3.2.1.会话存档SDK

可在官网下载必要的SDK,我这里使用的是2.0版本

获取会话内容 - 文档 - 企业微信开发者中心获取会话内容 - 文档 - 企业微信开发者中心

下载完成后解压到自己项目的resource资源目录的wechat.sdk目录下,可以把linux和window的sdk都解压进来(后续在linux上部署程序的时候,就需要拷贝库文件到linux机器上再加载)

3.2.2.会话存档Finance类

新建包com.tencent.wework,拷贝官方提供的Java案例项目中的Finance工具类(你也可以直接复制我的)

java 复制代码
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 java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;

public class Finance {
    private static boolean isLibraryLoaded = false;

    public native static long NewSdk();

    /**
     * 初始化函数
     * Return值=0表示该API调用成功
     *
     * @param sdk			NewSdk返回的sdk指针
     * @param corpid      调用企业的企业id,例如:wwd08c8exxxx5ab44d,可以在企业微信管理端--我的企业--企业信息查看
     * @param secret		聊天内容存档的Secret,可以在企业微信管理端--管理工具--聊天内容存档查看
     * @return 返回是否初始化成功
     * 0   - 成功
     * !=0 - 失败
     */
    public native static int Init(long sdk, String corpid, String secret);

    /**
     * 拉取聊天记录函数
     * Return值=0表示该API调用成功
     *
     * @param  sdk				NewSdk返回的sdk指针
     * @param  seq				从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0
     * @param  limit			一次拉取的消息条数,最大值1000条,超过1000条会返回错误
     * @param  proxy			使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
     * @param  passwd			代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
     * @param  chatData		返回本次拉取消息的数据,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  sdk				NewSdk返回的sdk指针
     * @param  sdkField		从GetChatData返回的聊天消息中,媒体消息包括的sdkfileid
     * @param  proxy			使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
     * @param  passwd			代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
     * @param  indexbuf		媒体消息分片拉取,需要填入每次拉取的索引信息。首次不需要填写,默认拉取512k,后续每次调用只需要将上次调用返回的outindexbuf填入即可。
     * @param  mediaData		返回本次拉取的媒体数据.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  encrypt_key, getchatdata返回的encrypt_key
     * @param  encrypt_msg, getchatdata返回的content
     * @param  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);


    public static boolean isWindows() {
        String osName = System.getProperties().getProperty("os.name");
        System.out.println("current system is : " + osName);
        return osName.toUpperCase().indexOf("WINDOWS") != -1;
    }

    /**
     * 加载本地Native动态库
     */
    static {
        if (!isLibraryLoaded) {
            synchronized (Finance.class) {
                if (!isLibraryLoaded) {
                    loadLibrary();
                    isLibraryLoaded = true;
                }
            }
        }
    }

    public static void loadLibrary(){
        if(isWindows()) {
            // window系统,这里指定库文件的路径即可
            System.load("E:\\jars\\msgaudit\\WeWorkFinanceSdk.dll");
        }else {
            // 如果使用linux系统,打成jar包后无法使用包内的so文件需要先将.so文件复制到linux目录下,然后再加载
            try {
                String newSoPath = loadlib("/usr/lib/libWeWorkFinanceSdk_Java.so","/wechat/sdk/libWeWorkFinanceSdk_Java.so");
                System.load(newSoPath +"/" + "libWeWorkFinanceSdk_Java.so");
            } catch (IOException e) {
                e.printStackTrace();
            }

        }
    }

    public static String loadlib(String fileName,String sourcePath) throws IOException {
        InputStream in = Finance.class.getResourceAsStream(sourcePath);

        byte[] buffer = new byte[1024];
        File temp = new File(fileName);
        Files.deleteIfExists(temp.toPath());
        FileOutputStream fos = new FileOutputStream(temp);
        int read = -1;
        while((read = in.read(buffer)) != -1) {
            fos.write(buffer, 0, read);
        }
        fos.close();
        in.close();
        String abPath = temp.getAbsolutePath();
        return abPath.substring(0,abPath.lastIndexOf("/"));
    }
}

注意需要修改FInance类中loadLibrary方法的库文件路径

3.2.3.使用前初始化Finance实例

定义配置类,或者在你的业务bean中,定义如下初始化逻辑

java 复制代码
    @PostConstruct
    public void init() {
        // 在这里进行SDK的初始化操作
        System.out.println("Initializing SDK...");
        sdk = Finance.NewSdk();
        int init = Finance.Init(
                sdk,
                公司id,
                会话存档应用的密钥(前文获取的)
        );

        if (init != 0) {
            Finance.DestroySdk(sdk);
            LoggerUtils.error(this.getClass(), "初始化sdk失败,失败消息为: ret = " + init);
            throw new RuntimeException("初始化sdk失败,失败消息为: ret = " + init);
        }
    }

3.2.4.使用Finance拉取存档消息内容

定义消息拉取方法,第一次拉取参数startSeq可以传递0,startSeq就是一个查询的分页参数,代表你要从什么位置开始查。这个方法拉取到的内容为JSON字符串,关于拉取到的数据格式的相关文档:获取会话记录数据 - 文档 - 企业微信开发者中心

java 复制代码
    private String getChatData(Long startSeq) {
        long limit = {单次查询的数据量,比如50}
        long slice = Finance.NewSlice();
        int ret = Finance.GetChatData(sdk, startSeq, limit, GlobalConstants.EMPTY_STRING, GlobalConstants.EMPTY_STRING, 1000, slice);
        if (ret != 0) {
            System.out.println("调用sdk拉取消息接口失败,失败消息为 ret = " + ret);
            XxlJobLogger.log("调用sdk拉取消息接口失败,失败消息为 ret = " + ret);
            Finance.FreeSlice(slice);
            return GlobalConstants.EMPTY_STRING;
        }
        return Finance.GetContentFromSlice(slice);
    }

3.2.5.解密拉取到的内容

定义消息解密方法,用于解密数据并反序列化消息内容:

java 复制代码
 /**
     * 解密chatData数据
     *
     * @param contentResult 拉取到的JSON原文
     */
    private List<Message> decodeChatData(String contentResult) {
        // 1.基础信息解析
        List<Message> messageList = new ArrayList<>();
        MessagePullResponse messagePullResponse = JSON.parseObject(contentResult, MessagePullResponse.class);
        if (messagePullResponse == null || messagePullResponse.getChatdata() == null) {
            XxlJobLogger.log(messagePullResponse.toString());
            return Collections.emptyList();
        }
        List<ChatData> chatdataList = messagePullResponse.getChatdata();

        // 2.解密数据
        String privateKey = ConfigCenterWrapper.get(GlobalConstants.ApolloKey.PRIVATE_KEY, "");
        for (ChatData chatData : chatdataList) {
            try {
                // 解密ChatData数据
                String plainTextJson = encode(chatData, privateKey);
                BaseMessageDto messageDto = JSON.parseObject(plainTextJson, BaseMessageDto.class);
                // 转换dto数据到entity
                Message message = messageDto.convertToMessage();
                message.setSeq(chatData.getSeq());
                message.setOriginContent(plainTextJson);
                messageList.add(message);
            } catch (Exception e) {
                // 收集失败情况数据
                XxlJobLogger.log("消息拉取处理-解密数据时出现异常:" + e);
                e.printStackTrace();
                // 解密失败,取消执行[正常情况不会遇到解密失败的问题]
                return Collections.emptyList();
            }
        }

        // 4.保存消息和失败任务
        return messageList;
    }

消息拉取响应实体类

java 复制代码
/**
 * @description 消息拉取响应实体类
 * @author 浅沫云归
 * @date 2024/11/20 17:13
 */
public class MessagePullResponse {
    private int errcode;
    private String errmsg;
    private List<ChatData> chatdata;

    // Getters and Setters
}
java 复制代码
/**
 * @description 消息加解密实体类
 * @author 浅沫云归
 * @date 2024/11/20 17:16
 */
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;
    
    // Getters and Setters
}
java 复制代码
/**
 * @description 消息基础信息
 * @author 48444
 * @date 2024/11/21 10:46
 */
public class Message {

    /**
     * 自增主键
     */
    private Long id;

    /**
     * 消息id,消息的唯一标识
     */
    private String msgId;

    /**
     * 消息动作
     */
    private String action;

    /**
     * 消息发送方id
     */
    private String fromId;
    /**
     * 消息发送方群名称
     */
    private String fromNickName;
    /**
     * 消息发送方账户名称
     */
    private String fromName;

    /**
     * 消息接收方列表
     */
    private String toList;

    /**
     * 群聊消息的id
     */
    private String roomId;

    /**
     * 消息序号
     */
    private Long seq;

    /**
     * 消息类型
     */
    private String msgType;

    /**
     * 解密后的明文消息内容
     */
    private String originContent;

    /**
     * 消息发送的时间
     */
    private Long msgTime;

    /**
     * 消息发送的时间(格式化)
     */
    private String msgTimeStr;

    /**
     * 消息状态(是否被处理)
     */
    private Integer status;

    // Getters and Setters
}

使用decodeChatData方法解密我们拉取到的加密密文数据后,即可得到存档消息数据List<Message>了,有了原文消息数据,随后我们就可以做自己的业务操作了,入库保存,媒体资源拉取,解析具体的消息内容到具体的类型消息表等等

3.2.6.媒体资源文件拉取

在处理存档消息内容时,很多类型的消息都是带有媒体资源的,企业微信并没有提供可以直接访问的文件url,我们需要使用媒体资源的sdkfileid来拉取媒体资源。

媒体资源文件拉取我尝试过使用多线程拉取,通过指定文件字节数indexbuf的范围来进行分片拉取,但是会经常偶发文件合并后的md5值错误的情况(问题详见指定indexbuf分片拉取资源错误),咨询官方无果,貌似企业微信官方现在并不支持这样操作,所以这里采用单线程拉取,虽然慢一点,但是用起来还是没啥毛病的,可参考如下代码:

java 复制代码
tempFile = File.createTempFile(GlobalConstants.DOWNLOAD_TEMP_FILE_NAME_PREFIX ,
                    GlobalConstants.TEMP_FILE_NAME_SUFFIX, mediaContext.getTempMergeFileDir());

            try (FileOutputStream fileOutputStream = new FileOutputStream(tempFile)) {
                while (true) {
                    long mediaData = Finance.NewMediaData();
                    int ret = Finance.GetMediaData(sdk, indexbuf, mediaTask.getSdkFileId(), null, null, 3, mediaData);
                    if (ret != 0) {
                        Finance.FreeMediaData(mediaData);
                        tempFile.delete();
                        return;
                    }
                    System.out.println(Finance.GetOutIndexBuf(mediaData));
                    fileOutputStream.write(Finance.GetData(mediaData));
                    if (Finance.IsMediaDataFinish(mediaData) == 1) {
                        Finance.FreeMediaData(mediaData);
                        break;
                    } else {
                        indexbuf = Finance.GetOutIndexBuf(mediaData);
                        Finance.FreeMediaData(mediaData);
                    }
                }
            }

这里下载完成后可使用消息数据中的md5和本地文件进行md5校验,判断拉取的文件是否完整。

关于企业微信存档消息的媒体资源拉取的更多信息可以参考文档获取会话内容-获取媒体文件 - 文档 - 企业微信开发者中心

4.常见问题汇总

(1)企业微信官方的回调有重复请求的现象:这种情况可能是没有回调接口及时响应,导致企微认为回调失败,会进行请求重试,我们系统应该保证回调接口的快速响应,可以将回调事件暂时存储在事件表中,后面再慢慢消费处理

(2)拉取的图片文件正常且可以访问,但是md5值和企微提供的对不上:这个应该是企业微信官方的bug,微信用户在发送非原图图片的时候,会话存档实际拉取到的文件md5值和企微平台传递的md5不一致,这里唯一的解决办法就是不对image类型的消息进行md5校验。

**(3)seq和msgTime的顺序不会保持完全一致:**两者可能顺序不完全一致,这里建议使用seq作为去重的字段。

拉取存档内容时,常见的错误码

|-------|--------------|------------------------------------------------------------------------------------------------------|
| 10000 | 请求参数错误 | 检查Init接口corpid、secret参数;检查GetChatData接口limit参数是否未填或大于1000;检查GetMediaData接口sdkfileid是否为空,indexbuf是否正常 |
| 10001 | 网络请求错误 | 检查是否网络有异常、波动;检查使用代理的情况下代理参数是否设置正确的用户名与密码 |
| 10002 | 数据解析失败 | 建议重试请求。若仍失败,可以反馈给企业微信 |
| 10003 | 系统调用失败 | GetMediaData调用失败,建议重试请求。若仍失败,可以反馈给企业微信进行查询,请提供sdk接口参数与调用时间点等信息 |
| 10004 | 已废弃 | 目前不会返回此错误码 |
| 10005 | fileid错误 | 检查在GetMediaData接口传入的sdkfileid是否正确 |
| 10006 | 解密失败 | 请检查是否先进行base64decode再进行rsa私钥解密,再进行DecryptMsg调用 |
| 10007 | 已废弃 | 目前不会返回此错误码 |
| 10008 | DecryptMsg错误 | 建议重试请求。若仍失败,可以反馈给企业微信进行查询,请提供sdk接口参数与调用时间点等信息 |
| 10009 | ip非法 | 请检查sdk访问外网的ip是否与管理端设置的可信ip匹配,若不匹配会返回此错误码 |
| 10010 | 请求的数据过期 | 用户欲拉取的数据已过期,仅支持近5天内的数据拉取 |
| 10011 | ssl证书错误 | 使用openssl版本sdk,校验ssl证书失败 |

相关推荐
yihuiComeOn15 分钟前
【大数据高并发核心场景实战】 - 数据持久化之冷热分离
java·后端
范纹杉想快点毕业22 分钟前
解析Qt文件保存功能实现
java·开发语言·c++·算法·命令模式
你不困我困23 分钟前
啊啊啊啊啊啊啊啊code
java
Small black human40 分钟前
Spring-创建第一个SpringBoot项目
java·spring boot·intellij-idea
User_芊芊君子1 小时前
【Java】抽象类与接口全解析
java·开发语言
肉肉不想干后端1 小时前
DDD架构中的Assembler转换:从混乱到清晰的架构演进
java
GalaxyPokemon1 小时前
RPC - Response模块
java·前端·javascript
武昌库里写JAVA1 小时前
大模型更重要关注工艺
java·开发语言·spring boot·学习·课程设计
幻奏岚音2 小时前
Java数据结构——第一章Java基础回顾
java·开发语言·jvm·笔记·学习