实战技能:小小微信支付业务,何必虚惊一场
记得上次接触微信支付是2016年底,那次也是我程序生涯中首次碰及支付业务,慌张谈不上但是懵逼怀疑时时都有。说起第三方登录或者支付,想必都清楚是直接调用人家现成的API,完全没有开发成本和技术含量。但是我想说:如果你没有过一次从零开发并维护过一个完整支付模块的话,我相信有很多坑对你来说都是黑盒的,也许现在的公司或多或少都涵盖了支付业务,可能被早先的老程序员们已经维护的差不多完美了,完全是可以当成模板功能来使用,新手只需复制大体功能架子填充支付后的业务逻辑即可。
好了,切入正题吧。为什么突然在各位前辈面前摆上这么一篇没有任何技术含量的文章呢?因为好久没有在博客园发布过文章了,刚刚瞻仰完各位大佬分享的东西后心里很空虚。其次是主要原因,由于前几天休假刚回来就被刚刚入职的哥们拉住绕了一会儿,说这俩天测试人员反应充一次钱之后会看到好几条充值记录并且是偶现的,而且相应的游戏币也累加了很多次等等,然后还有一个更坑的现象就是照着微信开发文档调用接口,唤醒微信支付的组件开始调用预支付(统一下单)接口时抛签名错误的异常,竟然调不通(声明:签名方式已经是确保无误的)。。。真的,听他一说还真回忆起当年那个手忙脚乱的自己,在开发过程中各种懵逼、各种怀疑人生。所以今天就把这些踩坑经历分享一下,仅献给圈里刚刚接触或要开始接手支付业务的朋友,希望各位在铸造支付模块时能够一马平川。
准备工作:
- 申请注册微信开发平台账号,申请需支付300元,心里有个数啊。
- 在注册通过之后,到管理中心申请添加你们的APP应用。
- 如果APP应用审核通过之后,就可以申请开放微信支付功能,比如像分享到朋友圈、微信登录等功能都是自动开放的,无需申请。
- 最后如果微信支付功能审核也通过后,微信会给你分配一个微信商户号,基本包含了:登录账户号、密码、APPID和支付商户号等信息,这些信息要自己保留好,后续调用支付相关的接口时会用到。
服务端支付业务开发步骤:
1、登录微信商户平台,注意是商户平台不是开放平台,根据业务场景选择适合自己的支付类型,进入之后就可以看看具体的API列表,除此之外还提供了业务场景举例、业务流程时序图等非常清晰。提醒一点微信还提供了专门的demo压缩包,里面包含了工具类WXPayUtil建议下载,因为相信你能用到,是百分百能用到。
1 package com.qy.utils; 2 3 import java.io.ByteArrayInputStream; 4 import java.io.InputStream; 5 import java.io.StringWriter; 6 import java.util.*; 7 import java.security.MessageDigest; 8 import org.w3c.dom.Node; 9 import org.w3c.dom.NodeList; 10 11 import com.qy.utils.WXPayConstants.SignType; 12 13 import javax.crypto.Mac; 14 import javax.crypto.spec.SecretKeySpec; 15 import javax.xml.parsers.DocumentBuilder; 16 import javax.xml.parsers.DocumentBuilderFactory; 17 import javax.xml.transform.OutputKeys; 18 import javax.xml.transform.Transformer; 19 import javax.xml.transform.TransformerFactory; 20 import javax.xml.transform.dom.DOMSource; 21 import javax.xml.transform.stream.StreamResult; 22 import org.slf4j.Logger; 23 import org.slf4j.LoggerFactory; 24 25 26 public class WXPayUtil { 27 28 /** 29 * XML格式字符串转换为Map 30 * 31 * @param strXML XML字符串 32 * @return XML数据转换后的Map 33 * @throws Exception 34 */ 35 public static Map<String, String> xmlToMap(String strXML) throws Exception { 36 try { 37 Map<String, String> data = new HashMap<String, String>(); 38 DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); 39 DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); 40 InputStream stream = new ByteArrayInputStream(strXML.getBytes("UTF-8")); 41 org.w3c.dom.Document doc = documentBuilder.parse(stream); 42 doc.getDocumentElement().normalize(); 43 NodeList nodeList = doc.getDocumentElement().getChildNodes(); 44 for (int idx = 0; idx < nodeList.getLength(); ++idx) { 45 Node node = nodeList.item(idx); 46 if (node.getNodeType() == Node.ELEMENT_NODE) { 47 org.w3c.dom.Element element = (org.w3c.dom.Element) node; 48 data.put(element.getNodeName(), element.getTextContent()); 49 } 50 } 51 try { 52 stream.close(); 53 } catch (Exception ex) { 54 // do nothing 55 } 56 return data; 57 } catch (Exception ex) { 58 WXPayUtil.getLogger().warn("Invalid XML, can not convert to map. Error message: {}. XML content: {}", ex.getMessage(), strXML); 59 throw ex; 60 } 61 62 } 63 64 /** 65 * 将Map转换为XML格式的字符串 66 * 67 * @param data Map类型数据 68 * @return XML格式的字符串 69 * @throws Exception 70 */ 71 public static String mapToXml(Map<String, String> data) throws Exception { 72 DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); 73 DocumentBuilder documentBuilder= documentBuilderFactory.newDocumentBuilder(); 74 org.w3c.dom.Document document = documentBuilder.newDocument(); 75 org.w3c.dom.Element root = document.createElement("xml"); 76 document.appendChild(root); 77 for (String key: data.keySet()) { 78 String value = data.get(key); 79 if (value == null) { 80 value = ""; 81 } 82 value = value.trim(); 83 org.w3c.dom.Element filed = document.createElement(key); 84 filed.appendChild(document.createTextNode(value)); 85 root.appendChild(filed); 86 } 87 TransformerFactory tf = TransformerFactory.newInstance(); 88 Transformer transformer = tf.newTransformer(); 89 DOMSource source = new DOMSource(document); 90 transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); 91 transformer.setOutputProperty(OutputKeys.INDENT, "yes"); 92 StringWriter writer = new StringWriter(); 93 StreamResult result = new StreamResult(writer); 94 transformer.transform(source, result); 95 String output = writer.getBuffer().toString(); //.replaceAll("\n|\r", ""); 96 try { 97 writer.close(); 98 } 99 catch (Exception ex) { 100 } 101 return output; 102 } 103 104 105 /** 106 * 生成带有 sign 的 XML 格式字符串 107 * 108 * @param data Map类型数据 109 * @param key API密钥 110 * @return 含有sign字段的XML 111 */ 112 public static String generateSignedXml(final Map<String, String> data, String key) throws Exception { 113 return generateSignedXml(data, key, SignType.MD5); 114 } 115 116 /** 117 * 生成带有 sign 的 XML 格式字符串 118 * 119 * @param data Map类型数据 120 * @param key API密钥 121 * @param signType 签名类型 122 * @return 含有sign字段的XML 123 */ 124 public static String generateSignedXml(final Map<String, String> data, String key, SignType signType) throws Exception { 125 String sign = generateSignature(data, key, signType); 126 data.put(WXPayConstants.FIELD_SIGN, sign); 127 return mapToXml(data); 128 } 129 130 131 /** 132 * 判断签名是否正确 133 * 134 * @param xmlStr XML格式数据 135 * @param key API密钥 136 * @return 签名是否正确 137 * @throws Exception 138 */ 139 public static boolean isSignatureValid(String xmlStr, String key) throws Exception { 140 Map<String, String> data = xmlToMap(xmlStr); 141 if (!data.containsKey(WXPayConstants.FIELD_SIGN) ) { 142 return false; 143 } 144 String sign = data.get(WXPayConstants.FIELD_SIGN); 145 return generateSignature(data, key).equals(sign); 146 } 147 148 /** 149 * 判断签名是否正确,必须包含sign字段,否则返回false。使用MD5签名。 150 * 151 * @param data Map类型数据 152 * @param key API密钥 153 * @return 签名是否正确 154 * @throws Exception 155 */ 156 public static boolean isSignatureValid(Map<String, String> data, String key) throws Exception { 157 return isSignatureValid(data, key, SignType.MD5); 158 } 159 160 /** 161 * 判断签名是否正确,必须包含sign字段,否则返回false。 162 * 163 * @param data Map类型数据 164 * @param key API密钥 165 * @param signType 签名方式 166 * @return 签名是否正确 167 * @throws Exception 168 */ 169 public static boolean isSignatureValid(Map<String, String> data, String key, SignType signType) throws Exception { 170 if (!data.containsKey(WXPayConstants.FIELD_SIGN) ) { 171 return false; 172 } 173 String sign = data.get(WXPayConstants.FIELD_SIGN); 174 return generateSignature(data, key, signType).equals(sign); 175 } 176 177 /** 178 * 生成签名 179 * 180 * @param data 待签名数据 181 * @param key API密钥 182 * @return 签名 183 */ 184 public static String generateSignature(final Map<String, String> data, String key) throws Exception { 185 return generateSignature(data, key, SignType.MD5); 186 } 187 188 /** 189 * 生成签名. 注意,若含有sign_type字段,必须和signType参数保持一致。 190 * 191 * @param data 待签名数据 192 * @param key API密钥 193 * @param signType 签名方式 194 * @return 签名 195 */ 196 public static String generateSignature(final Map<String, String> data, String key, SignType signType) throws Exception { 197 Set<String> keySet = data.keySet(); 198 String[] keyArray = keySet.toArray(new String[keySet.size()]); 199 Arrays.sort(keyArray); 200 StringBuilder sb = new StringBuilder(); 201 for (String k : keyArray) { 202 if (k.equals(WXPayConstants.FIELD_SIGN)) { 203 continue; 204 } 205 if (data.get(k).trim().length() > 0) // 参数值为空,则不参与签名 206 sb.append(k).append("=").append(data.get(k).trim()).append("&"); 207 } 208 sb.append("key=").append(key); 209 if (SignType.MD5.equals(signType)) { 210 return MD5(sb.toString()).toUpperCase(); 211 } 212 else if (SignType.HMACSHA256.equals(signType)) { 213 return HMACSHA256(sb.toString(), key); 214 } 215 else { 216 throw new Exception(String.format("Invalid sign_type: %s", signType)); 217 } 218 } 219 220 221 /** 222 * 获取随机字符串 Nonce Str 223 * 224 * @return String 随机字符串 225 */ 226 public static String generateNonceStr() { 227 return UUID.randomUUID().toString().replaceAll("-", "").substring(0, 32); 228 } 229 230 231 /** 232 * 生成 MD5 233 * 234 * @param data 待处理数据 235 * @return MD5结果 236 */ 237 public static String MD5(String data) throws Exception { 238 java.security.MessageDigest md = MessageDigest.getInstance("MD5"); 239 byte[] array = md.digest(data.getBytes("UTF-8")); 240 StringBuilder sb = new StringBuilder(); 241 for (byte item : array) { 242 sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3)); 243 } 244 return sb.toString().toUpperCase(); 245 } 246 247 /** 248 * 生成 HMACSHA256 249 * @param data 待处理数据 250 * @param key 密钥 251 * @return 加密结果 252 * @throws Exception 253 */ 254 public static String HMACSHA256(String data, String key) throws Exception { 255 Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); 256 SecretKeySpec secret_key = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"); 257 sha256_HMAC.init(secret_key); 258 byte[] array = sha256_HMAC.doFinal(data.getBytes("UTF-8")); 259 StringBuilder sb = new StringBuilder(); 260 for (byte item : array) { 261 sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3)); 262 } 263 return sb.toString().toUpperCase(); 264 } 265 266 /** 267 * 日志 268 * @return 269 */ 270 public static Logger getLogger() { 271 Logger logger = LoggerFactory.getLogger("wxpay java sdk"); 272 return logger; 273 } 274 275 /** 276 * 获取当前时间戳,单位秒 277 * @return 278 */ 279 public static long getCurrentTimestamp() { 280 return System.currentTimeMillis()/1000; 281 } 282 283 /** 284 * 获取当前时间戳,单位毫秒 285 * @return 286 */ 287 public static long getCurrentTimestampMs() { 288 return System.currentTimeMillis(); 289 } 290 291 /** 292 * 生成 uuid, 即用来标识一笔单,也用做 nonce_str 293 * @return 294 */ 295 public static String generateUUID() { 296 return UUID.randomUUID().toString().replaceAll("-", "").substring(0, 32); 297 } 298 299 }
2、移动端唤起微信支付的组件,首先发起开始支付的动作,服务端就会相应的调用预支付(统一下单)接口https://api.mch.weixin.qq.com/pay/unifiedorder,注意该过程需要对参数进行签名,然后将带有签名字段的参数作为接口形参,过程比较繁琐建议封装一下,后续的操作还有会用到的,最后拿到微信服务器返回的结果后解析出移动端需要的参数,记得签名后返给前端。好了部分源码可以参考一下:
1 String body = "抓乐GO"; 2 String nonceStr = WXPayUtil.generateNonceStr(); //获取随机字符串 3 String outTradeNo = WXPayUtil.generateUUID(); //商户订单号(本次交易订单号) 4 String tradeType = "APP"; //支付类型:APP 5 Double money = tokenInfo.getMoney() * 100.0; //金额(单位/分) 6 //int totalFee = (new Double(money)).intValue(); 7 int totalFee = (int) Math.ceil(money); 8 logger.info("------------------支付金额为:{}",totalFee); 9 String parames = "<xml><appid>"+appID+"</appid><body>"+body+"</body><mch_id>" 10 +mchID+"</mch_id><nonce_str>"+nonceStr+"</nonce_str><notify_url>"+notifyUrl 11 +"</notify_url><out_trade_no>"+outTradeNo+"</out_trade_no><spbill_create_ip>"+spbillCreateIp 12 +"</spbill_create_ip><total_fee>"+totalFee+"</total_fee><trade_type>"+tradeType 13 +"</trade_type></xml>"; 14 Map<String, String> mapXML = WXPayUtil.xmlToMap(parames); //将不包含sign字段的xml字符串转成map 15 String sign = WXPayUtil.generateSignature(mapXML, appKey); //生成签名 16 17 String signParames = "<xml><appid>"+appID+"</appid><body>"+body+"</body><mch_id>" 18 +mchID+"</mch_id><nonce_str>"+nonceStr+"</nonce_str><notify_url>"+notifyUrl 19 +"</notify_url><out_trade_no>"+outTradeNo+"</out_trade_no><spbill_create_ip>"+spbillCreateIp 20 +"</spbill_create_ip><total_fee>"+totalFee+"</total_fee><trade_type>"+tradeType 21 +"</trade_type><sign>"+sign+"</sign></xml>"; //预支付接口xml参数(包含sign) 22 Map<String, String> mapXML1 = WXPayUtil.xmlToMap(signParames); 23 boolean boo = WXPayUtil.isSignatureValid(mapXML1, appKey); //校验签名是否正确
1 if("SUCCESS".equals(dataMap.get("result_code"))){ 2 logger.info("预支付接口调用成功:"); 3 //预支付调用成功 4 //二次签名 5 Map<String,String> signMap = new LinkedHashMap<String,String>(); 6 signMap.put("appid", dataMap.get("appid")); 7 signMap.put("partnerid", dataMap.get("mch_id")); 8 signMap.put("prepayid", dataMap.get("prepay_id")); 9 signMap.put("package", "Sign=WXPay"); 10 signMap.put("noncestr", WXPayUtil.generateNonceStr()); 11 signMap.put("timestamp", String.valueOf(System.currentTimeMillis()/1000)); 12 String appSign = WXPayUtil.generateSignature(signMap, appKey); 13 signMap.put("sign", appSign); 14 signMap.put("outTradeNo", outTradeNo); //支付订单号 15 //String signXml = WXPayUtil.mapToXml(signMap); 16 //System.out.println(signXml); 17 dataJson.put("code", Constants.HTTP_RESPONSE_SUCCESS); 18 dataJson.put("msg", Constants.HTTP_RESPONSE_SUCCESS_MSG); 19 dataJson.put("data", signMap); 20 logger.info("返给APP的二次签名数据:{}",signMap);
3、移动端拿到支付参数后就会真正调起支付操作,这时后端只需做一个供微信回调的接口,该接口的作用主要是接受每次支付的支付结果。注意:该回调地址需要在发起预支付接口时务必告诉微信服务器,而且还要保证能够畅通无阻。当完成一笔支付操作后,微信服务器就立刻会调用你提供的自定义回调接口告诉你支付结果,你只需完成支付成功后的业务逻辑,即视为本次支付过程结束。
1 public String payCallback(HttpServletRequest request, HttpServletResponse response){ 2 BufferedReader reader = null; 3 try { 4 reader = request.getReader(); 5 String line = ""; 6 String xmlString = null; 7 StringBuffer inputString = new StringBuffer(); 8 while ((line = reader.readLine()) != null) { 9 inputString.append(line); 10 } 11 xmlString = inputString.toString(); 12 if(WXPayUtil.isSignatureValid(xmlString,appKey)){ 13 logger.info("微信支付结果{}",xmlString); 14 request.getReader().close(); 15 Map<String, String> resultMap = WXPayUtil.xmlToMap(xmlString); //支付结果 16 if("SUCCESS".equals(resultMap.get("return_code"))){
支付成功后的业务逻辑....
4、当然,微信也专门提供了查询某笔支付订单的支付结果的接口https://api.mch.weixin.qq.com/pay/orderquery,详情自行查询。
1 public String getOrderResult(String outTradeNo){ 2 logger.info("查询支付订单号{}支付结果================",outTradeNo); 3 Map<String,Object> dataJson = new LinkedHashMap<String,Object>(); 4 try { 5 String nonceStr = WXPayUtil.generateNonceStr(); //获取随机字符串 6 //请求参数 7 String parames = "<xml><appid>"+appID+"</appid><mch_id>"+mchID+"</mch_id>" 8 +"<nonce_str>"+nonceStr+"</nonce_str>" 9 + "<out_trade_no>"+outTradeNo+"</out_trade_no></xml>"; 10 String signXMLData = WXPayUtil.generateSignedXml(WXPayUtil.xmlToMap(parames), appKey); //生成带有签名的xml 11 String queryResult = HttpClient.doPostXML(orderQueryURL, signXMLData); //查询结果xml 12 if(WXPayUtil.isSignatureValid(queryResult,appKey)){ 13 Map<String, String> wxPayResult = WXPayUtil.xmlToMap(queryResult); //微信支付的结果通知
OK,到这儿整个支付业务算是真正跑通了,勉强画条暂时的分割线吧。
- 为什么调用统一下单(预支付)接口在正确签名后,还是调不通,总提示签名错误?
这个问题确实对于很多新手来说是狠TM扯淡的,调不通还老提示签名错误可能是因为:http请求的参数列表中body那个字段你传的是中文,并且微信开发文档中的案例模板也是中文。
解决方案:只需将最终发送的参数列表进行编码处理即可,但是你也可以全部传入英文。
1 //如果校验通过,则调用预支付 2 logger.info("开始调用微信预支付接口:https://api.mch.weixin.qq.com/pay/unifiedorder"); 3 signParames = new String(signParames.getBytes("UTF-8"), "ISO-8859-1"); 4 String result = HttpClient.doPostXML(payURL, signParames); 5 Map<String, String> dataMap = WXPayUtil.xmlToMap(result); 6 logger.info("预支付结果:{}",result);
- 为什么支付会给产生多条充值明细,并且还给用户累加多次余额?
这个问题就有点考验你写接口的质量了,出现仅支付一次产生多条支付明细记录的情况,首先是因为你没有做好在成功拿到微信回调结果后及时对当前支付记录做好重复处理的逻辑,因为那哥们儿发起一笔支付请求后在成功拿到支付结果没有告诉微信支付成功,所以微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功,通知频率为15/15/30/180/1800/1800/1800/1800/3600,单位:秒。所以不断处理同一笔支付订单。其次就是没有考虑并发的情况,需要对拿到的回调结果做线程安全的处理,可以有俩种方案:第一种就是在数据库层面上做限制,设置联合主键将重复操作的支付记录数据挡在外面不允许插入数据库;第二种是在业务层加锁,在处理每笔支付结果时判断是否已经处理过了,如果处理过就忽略当前回调结果否则正常处理。提醒一点:不要直接在方法上直接添加synchronized,还有在加锁的时候尽量将锁的粒度控制到最小,否则会影响接口的性能。(参考:http://www.cnblogs.com/1315925303zxz/p/7561236.html)
更多实战技能,请关注本人技术文章公众号:xz_303 期待与君共勉!