diff --git a/src-plugin/com/wentch/redkale/service/weixin/WeiXinMPService.java b/src-plugin/com/wentch/redkale/service/weixin/WeiXinMPService.java new file mode 100644 index 000000000..5d29f12b6 --- /dev/null +++ b/src-plugin/com/wentch/redkale/service/weixin/WeiXinMPService.java @@ -0,0 +1,97 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package com.wentch.redkale.service.weixin; + +import com.wentch.redkale.convert.json.*; +import com.wentch.redkale.service.*; +import static com.wentch.redkale.service.weixin.WeiXinQYService.MAPTYPE; +import com.wentch.redkale.util.*; +import static com.wentch.redkale.util.Utility.getHttpContent; +import java.io.*; +import java.security.*; +import java.util.*; +import java.util.logging.*; +import javax.annotation.*; + +/** + * 微信服务号Service + * + * @author zhangjx + */ +public class WeiXinMPService { + + protected final Logger logger = Logger.getLogger(this.getClass().getSimpleName()); + + private final boolean finest = logger.isLoggable(Level.FINEST); + + private final boolean finer = logger.isLoggable(Level.FINER); + + protected final Map mpsecrets = new HashMap<>(); + + @Resource + protected JsonConvert convert; + + // http://m.xxx.com/pipes/wx/verifymp + @Resource(name = "property.wxmp.token") + protected String mptoken = ""; + + @Resource(name = "property.wxmp.corpid") + protected String mpcorpid = "wxYYYYYYYYYYYYYY"; + + @Resource(name = "property.wxmp.aeskey") + protected String mpaeskey = ""; + + public WeiXinMPService() { + // mpsecrets.put("wxYYYYYYYYYYYYYYYYYY", "xxxxxxxxxxxxxxxxxxxxxxxxxxx"); + } + + //-----------------------------------微信服务号接口---------------------------------------------------------- + public RetResult getMPWxunionid(String appid, String code) { + try { + Map wxmap = getMPUserToken(appid, code); + final String unionid = wxmap.get("unionid"); + if (unionid != null && !unionid.isEmpty()) return new RetResult<>(unionid); + return new RetResult<>(1011002); + } catch (IOException e) { + return new RetResult<>(1011001); + } + } + + public Map getMPUserToken(String appid, String code) throws IOException { + String url = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=" + appid + "&secret=" + mpsecrets.get(appid) + "&code=" + code + "&grant_type=authorization_code"; + String json = getHttpContent(url); + if (finest) logger.finest(url + "--->" + json); + Map jsonmap = convert.convertFrom(MAPTYPE, json); + url = "https://api.weixin.qq.com/sns/userinfo?access_token=" + jsonmap.get("access_token") + "&openid=" + jsonmap.get("openid"); + json = getHttpContent(url); + if (finest) logger.finest(url + "--->" + json); + jsonmap = convert.convertFrom(MAPTYPE, json.replaceFirst("\\[.*\\]", "null")); + return jsonmap; + } + + public String verifyMPURL(String msgSignature, String timeStamp, String nonce, String echoStr) { + String signature = sha1(mptoken, timeStamp, nonce); + if (!signature.equals(msgSignature)) throw new RuntimeException("signature verification error"); + return echoStr; + } + + /** + * 用SHA1算法生成安全签名 + *

+ * @param strings + * @return 安全签名 + */ + protected static String sha1(String... strings) { + try { + Arrays.sort(strings); + MessageDigest md = MessageDigest.getInstance("SHA-1"); + for (String s : strings) md.update(s.getBytes()); + return Utility.binToHexString(md.digest()); + } catch (Exception e) { + throw new RuntimeException("SHA encryption to generate signature failure", e); + } + } +} diff --git a/src-plugin/com/wentch/redkale/service/weixin/WeiXinQYMessage.java b/src-plugin/com/wentch/redkale/service/weixin/WeiXinQYMessage.java new file mode 100644 index 000000000..6c64e6191 --- /dev/null +++ b/src-plugin/com/wentch/redkale/service/weixin/WeiXinQYMessage.java @@ -0,0 +1,105 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package com.wentch.redkale.service.weixin; + +import com.wentch.redkale.convert.json.*; +import java.util.*; + +/** + * 微信企业号Service + * + * @author zhangjx + */ +public class WeiXinQYMessage { + + private String agentid = "1"; + + private String msgtype = "text"; + + private Map text; + + private String touser = "@all"; + + private String toparty; + + private String totag; + + private String safe; + + public WeiXinQYMessage() { + } + + public WeiXinQYMessage(String agentid, String text) { + this.agentid = agentid; + setTextMessage(text); + } + + public final void setTextMessage(String content) { + if (text == null) text = new HashMap<>(); + text.put("content", content); + } + + public String getAgentid() { + return agentid; + } + + public void setAgentid(String agentid) { + this.agentid = agentid; + } + + public String getMsgtype() { + return msgtype; + } + + public void setMsgtype(String msgtype) { + this.msgtype = msgtype; + } + + public Map getText() { + return text; + } + + public void setText(Map text) { + this.text = text; + } + + public String getTouser() { + return touser; + } + + public void setTouser(String touser) { + this.touser = touser; + } + + public String getToparty() { + return toparty; + } + + public void setToparty(String toparty) { + this.toparty = toparty; + } + + public String getTotag() { + return totag; + } + + public void setTotag(String totag) { + this.totag = totag; + } + + public String getSafe() { + return safe; + } + + public void setSafe(String safe) { + this.safe = safe; + } + + @Override + public String toString() { + return JsonFactory.root().getConvert().convertTo(this); + } +} diff --git a/src-plugin/com/wentch/redkale/service/weixin/WeiXinQYService.java b/src-plugin/com/wentch/redkale/service/weixin/WeiXinQYService.java new file mode 100644 index 000000000..7f166e980 --- /dev/null +++ b/src-plugin/com/wentch/redkale/service/weixin/WeiXinQYService.java @@ -0,0 +1,309 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package com.wentch.redkale.service.weixin; + +import com.wentch.redkale.boot.*; +import com.wentch.redkale.convert.json.*; +import com.wentch.redkale.net.*; +import com.wentch.redkale.service.*; +import com.wentch.redkale.util.*; +import static com.wentch.redkale.util.Utility.*; +import java.io.*; +import java.lang.reflect.*; +import java.nio.charset.*; +import java.security.*; +import java.util.*; +import java.util.logging.*; +import javax.annotation.*; +import javax.crypto.*; +import javax.crypto.spec.*; + +/** + * + * @author zhangjx + */ +public class WeiXinQYService implements Service { + + protected final Logger logger = Logger.getLogger(this.getClass().getSimpleName()); + + private final boolean finest = logger.isLoggable(Level.FINEST); + + private final boolean finer = logger.isLoggable(Level.FINER); + + private static class Token { + + public String token; + + public long expires = 7100000; + + public long accesstime; + } + + private static final String BASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + private static final Charset CHARSET = Charset.forName("UTF-8"); + + private static final Random RANDOM = new Random(); + + protected static final Type MAPTYPE = new TypeToken>() { + }.getType(); + + @Resource + protected JsonConvert convert; + + //------------------------------------------------------------------------------------------------------ + // http://oa.xxxx.com/pipes/wx/verifyqy + @Resource(name = "property.wxqy.token") + protected String qytoken = ""; + + @Resource(name = "property.wxqy.corpid") + protected String qycorpid = "wxYYYYYYYYYYYYYYYY"; + + @Resource(name = "property.wxqy.aeskey") + protected String qyaeskey = ""; + + @Resource(name = "property.wxqy.secret") + private String qysecret = "#########################"; + + private SecretKeySpec qykeyspec; + + private IvParameterSpec qyivspec; + + private final Token qyAccessToken = new Token(); + + //------------------------------------------------------------------------------------------------------ + public WeiXinQYService() { + } + + public static void main(String[] args) throws Exception { + WeiXinQYService service = Application.singleton(WeiXinQYService.class); + + WeiXinQYMessage message = new WeiXinQYMessage(); + message.setTextMessage("【测试】duang!"); + message.setAgentid("2"); + service.sendQYMessage(message); + } + + //-----------------------------------微信企业号接口---------------------------------------------------------- + public Map getQYUserCode(String code, String agentid) throws IOException { + String url = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token=" + getQYAccessToken() + "&code=" + code + "&agentid=" + agentid; + String json = getHttpContent(url); + if (finest) logger.finest(url + "--->" + json); + return convert.convertFrom(MAPTYPE, json); + } + + public void sendQYTextMessage(String agentid, String message) { + sendQYMessage(new WeiXinQYMessage(agentid, message)); + } + + public void sendQYMessage(WeiXinQYMessage message) { + submit(() -> { + String result = null; + try { + String url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + getQYAccessToken(); + result = postHttpContent(url, convert.convertTo(message)); + if (finest) logger.finest("sendQYMessage ok: " + message + " -> " + result); + } catch (Exception e) { + logger.log(Level.WARNING, "sendQYMessage error: " + message + " -> " + result, e); + } + }); + } + + public String verifyQYURL(String msgSignature, String timeStamp, String nonce, String echoStr) { + String signature = sha1(qytoken, timeStamp, nonce, echoStr); + if (!signature.equals(msgSignature)) throw new RuntimeException("signature verification error"); + return decryptQY(echoStr); + } + + protected String getQYAccessToken() throws IOException { + if (qyAccessToken.accesstime < System.currentTimeMillis() - qyAccessToken.expires) qyAccessToken.token = null; + if (qyAccessToken.token == null) { + String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + qycorpid + "&corpsecret=" + qysecret; + String json = getHttpContent(url); + if (finest) logger.finest(url + "--->" + json); + Map jsonmap = convert.convertFrom(MAPTYPE, json); + qyAccessToken.accesstime = System.currentTimeMillis(); + qyAccessToken.token = jsonmap.get("access_token"); + String exp = jsonmap.get("expires_in"); + if (exp != null) qyAccessToken.expires = (Integer.parseInt(exp) - 100) * 1000; + } + return qyAccessToken.token; + } + + /** + * 将公众平台回复用户的消息加密打包. + *

    + *
  1. 对要发送的消息进行AES-CBC加密
  2. + *
  3. 生成安全签名
  4. + *
  5. 将消息密文和安全签名打包成xml格式
  6. + *
+ *

+ * @param replyMsg 公众平台待回复用户的消息,xml格式的字符串 + * @param timeStamp 时间戳,可以自己生成,也可以用URL参数的timestamp + * @param nonce 随机串,可以自己生成,也可以用URL参数的nonce + *

+ * @return 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串 + */ + protected String encryptQYMessage(String replyMsg, String timeStamp, String nonce) { + // 加密 + String encrypt = encryptQY(random16String(), replyMsg); + + // 生成安全签名 + if (timeStamp == null || timeStamp.isEmpty()) timeStamp = Long.toString(System.currentTimeMillis()); + String signature = sha1(qytoken, timeStamp, nonce, encrypt); + + // System.out.println("发送给平台的签名是: " + signature[1].toString()); + // 生成发送的xml + return "\n\n" + + "\n" + + "" + timeStamp + "\n" + + "\n"; + } + + protected String decryptQYMessage(String msgSignature, String timeStamp, String nonce, String postData) { + // 密钥,公众账号的app secret + // 提取密文 + String encrypt = postData.substring(postData.indexOf("")); + // 验证安全签名 + if (!sha1(qytoken, timeStamp, nonce, encrypt).equals(msgSignature)) throw new RuntimeException("signature verification error"); + return decryptQY(encrypt); + } + + /** + * 对明文进行加密. + *

+ * @param randomStr + * @param text 需要加密的明文 + * @return 加密后base64编码的字符串 + */ + protected String encryptQY(String randomStr, String text) { + ByteArray bytes = new ByteArray(); + byte[] randomStrBytes = randomStr.getBytes(CHARSET); + byte[] textBytes = text.getBytes(CHARSET); + byte[] corpidBytes = qycorpid.getBytes(CHARSET); + + // randomStr + networkBytesOrder + text + qycorpid + bytes.add(randomStrBytes); + bytes.addInt(textBytes.length); + bytes.add(textBytes); + bytes.add(corpidBytes); + + // ... + pad: 使用自定义的填充方式对明文进行补位填充 + byte[] padBytes = encodePKCS7(bytes.count()); + bytes.add(padBytes); + + // 获得最终的字节流, 未加密 + try { + // 加密 + byte[] encrypted = createQYCipher(Cipher.ENCRYPT_MODE).doFinal(bytes.directBytes(), 0, bytes.count()); + // 使用BASE64对加密后的字符串进行编码 + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new RuntimeException("AES加密失败", e); + } + } + + protected String decryptQY(String text) { + byte[] original; + try { + // 使用BASE64对密文进行解码 + original = createQYCipher(Cipher.DECRYPT_MODE).doFinal(Base64.getDecoder().decode(text)); + } catch (Exception e) { + throw new RuntimeException("AES解密失败", e); + } + try { + // 去除补位字符 + byte[] bytes = decodePKCS7(original); + // 分离16位随机字符串,网络字节序和corpid + int xmlLength = (bytes[16] & 0xFF) << 24 | (bytes[17] & 0xFF) << 16 | (bytes[18] & 0xFF) << 8 | bytes[19] & 0xFF; + if (!qycorpid.equals(new String(bytes, 20 + xmlLength, bytes.length - 20 - xmlLength, CHARSET))) { + throw new RuntimeException("corpid校验失败"); + } + return new String(bytes, 20, xmlLength, CHARSET); + } catch (RuntimeException e) { + if (e.getMessage().contains("corpid")) throw e; + throw new RuntimeException("解密后得到的buffer非法", e); + } + } + + protected Cipher createQYCipher(int mode) throws Exception { + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + if (qykeyspec == null) { + byte[] aeskeyBytes = Base64.getDecoder().decode(qyaeskey + "="); + qykeyspec = new SecretKeySpec(aeskeyBytes, "AES"); + qyivspec = new IvParameterSpec(aeskeyBytes, 0, 16); + } + cipher.init(mode, qykeyspec, qyivspec); + return cipher; + } + + protected void submit(Runnable runner) { + Thread thread = Thread.currentThread(); + if (thread instanceof WorkThread) { + ((WorkThread) thread).submit(runner); + return; + } + runner.run(); + } + + //-----------------------------------通用接口---------------------------------------------------------- + // 随机生成16位字符串 + protected static String random16String() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16; i++) { + sb.append(BASE.charAt(RANDOM.nextInt(BASE.length()))); + } + return sb.toString(); + } + + /** + * 用SHA1算法生成安全签名 + *

+ * @param strings + * @return 安全签名 + */ + protected static String sha1(String... strings) { + try { + Arrays.sort(strings); + MessageDigest md = MessageDigest.getInstance("SHA-1"); + for (String s : strings) md.update(s.getBytes()); + return Utility.binToHexString(md.digest()); + } catch (Exception e) { + throw new RuntimeException("SHA encryption to generate signature failure", e); + } + } + + /** + * 获得对明文进行补位填充的字节. + *

+ * @param count 需要进行填充补位操作的明文字节个数 + * @return 补齐用的字节数组 + */ + private static byte[] encodePKCS7(int count) { + // 计算需要填充的位数 + int amountToPad = 32 - (count % 32); + if (amountToPad == 0) amountToPad = 32; + // 获得补位所用的字符 + char padChr = (char) (byte) (amountToPad & 0xFF); + StringBuilder tmp = new StringBuilder(); + for (int index = 0; index < amountToPad; index++) { + tmp.append(padChr); + } + return tmp.toString().getBytes(CHARSET); + } + + /** + * 删除解密后明文的补位字符 + *

+ * @param decrypted 解密后的明文 + * @return 删除补位字符后的明文 + */ + private static byte[] decodePKCS7(byte[] decrypted) { + int pad = (int) decrypted[decrypted.length - 1]; + if (pad < 1 || pad > 32) pad = 0; + return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad); + } +} diff --git a/src/com/wentch/redkale/service/RetResult.java b/src/com/wentch/redkale/service/RetResult.java new file mode 100644 index 000000000..feba3923e --- /dev/null +++ b/src/com/wentch/redkale/service/RetResult.java @@ -0,0 +1,94 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package com.wentch.redkale.service; + +import com.wentch.redkale.convert.json.*; + +/** + * 通用的结果对象 + * + * @author zhangjx + * @param + */ +public class RetResult { + + public static final RetResult SUCCESS = new RetResult() { + + @Override + public void setRetcode(int retcode) { + } + + @Override + public void setRetinfo(String retinfo) { + } + + @Override + public void setResult(Object result) { + } + + }; + + protected int retcode; + + protected String retinfo; + + private T result; + + public RetResult() { + } + + public RetResult(T result) { + this.result = result; + } + + public RetResult(int retcode) { + this.retcode = retcode; + } + + public RetResult(int retcode, String retinfo) { + this.retcode = retcode; + this.retinfo = retinfo; + } + + public RetResult(int retcode, String retinfo, T result) { + this.retcode = retcode; + this.retinfo = retinfo; + this.result = result; + } + + public boolean isSuccess() { + return retcode == 0; + } + + public int getRetcode() { + return retcode; + } + + public void setRetcode(int retcode) { + this.retcode = retcode; + } + + public String getRetinfo() { + return retinfo; + } + + public void setRetinfo(String retinfo) { + this.retinfo = retinfo; + } + + public T getResult() { + return result; + } + + public void setResult(T result) { + this.result = result; + } + + @Override + public String toString() { + return JsonFactory.root().getConvert().convertTo(this); + } +}