在上一篇文章《企来微信开发(一):开通及调研》中,介绍了进行企业微信开发背景,及一些调研的过程,本篇文章针对API的对接,及Demo程序的开发来进行介绍,这个过程中遇到了挺多坑的。
一、企业微信SDK下载及API介绍
从官网的技术文档的连接:企业微信 - 获取会话内容 中,可以下载到SDK,本文下载的是Linux SDK: 下载 SDK v1.2 , 解压后的文件如下:
下载的sdk中,提供了java/c的sdk包,本公司的项目都是基于java的,所以使用java_sdk。在文件列表中:
- sdkdemo.java : 官方提供的java调用dll(.so)的例子。
- libWeWorkFinanceSdk_Java.so : 官方封装好的dll(.so)文件,需要放到Java的环境目录下/或者绝对目录下。
- com目录 : 这个目录是官方生成的java调.so的API的类文件,这个包结构是不能改变的(要原封不动地放到项目中),只有一个类:com.tencent.wework Finance.java
二、搭建Demo项目及API对接
1、准备工作
新建一个项目,比如ewxchat-demo,这是我的项目名称,将上面的sdkdemo.java,及com整个目录拷到项目中,如下图:
sdkdemo中的例子代码,他是通过命令行的方式来交互的。在实际的开发中,我并没有使用sdkdemo中的代码,重新写了另外的单元测试类。
2、 准备RSA的私钥,公钥
在项目中,我是使用openssl来生成的,网上也有其他工具可以生成。
- 用 openssl genrsa -out private.pem 2048 来生成私钥,并保存。
- 用 openssl rsa -in private.pem -pubout -out public.pem 从私钥来产生公钥,并保存,需要配置到企业微信上(在前面的文章中有介绍配置)。
3、 pom.xml配置
项目中主要引入的jar,要按如下来来配置,网上及官方有提供,但针对不同版本的jdk,都可能存在问题,我使用的jdk是1.8版本,用这些jar测试是没有问题。
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20210307</version>
</dependency>
<dependency>
<groupId>org.jdom</groupId>
<artifactId>jdom</artifactId>
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpg-jdk15on</artifactId>
<version>1.64</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>1.64</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.64</version>
</dependency>
4、 准备RSA 工个类
public static String decryptRSA(String str, String privateKey) throws Exception {
Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
//此处的"RSA/ECB/PKCS1Padding", "BC"不可以改变,改变会导致解密乱码
Cipher rsa = Cipher.getInstance("RSA/ECB/PKCS1Padding", "BC");
rsa.init(Cipher.DECRYPT_MODE, getPrivateKey(privateKey));
byte[] utf8 = rsa.doFinal(Base64.decodeBase64(str));
String result = new String(utf8,"UTF-8");
return result;
}
public static PrivateKey getPrivateKey (String privateKey) throws Exception {
Reader privateKeyReader = new StringReader(privateKey);
PEMParser privatePemParser = new PEMParser(privateKeyReader);
Object privateObject = privatePemParser.readObject();
if (privateObject instanceof PEMKeyPair) {
PEMKeyPair pemKeyPair = (PEMKeyPair) privateObject;
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
PrivateKey privKey = converter.getPrivateKey(pemKeyPair.getPrivateKeyInfo());
return privKey;
}
return null;
}
5、 Xml工具类
public static JSONObject xml2Json(String xmlStr) throws JDOMException, IOException {
if (!StringUtils.hasLength(xmlStr)) {
return null;
}
xmlStr = xmlStr.replaceAll("\\\n", "");
byte[] xml = xmlStr.getBytes("UTF-8");
//JSONObject json = new JSONObject();
InputStream is = new ByteArrayInputStream(xml);
SAXBuilder sb = new SAXBuilder();
Document doc = sb.build(is);
Element root = doc.getRootElement();
//json.put(root.getName(), iterateElement(root));
return iterateElement(root);
}
private static JSONObject iterateElement(Element element) {
List<Element> node = element.getChildren();
JSONObject obj = new JSONObject();
List list = null;
for (Element child : node) {
list = new LinkedList();
String text = child.getTextTrim();
if (!StringUtils.hasLength(text)) {
if (child.getChildren().size() == 0) {
continue;
}
if (obj.has(child.getName())) {
list = (List) obj.get(child.getName());
}
list.add(iterateElement(child)); //遍历child的子节点
obj.put(child.getName(), list);
} else {
if (obj.has(child.getName())) {
Object value = obj.get(child.getName());
try {
list = (List) value;
} catch (ClassCastException e) {
list.add(value);
}
}
if (child.getChildren().size() == 0) {
//child无子节点时直接设置text
obj.put(child.getName(), text);
} else {
list.add(text);
obj.put(child.getName(), list);
}
}
}
return obj;
}
public static void main(String[] args){
String str = "<xml><ToUserName><![CDATA[ww97e7f6721fd99ce9]]></ToUserName><FromUserName><![CDATA[sys]]></FromUserName><CreateTime>1654915333</CreateTime><MsgType><![CDATA[event]]></MsgType><AgentID>2000004</AgentID><Event><![CDATA[msgaudit_notify]]></Event></xml>";
try {
JSONObject out = XmlUtils.xml2Json(str);
System.out.println(out);
} catch (JDOMException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
6、拉取会话的实现
WxchatMsgPullService.java
@Value("${corp_id}")
private String corpId;
@Value("${corp_key}")
private String corpKey;
@Value("${media.save.path}")
private String mediaSavePath;
//私钥
private String privateKey;
private long sdk;
private Boolean sdkSuccess = Boolean.FALSE;
private void init(){
if(sdkSuccess){
return;
}
//log.info("{}", System.getProperty("java.library.path"));
this.sdk = Finance.NewSdk();
log.info("corpId: {}, corpKey: {}", corpId, corpKey);
long ret = Finance.Init(sdk, corpId, corpKey); // 初始化
if(ret != 0){
Finance.DestroySdk(sdk);
log.error("init sdk err : {}", ret);
throw new RuntimeException("init wxchat sdk error");
}
sdkSuccess = true;
}
/**
* 会话消息的定义:
* 参数 说明
* msgid 消息id,消息的唯一标识,企业可以使用此字段进行消息去重。String类型
* action 消息动作,目前有send(发送消息)/recall(撤回消息)/switch(切换企业日志)三种类型。String类型
* from 消息发送方id。同一企业内容为userid,非相同企业为external_userid。消息如果是机器人发出,也为external_userid。String类型
* tolist 消息接收方列表,可能是多个,同一个企业内容为userid,非相同企业为external_userid。数组,内容为string类型
* roomid 群聊消息的群id。如果是单聊则为空。String类型
* msgtime 消息发送时间戳,utc时间,ms单位。
* msgtype 文本消息为:text。String类型
* content 消息内容。String类型
* @return
*/
public String readMsgs(){
init();
// 从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,
// seq为之前接口返回的最大seq值。首次使用请使用seq:0(这个值需要记录下来,以便下一次的拉去)
int seq = 0;
int limit = 60;
long slice = Finance.NewSlice();
long ret = Finance.GetChatData(sdk, seq, limit, null, null, 3, slice);
if (ret != 0) {
log.error("Call Finance.GetChatData error: {}", ret);
throw new IllegalStateException("调用Finance.GetChatData错误:" + ret);
}
String getchatdata = Finance.GetContentFromSlice(slice);
log.info("序号:{}, 拉去的聊天记录密文结果:{}", seq, getchatdata);
JSONObject jo = new JSONObject(getchatdata);
JSONArray chatdata = jo.getJSONArray("chatdata");
log.info("消息数:{}", chatdata.length());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < chatdata.length(); i++) {
JSONObject data = new JSONObject(chatdata.get(i).toString());
String encryptRandomKey = data.getString("encrypt_random_key");
String encryptChatMsg = data.getString("encrypt_chat_msg");
long msg = Finance.NewSlice();
try {
// 聊天记录密文解密
String message = RSAKit.decryptRSA(encryptRandomKey, Conf.RSA_primary);
ret = Finance.DecryptData(sdk, message, encryptChatMsg, msg);
if (ret != 0) {
log.error("Finance.DecryptData error : {}", ret);
throw new IllegalStateException("调用Finance.DecryptData解密出错:" + ret);
}
String plaintext = Finance.GetContentFromSlice(msg);
log.info("decrypt result: {}, msg: {}", ret, plaintext);
Finance.FreeSlice(msg);
JSONObject plaintextJson = new JSONObject(plaintext);
//如果包含msgtype,是有效的消息,
// 不包含msgtype,可能是一些操作类的通知
//消息动作,目前有send(发送消息)/recall(撤回消息)/switch(切换企业日志)三种类型。String类型
if(plaintextJson.has("msgtype")) {
// 拉去媒体文件解密
String msgtype = plaintextJson.getString("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.length(); j++) {
JSONObject item = new JSONObject(items.get(j).toString());
JSONObject content = new JSONObject(item.getString("content"));
String type = item.getString("type");
if ("text".equals(type)) {
item.put("content", content.getString("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);
}
}
// 会话内容写入数据库
sb.append(plaintextJson.toString()).append(",");
log.info("第{}条:{}", i, plaintextJson);
// save(plaintextJson);
} catch (Exception e) {
log.error("第{}条,解密会话内容出错", i, e);
continue;
}
}
return sb.toString();
}
// 拉去媒体信息
private 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.getString("msgid");
} else {
// 混合消息
file = plaintextJson;
savefileName = file.getString("md5sum");
}
log.info("媒体文件信息:{}", 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.getString("fileext");
break;
}
savefileName += suffix;
String savefile = this.mediaSavePath + 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.getString("sdkfileid");
while (true) {
long mediaData = Finance.NewMediaData();
int ret = Finance.GetMediaData(sdk, indexbuf, sdkfileid, null, null, 3, mediaData);
if (ret != 0) {
log.error("调用getmediadata ret: {}", ret);
Finance.FreeMediaData(mediaData);
return null;
}
log.info("getmediadata outindex len:{}, data_len:{}, is_finis:{}\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) {
log.error("输出媒体文件出错", e);
}
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 "";
}
//注意,这里要有个方法,来释放初始化的SDK,
//不然会一直占用在微信那边
@PreDestroy
public void releaseSDK(){
Finance.DestroySdk(sdk);
}
7、实现一个拉取的接口
@RestController
@RequiredArgsConstructor
public class PullMsgApi {
private final WxchatMsgPullService wxchatMsgPullService;
@GetMapping(value = "api/pull")
public String pullMsg(){
String result = wxchatMsgPullService.readMsgs();
return result;
}
}
8、 .so包的加载
官方提供的.so加载,是放在Finance类中,我把他迁到了启动类中,如下:
public class EwxchatDemoApplication {
public static void main(String[] args) {
SpringApplication.run(EwxchatDemoApplication.class, args);
}
static {
/**
* 开发时就要调整此处的参数
* 建议将.so包,入到jdk的lib目录下,这样可以直接使用loadLibrary
* 不然只能用load,这要绝对路径
*/
//System.loadLibrary("libWeWorkFinanceSdk_Java.so");
//System.load("/Users/xxxx(改成目录)/Documents/dev/libWeWorkFinanceSdk_Java.so");
System.load("/usr/xxxx/logs/ewxchat-demo/lib/libWeWorkFinanceSdk_Java.so");
}
说明,我这里用于服务器的绝对路径来加载.so文件,你也可以放到jdk的lib目录下,使用另外一个方法来加载。
8、 结果
用工具请求这个接口:xxxx.xxxx .com/ewxchat/api/pull,得到的结果如下:
[ {
"msgid": "17641547684399051526_1655110008353", "action": "switch", "time": 1655110008334, "user": "zhangshan" }, {
"tolist": [ "jasontan" ],
"msgtime": 1655198980626,
"msgid": "17836885138636475767_1655198980860",
"action": "send",
"from": "lishe",
"text": {
"content": "测试一下"
},
"msgtype": "text",
"roomid": ""
},
{
"tolist": [
"lishe"
],
"msgtime": 1655199069793,
"msgid": "17966145653496018174_1655199070041",
"action": "send",
"from": "zhangshan",
"text": {
"content": "收到"
},
"msgtype": "text",
"roomid": ""
},
{
"tolist": [
"jasontan"
],
"msgtime": 1655199083555,
"msgid": "250435355251795619_1655199083855",
"action": "send",
"from": "lishe",
"text": {
"content": "不用回"
},
"msgtype": "text",
"roomid": ""
},
{
"tolist": [
"zhangshan"
],
"msgtime": 1655199794237,
"msgid": "15655799441533302234_1655199794364",
"action": "recall",
"revoke": {
"pre_msgid": "17836885138636475767_1655198980860"
},
"from": "lishe",
"msgtype": "revoke",
"roomid": ""
}
]
三、遇到的问题
1、 遇到的第一个问题
makefile
异常java.security.InvalidKeyException:illegal Key Size
这个是JCE权限策略文件的问题,需要去官方网站下载JCE无限制权限策略文件(请到官网下载对应的版本, 例如JDK8的下载地址(根据不同JDK下载):www.oracle.com/technetwork… ):下载后解压,可以看到local_policy.jar和US_export_policy.jar以及readme.txt。
2、 环境问题
由于我用的是mac系统来开发,运行过程中,报无法加载.so文件,这个是因为官方提供的.so文件只支持 linux, window系统。
Exception in thread "main" java.lang.UnsatisfiedLinkError: /Users/xxx/Documents/dev/libWeWorkFinanceSdk_Java.so:
dlopen(/Users/xxx/Documents/dev/libWeWorkFinanceSdk_Java.so, 1):
no suitable image found. Did find:
/Users/xxx/Documents/dev/libWeWorkFinanceSdk_Java.so: unknown file type, first eight bytes: 0x7F 0x45 0x4C 0x46 0x02 0x01 0x01 0x00
/Users/xxx/Documents/dev/libWeWorkFinanceSdk_Java.so: unknown file type, first eight bytes: 0x7F 0x45 0x4C 0x46 0x02 0x01 0x01 0x00
3、 引用包问题
bouncycastle 包引入版本不对问题,经过调试,按我上面引入的是没有问题的。
四、总结
demo程序能完成会话内容的获取,可以存储起来,方便后续对内容进行分析。
如果在调试,开发过程中遇到问题,可以找我,帮你解决。