package org.nutz.weixin.util; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.Reader; import java.io.StringReader; import java.io.StringWriter; import java.io.Writer; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.nutz.http.Http; import org.nutz.http.Response; import org.nutz.json.Json; import org.nutz.lang.Encoding; import org.nutz.lang.Files; import org.nutz.lang.Lang; import org.nutz.lang.MapKeyConvertor; import org.nutz.lang.Mirror; import org.nutz.lang.Streams; import org.nutz.lang.Strings; import org.nutz.lang.Xmls; import org.nutz.lang.random.R; import org.nutz.lang.tmpl.Tmpl; import org.nutz.lang.util.NutMap; import org.nutz.log.Log; import org.nutz.log.Logs; import org.nutz.mvc.View; import org.nutz.mvc.view.HttpStatusView; import org.nutz.mvc.view.RawView; import org.nutz.mvc.view.ViewWrapper; import org.nutz.weixin.bean.WxArticle; import org.nutz.weixin.bean.WxEventType; import org.nutz.weixin.bean.WxImage; import org.nutz.weixin.bean.WxInMsg; import org.nutz.weixin.bean.WxMsgType; import org.nutz.weixin.bean.WxMusic; import org.nutz.weixin.bean.WxOutMsg; import org.nutz.weixin.bean.WxVideo; import org.nutz.weixin.bean.WxVoice; import org.nutz.weixin.mvc.WxView; import org.nutz.weixin.repo.com.qq.weixin.mp.aes.AesException; import org.nutz.weixin.repo.com.qq.weixin.mp.aes.WXBizMsgCrypt; import org.nutz.weixin.spi.WxHandler; import org.xml.sax.InputSource; import org.xml.sax.SAXException; public class Wxs { private static final Log log = Logs.get(); public static boolean DEV_MODE = false; public static void enableDevMode() { DEV_MODE = true; log.warn("nutzwx DevMode=true now"); } /** * 根据提交参数,生成签名 * * @param map * 要签名的集合 * @param key * 商户秘钥 * @return 签名 * * @see <a href= * "https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3"> * 微信商户平台签名算法</a> * */ public static String genPaySign(Map<String, Object> map, String key, String signType) { String[] nms = map.keySet().toArray(new String[map.size()]); Arrays.sort(nms); StringBuilder sb = new StringBuilder(); signType = signType == null ? "MD5" : signType.toUpperCase(); boolean isMD5 = "MD5".equals(signType); for (String nm : nms) { Object v = map.get(nm); if (null == v) continue; // JSSDK 支付签名时间戳,注意微信jssdk中的所有使用timestamp字段均为小写。 // 但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符 if (isMD5 && "timestamp".equals(nm)) { nm = "timeStamp"; } String s = v.toString(); if (Strings.isBlank(s)) continue; sb.append(nm).append('=').append(s).append('&'); } sb.append("key=").append(key); return Lang.digest(signType, sb).toUpperCase(); } /** * 默认采用 MD5 方式的签名 * * @see #genPaySign(Map, String, String) */ public static String genPaySignMD5(Map<String, Object> map, String key) { return genPaySign(map, key, "MD5"); } /** * 为参数集合填充随机数,以及生成签名 * * @param map * 参数集合 * @param key * 商户秘钥 * * @see #genPaySignMD5(Map, String) */ public static void fillPayMap(Map<String, Object> map, String key) { // 首先确保有随机数 map.put("nonce_str", "" + R.random(10000000, 100000000)); // 填充签名 String sign = genPaySignMD5(map, key); map.put("sign", sign); } /** * 检查一下支付平台返回的 xml,是否签名合法,如果合法,转换成一个 map * * @param xml * 支付平台返回的 xml * @param key * 商户秘钥 * @return 合法的 Map * * @throws "e.wx.sign.invalid" * * @see #checkPayReturnMap(NutMap, String) * @see <a href= * "https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1"> * 支付平台文档</a> */ public static NutMap checkPayReturn(String xml, String key) { try { NutMap map = getkPayReturn(xml); return checkPayReturnMap(map, key); } catch (RuntimeException e) { throw e; } catch (Exception e) { throw Lang.makeThrow("e.wx.pay.re.error : %s", xml); } } public static NutMap getkPayReturn(String xml) { try { return Xmls.asMap(xmls().parse(new InputSource(new StringReader(xml))) .getDocumentElement()); } catch (RuntimeException e) { throw e; } catch (Exception e) { throw Lang.makeThrow("e.wx.pay.re.error : %s", xml); } } /** * 检查一下支付平台返回的 xml,是否签名合法,如果合法,转换成一个 map * * @param map * 描述支付平台返回的 xml 信息的 Map 对象 * @param key * 商户秘钥 * @return 合法的 Map * * @throws "e.wx.sign.invalid" * * @see <a href= * "https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1"> * 支付平台文档</a> */ public static NutMap checkPayReturnMap(NutMap map, String key) { if (!map.containsKey("sign")) { throw Lang.makeThrow("e.wx.pay.re.error : %s", map); } String sign = map.remove("sign").toString(); String sign2 = Wxs.genPaySignMD5(map, key); if (!sign.equals(sign2)) throw Lang.makeThrow("e.wx.pay.re.sign.invalid : expect '%s' but '%s'", sign2, sign); return map; } public static WxInMsg convert(InputStream in) { return convert(in, WxInMsg.class); } public static WxInMsg convert(String data) { return convert(new ByteArrayInputStream(data.getBytes())); } public static <T> T convert(String data, Class<T> klass) { return convert(new ByteArrayInputStream(data.getBytes()), klass); } /** * 将一个输入流转为WxInMsg */ public static <T> T convert(InputStream in, Class<T> klass) { Map<String, Object> map; String raw; try { // fix: // DocumentBuilder不支持直接传入Reader,如果直接传InputStream的话又按系统默认编码,所以,用InputSource中转一下 Reader r = Streams.utf8r(in); raw = Lang.readAll(r); map = Xmls.asMap(xmls().parse(new InputSource(new StringReader(raw))) .getDocumentElement()); } catch (Exception e) { throw Lang.wrapThrow(e); } Lang.convertMapKey(map, new MapKeyConvertor() { @Override public String convertKey(String key) { return Strings.lowerFirst(key); } }, true); if (DEV_MODE) { log.debug("Income >> \n" + Json.toJson(map)); } T t = Lang.map2Object(map, klass); if (t instanceof WxInMsg) ((WxInMsg) t).raw(raw); else if (t instanceof WxOutMsg) ((WxOutMsg) t).raw(raw); return t; } /** * 检查signature是否合法 */ public static boolean check(String token, String signature, String timestamp, String nonce) { // 防范长密文攻击 if (signature == null || signature.length() > 128 || timestamp == null || timestamp.length() > 128 || nonce == null || nonce.length() > 128) { log.warnf("bad check : signature=%s,timestamp=%s,nonce=%s", signature, timestamp, nonce); return false; } ArrayList<String> tmp = new ArrayList<String>(); tmp.add(token); tmp.add(timestamp); tmp.add(nonce); Collections.sort(tmp); String key = Lang.concat("", tmp).toString(); return Lang.sha1(key).equalsIgnoreCase(signature); } /** * 根据不同的消息类型,调用WxHandler不同的方法 */ public static WxOutMsg handle(WxInMsg msg, WxHandler handler) { WxOutMsg out = null; switch (WxMsgType.valueOf(msg.getMsgType())) { case text: out = handler.text(msg); break; case image: out = handler.image(msg); break; case voice: out = handler.voice(msg); break; case video: out = handler.video(msg); break; case location: out = handler.location(msg); break; case link: out = handler.link(msg); break; case event: out = handleEvent(msg, handler); break; case shortvideo: out = handler.shortvideo(msg); break; default: log.infof("New MsyType=%s ? fallback to defaultMsg", msg.getMsgType()); out = handler.defaultMsg(msg); break; } return out; } /** * 根据msg中Event的类型,调用不同的WxHandler方法 */ public static WxOutMsg handleEvent(WxInMsg msg, WxHandler handler) { WxOutMsg out = null; switch (WxEventType.valueOf(msg.getEvent())) { case subscribe: out = handler.eventSubscribe(msg); break; case unsubscribe: out = handler.eventUnsubscribe(msg); break; case LOCATION: out = handler.eventLocation(msg); break; case SCAN: out = handler.eventScan(msg); break; case CLICK: out = handler.eventClick(msg); break; case VIEW: out = handler.eventView(msg); break; case TEMPLATESENDJOBFINISH: out = handler.eventTemplateJobFinish(msg); break; default: log.infof("New EventType=%s ? fallback to defaultMsg", msg.getMsgType()); out = handler.defaultMsg(msg); break; } return out; } /** * 根据输入信息,修正发送信息的发送者和接受者 */ public static WxOutMsg fix(WxInMsg in, WxOutMsg out) { out.setFromUserName(in.getToUserName()); out.setToUserName(in.getFromUserName()); out.setCreateTime(System.currentTimeMillis() / 1000); return out; } /** * 创建一条文本响应 */ public static WxOutMsg respText(String to, String content) { WxOutMsg out = new WxOutMsg("text"); out.setContent(content); if (to != null) out.setToUserName(to); return out; } /** * 创建一条图片响应 */ public static WxOutMsg respImage(String to, String mediaId) { WxOutMsg out = new WxOutMsg("image"); out.setImage(new WxImage(mediaId)); if (to != null) out.setToUserName(to); return out; } /** * 创建一个语音响应 */ public static WxOutMsg respVoice(String to, String mediaId) { WxOutMsg out = new WxOutMsg("voice"); out.setVoice(new WxVoice(mediaId)); if (to != null) out.setToUserName(to); return out; } /** * 创建一个视频响应 */ public static WxOutMsg respVideo(String to, String mediaId, String title, String description) { WxOutMsg out = new WxOutMsg("video"); out.setVideo(new WxVideo(mediaId, title, description)); if (to != null) out.setToUserName(to); return out; } /** * 创建一个音乐响应 */ public static WxOutMsg respMusic(String to, String title, String description, String musicURL, String hQMusicUrl, String thumbMediaId) { WxOutMsg out = new WxOutMsg("music"); out.setMusic(new WxMusic(title, description, musicURL, hQMusicUrl, thumbMediaId)); if (to != null) out.setToUserName(to); return out; } /** * 创建一个图文响应 */ public static WxOutMsg respNews(String to, WxArticle... articles) { return respNews(to, Arrays.asList(articles)); } /** * 创建一个图文响应 */ public static WxOutMsg respNews(String to, List<WxArticle> articles) { WxOutMsg out = new WxOutMsg("news"); out.setArticles(articles); if (to != null) out.setToUserName(to); return out; } // public static StringBuilder toWxXml(WxOutMsg out) { // Map<String, Object> map = Lang.obj2map(out); // StringBuilder sb = new StringBuilder(); // sb.append("<xml>\n"); // toWxXml(sb, map); // sb.append("</xml>"); // return sb; // } // // @SuppressWarnings("unchecked") // public static void toWxXml(StringBuilder sb, Map<String, Object> map) { // for (Entry<String, Object> en : map.entrySet()) { // Object obj = en.getValue(); // if (obj == null) // continue; // if (obj instanceof Number && ((Number)obj).intValue() == 0) { // continue; // } // sb.append("<" + Strings.upperFirst(en.getKey()) + ">"); // if (obj instanceof String) { // sb.append("<![CDATA[").append(obj).append("]]>"); // } else if (obj instanceof Number) { // sb.append(obj); // } else if (obj instanceof Map) { // sb.append("\n"); // toWxXml(sb, ((Map<String, Object>)obj)); // } else if (obj instanceof List) { // sb.append("\n"); // for (Object _obj : ((Collection<Object>)obj)) { // toWxXml(sb, ((Map<String, Object>)_obj)); // } // } else { // throw Lang.noImplement(); // } // sb.append("</" + Strings.upperFirst(en.getKey()) + ">\n"); // } // } public static String cdata(String str) { if (Strings.isBlank(str)) return ""; return "<![CDATA[" + str.replaceAll("]]", "__") + "]]>"; } public static String tag(String key, String val) { StringBuilder sb = new StringBuilder(); sb.append("<").append(key).append(">"); sb.append(val).append(""); sb.append("</").append(key).append(">\n"); return sb.toString(); } /** * @see #asXml(Writer, WxOutMsg) */ public static String asXml(WxOutMsg msg) { if (msg.raw() != null) return msg.raw(); StringWriter sw = new StringWriter(); asXml(sw, msg); return sw.toString(); } /** * 将一个WxOutMsg转为被动响应所需要的XML文本 * * @param msg * 微信消息输出对象 * * @return 输出的 XML 文本 */ public static void asXml(Writer writer, WxOutMsg msg) { try { Writer _out = writer; if (DEV_MODE) { writer = new StringWriter(); } writer.write("<xml>\n"); writer.write(tag("ToUserName", cdata(msg.getToUserName()))); writer.write(tag("FromUserName", cdata(msg.getFromUserName()))); writer.write(tag("CreateTime", "" + msg.getCreateTime())); writer.write(tag("MsgType", cdata(msg.getMsgType()))); switch (WxMsgType.valueOf(msg.getMsgType())) { case text: writer.write(tag("Content", cdata(msg.getContent()))); break; case image: writer.write(tag("Image", tag("MediaId", msg.getImage().getMediaId()))); break; case voice: writer.write(tag("Voice", tag("MediaId", msg.getVoice().getMediaId()))); break; case video: writer.write("<Video>\n"); writer.write(tag("MediaId", cdata(msg.getVideo().getMediaId()))); if (msg.getVideo().getTitle() != null) writer.write(tag("Title", cdata(msg.getVideo().getTitle()))); if (msg.getVideo().getDescription() != null) writer.write(tag("Description", cdata(msg.getVideo().getDescription()))); writer.write("</Video>\n"); break; case music: writer.write("<Music>\n"); WxMusic music = msg.getMusic(); if (music.getTitle() != null) writer.write(tag("Title", cdata(music.getTitle()))); if (music.getDescription() != null) writer.write(tag("Description", cdata(music.getDescription()))); if (music.getMusicUrl() != null) writer.write(tag("MusicUrl", cdata(music.getMusicUrl()))); if (music.getHQMusicUrl() != null) writer.write(tag("HQMusicUrl", cdata(music.getHQMusicUrl()))); writer.write(tag("ThumbMediaId", cdata(music.getThumbMediaId()))); writer.write("</Music>\n"); break; case news: writer.write(tag("ArticleCount", "" + msg.getArticles().size())); writer.write("<Articles>\n"); for (WxArticle article : msg.getArticles()) { writer.write("<item>\n"); if (article.getTitle() != null) writer.write(tag("Title", cdata(article.getTitle()))); if (article.getDescription() != null) writer.write(tag("Description", cdata(article.getDescription()))); if (article.getPicUrl() != null) writer.write(tag("PicUrl", cdata(article.getPicUrl()))); if (article.getUrl() != null) writer.write(tag("Url", cdata(article.getUrl()))); writer.write("</item>\n"); } writer.write("</Articles>\n"); break; case transfer_customer_service: if (msg.getKfAccount() != null) { writer.write("<TransInfo>\n"); writer.write(tag("KfAccount", cdata(msg.getKfAccount().getAccount()))); writer.write("</TransInfo>\n"); } break; default: break; } writer.write("</xml>"); if (DEV_MODE) { String str = writer.toString(); log.debug("Outcome >>\n" + str); _out.write(str); } } catch (IOException e) { throw Lang.wrapThrow(e); } } /** * @see #asJson(Writer, WxOutMsg) */ public static String asJson(WxOutMsg msg) { StringWriter sw = new StringWriter(); asJson(sw, msg); return sw.toString(); } /** * 将一个WxOutMsg转为主动信息所需要的Json文本 * * @param msg * 微信消息输出对象 * * @return 输出的 JSON 文本 */ public static void asJson(Writer writer, WxOutMsg msg) { NutMap map = new NutMap(); map.put("touser", msg.getToUserName()); map.put("msgtype", msg.getMsgType()); switch (WxMsgType.valueOf(msg.getMsgType())) { case text: map.put("text", new NutMap().setv("content", msg.getContent())); break; case image: map.put("image", new NutMap().setv("media_id", msg.getImage().getMediaId())); break; case voice: map.put("voice", new NutMap().setv("media_id", msg.getVoice().getMediaId())); break; case video: NutMap _video = new NutMap(); _video.setv("media_id", msg.getVideo().getMediaId()); if (msg.getVideo().getTitle() != null) _video.put("title", (msg.getVideo().getTitle())); if (msg.getVideo().getDescription() != null) _video.put("description", (msg.getVideo().getDescription())); map.put("video", _video); break; case music: NutMap _music = new NutMap(); WxMusic music = msg.getMusic(); if (music.getTitle() != null) _music.put("title", (music.getTitle())); if (music.getDescription() != null) _music.put("description", (music.getDescription())); if (music.getMusicUrl() != null) _music.put("musicurl", (music.getMusicUrl())); if (music.getHQMusicUrl() != null) _music.put("hqmusicurl", (music.getHQMusicUrl())); _music.put("thumb_media_id", (music.getThumbMediaId())); break; case news: NutMap _news = new NutMap(); List<NutMap> list = new ArrayList<NutMap>(); for (WxArticle article : msg.getArticles()) { NutMap item = new NutMap(); if (article.getTitle() != null) item.put("title", (article.getTitle())); if (article.getDescription() != null) item.put("description", (article.getDescription())); if (article.getPicUrl() != null) item.put("picurl", (article.getPicUrl())); if (article.getUrl() != null) item.put("url", (article.getUrl())); list.add(item); } _news.put("articles", list); map.put("news", _news); break; case mpnews: map.put("mpnews", new NutMap().setv("media_id", msg.getMedia_id())); break; case wxcard: map.put("wxcard", new NutMap().setv("card_id", msg.getCard().getId()) .setv("card_ext", msg.getCard().getExt())); break; default: break; } Json.toJson(writer, map); } /** * 用一个wxHandler处理对应的用户请求 */ public static View handle(WxHandler wxHandler, HttpServletRequest req, String key) throws IOException { if (wxHandler == null) { log.info("WxHandler is NULL"); return HttpStatusView.HTTP_502; } String signature = req.getParameter("signature"); String timestamp = req.getParameter("timestamp"); String nonce = req.getParameter("nonce"); String msg_signature = req.getParameter("msg_signature"); String encrypt_type = req.getParameter("encrypt_type"); if (!wxHandler.check(signature, timestamp, nonce, key)) { log.info("token is invalid"); return HttpStatusView.HTTP_502; } if ("GET".equalsIgnoreCase(req.getMethod())) { String echostr = req.getParameter("echostr"); log.info("GET? return echostr=" + echostr); return new ViewWrapper(new RawView(null), echostr); } String postData = Streams.readAndClose(new InputStreamReader(req.getInputStream(), Encoding.CHARSET_UTF8)); if ("aes".equals(encrypt_type)) { WXBizMsgCrypt msgCrypt = wxHandler.getMsgCrypt(); try { // 若抛出Illegal key size,请更新JDK的加密库为不限制长度 postData = msgCrypt.decryptMsg(msg_signature, timestamp, nonce, postData); } catch (AesException e) { return new HttpStatusView(403); } } WxInMsg in = Wxs.convert(postData); in.setExtkey(key); WxOutMsg out = wxHandler.handle(in); if (out != null) { Wxs.fix(in, out); } return new ViewWrapper(WxView.me, out); } /** * 下载媒体文件(放到临时目录中), 返回对应文件 * * @param accessToken * @param mediaId */ public static File downloadMedia(String accessToken, String mediaId) { String url = "http://file.api.weixin.qq.com/cgi-bin/media/get?access_token=" + accessToken + "&media_id=" + mediaId; File mf = null; for (int i = 0; i < 3; i++) { InputStream in = null; OutputStream out = null; try { Response resp = Http.get(url, 60 * 1000); if (resp.isOK()) { in = resp.getStream(); mf = File.createTempFile(mediaId, ".wxmedia"); out = new FileOutputStream(mf); Streams.writeAndClose(out, in); // 检查一下是不是报错 if (mf.length() < 128) { byte[] data = Files.readBytes(mf); if (data[0] == '{') { // 看上去是个json,悲催了... // 多媒体文件怎么可能是{开头,抛错吧 throw new IllegalArgumentException("mediaId=" + mediaId + "," + new String(data)); } } log.debugf("media download success mediaId=" + mediaId); break; } else {} } catch (Throwable e) { log.infof("download %s fail", mediaId, e); } finally { Streams.safeClose(in); Streams.safeClose(out); } } return mf; } public static WxOutMsg respText(String content) { return respText(null, content); } public static String pojoClass2MapClass(Class<?> klass) { StringBuilder sb = new StringBuilder(); sb.append("package " + klass.getPackage().getName() + ";\r\n\r\n"); sb.append("import org.nutz.lang.util.NutMap;\r\n\r\n"); sb.append("@SuppressWarnings(\"serial\")\r\n"); sb.append("public class " + klass.getSimpleName() + " extends NutMap {\r\n"); for (Field field : klass.getDeclaredFields()) { mapField(sb, klass, field); } sb.append("}"); return sb.toString(); } @SuppressWarnings("rawtypes") public static void mapField(StringBuilder sb, Class<?> klass, Field field) { sb.append("\r\n"); String fieldName = field.getName(); String className = klass.getSimpleName(); Mirror mirror = Mirror.me(field.getType()); String getterTmpl = "return (${fieldType})get(\"${fieldName}\")"; if (mirror.isPrimitiveNumber()) { if (mirror.isBoolean()) { getterTmpl = "return getBoolean(\"${fieldName}\", false)"; } else { getterTmpl = "return get" + Strings.upperFirst(mirror.getType().getSimpleName()) + "(\"${fieldName}\", 0)"; } } Tmpl tmpl = Tmpl.parse(" public ${className} set${upperFieldName}(${fieldType} ${fieldName}){\r\n" + " put(\"${fieldName}\", ${fieldName});\r\n" + " return this;\r\n" + " }\r\n" + "\r\n" + " public ${fieldType} get${upperFieldName}(){\r\n" + " " + getterTmpl + ";\r\n" + " }\r\n"); NutMap ctx = new NutMap().setv("className", className).setv("fieldName", fieldName); ctx.setv("upperFieldName", Strings.upperFirst(fieldName)); ctx.setv("fieldType", field.getType().getSimpleName()); sb.append(tmpl.render(ctx)); } public static DocumentBuilder xmls() throws ParserConfigurationException, SAXException, IOException { // 修复XXE form // https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=23_5 DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); String FEATURE = null; FEATURE = "http://apache.org/xml/features/disallow-doctype-decl"; factory.setFeature(FEATURE, true); FEATURE = "http://xml.org/sax/features/external-general-entities"; factory.setFeature(FEATURE, false); FEATURE = "http://xml.org/sax/features/external-parameter-entities"; factory.setFeature(FEATURE, false); FEATURE = "http://apache.org/xml/features/nonvalidating/load-external-dtd"; factory.setFeature(FEATURE, false); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); return factory.newDocumentBuilder(); } }