一.小程序支付
参考小程序支付开发文档:https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=7_4&index=3
账号支持:小程序appid,小程序secret,商户号mchid,商户secret
服务端和微信支付系统主要交互:
1、小程序内调用登录接口,获取到用户的openid,api参见公共api【小程序登录API】
前端调用接口wx.login() 获取临时登录凭证(code)
通过code值获取openid地址:
https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
请求参数:
参数 | 必填 | 说明 |
---|---|---|
appid | 是 | 小程序唯一标识 |
secret | 是 | 小程序的 app secret |
js_code | 是 | 登录时获取的 code |
grant_type | 是 | 填写为 authorization_code |
发送一个get请求即可参数也比较容易理解,这样我们就完成了第一步获取到了用户的openid
2、商户server调用支付统一下单,api参见公共api【统一下单API】
统一下单接口地址:
https://api.mch.weixin.qq.com/pay/unifiedorder
请求参数(有删减):
字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 |
小程序ID | appid | 是 | String(32) | wxd678efh567hg6787 | 微信分配的小程序ID |
商户号 | mch_id | 是 | String(32) | 1230000109 | 微信支付分配的商户号 |
随机字符串 | nonce_str | 是 | String(32) | 5K8264ILTKCH16CQ2502SI8ZNMTM67VS | 随机字符串,长度要求在32位以内。推荐随机数生成算法 |
签名 | sign | 是 | String(32) | C380BEC2BFD727A4B6845133519F3AD6 | 通过签名算法计算得出的签名值,详见签名生成算法 |
商品描述 | body | 是 | String(128) | 腾讯充值中心-QQ会员充值 | 商品简单描述,该字段请按照规范传递,具体请见参数规定 |
商户订单号 | out_trade_no | 是 | String(32) | 20150806125346 | 商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|*且在同一个商户号下唯一。详见商户订单号 |
标价金额 | total_fee | 是 | Int | 88 | 订单总金额,单位为分,详见支付金额 |
终端IP | spbill_create_ip | 是 | String(16) | 123.12.12.123 | APP和网页支付提交用户端ip。 |
通知地址 | notify_url | 是 | String(256) | http://www.weixin.qq.com/wxpay/pay.php | 异步接收微信支付结果通知的回调地址,通知url必须为外网可访问的url,不能携带参数。 |
交易类型 | trade_type | 是 | String(16) | JSAPI | 小程序取值如下:JSAPI,详细说明见参数规定 |
用户标识 | openid | 否 | String(128) | oUpF8uMuAJO_M2pxb1Q9zNjWeS6o | trade_type=JSAPI,此参数必传,用户在商户appid下的唯一标识。openid如何获取,可参考【获取openid】。 |
提供代码片段:
/** * 商户发起生成预付单请求(统一下单接口) * * @param body 商品信息 * @param orderId 商户自己的订单号 * @param totalFee 支付价格(单位分) * @param openid 微信用户的openid(必须关注此公众号的openid) * @param notifyUrl 微信回调地址 */ public Map<String, Object> unifiedOrder(String body, Long orderId, Long totalFee, String openid, String notifyUrl) { //微信账号校验 checkConfig(weixinProperties); SortedMap<String, Object> params = new TreeMap<>(); //小程序appid params.put("appid", "**************"); //商户号 params.put("mch_id", "**************"); params.put("spbill_create_ip", getUserContext().getIp()); params.put("trade_type", "JSAPI"; params.put("nonce_str", StringUtil.getRandomStringByLength(24)); params.put("notify_url", notifyUrl); params.put("body", body); params.put("out_trade_no", fictitiousOrderService.insertFictitiousOrder(orderId, body, totalFee).toString()); params.put("total_fee", totalFee); params.put("openid", openid); //签名算法 String sign = getSign(params); params.put("sign", sign); String xml = XmlHelper.mapToXml(params); try { //发送xmlPost请求 String xmlStr = HttpUtil.xmlPost("https://api.mch.weixin.qq.com/pay/unifiedorder", xml); //加入微信支付日志 payWechatLogService.insertPayWechatLog(Constants.PAY_UNIFIED_ORDER_RESULT_LOG, xmlStr); Map map = XmlHelper.xmlToMap(xmlStr); if (map != null && Constants.REQUEST_SUCCESS.equals(map.get("result_code")) && map.get("prepay_id") != null) { //返回二次签名前端调用微信信息 Map<String, Object> result = secondarySign(map); return result; } else { //异常通知 mqSendService.sendRobotMsg( String.format("微信消息-传入参数[%s];微信输出[%s]", JSON.toJSONString(params), JSON.toJSONString(map)), "统一下单"); throw serviceExceptionService.createServiceException(ExceptionConstants.UNIFIED_ORDER_INTERFACE_ERROR); } } catch (Exception e) { mqSendService.sendRobotMsg(String.format("微信消息-[%s]", e.getMessage()), "统一下单"); //统一下单接口异常 throw serviceExceptionService.createServiceException(ExceptionConstants.UNIFIED_ORDER_INTERFACE_ERROR); } }
方法注意点:
参数out_trade_no商户订单号即自己数据库存储的订单号这里有个坑,统一下单接口不予许同一订单发起两次下单请求,但是我们业务常常有这个需要。解决的办法是下单时用商户订单号生成一个虚拟订单号,用虚拟支付单号去请求微信,微信回调时用返回的虚拟支付单号解析出商户的订单号,修改订单的支付状态。
微信下单和退款的接口请求方式都是将参数转为xml格式发送跑
/** * 获取签名 md5加密(微信支付必须用MD5加密) 获取支付签名 */ public String getSign(SortedMap<String, Object> params) { StringBuffer sb = new StringBuffer(); //所有参与传参的参数按照accsii排序(升序) Set es = params.entrySet(); Iterator it = es.iterator(); while (it.hasNext()) { Map.Entry entry = (Map.Entry) it.next(); String k = (String) entry.getKey(); Object v = entry.getValue(); if (null != v && !"".equals(v) && !"sign".equals(k) && !"key".equals(k)) { sb.append(k + "=" + v + "&"); } } //key后面接商户号的秘钥secret sb.append("key=" + "*************"); String sign = EncryptUtil.MD5Encode(sb.toString(), "UTF-8").toUpperCase(); return sign; }
签名规则:
- 所有的参数按照accsii排序(升序),
- 所有参数按照key1=value1&key2=value2&...拼接成字符串
- 最后在key后面拼上秘钥(注:该秘钥是商户秘钥不是小程序秘钥)
- 通过MD5加密字符串后将所有的字符改为大写
微信在线签名校验工具地址:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=20_1
注:微信下单和退款的接口请求方式都是将参数转为xml格式发送post请求,提供xmlPost方法代码
/** * 微信提交xml参数 */ public static String xmlPost(String url1, String xml) { try { // 创建url资源 URL url = new URL(url1); // 建立http连接 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); // 设置允许输出 conn.setDoOutput(true); conn.setDoInput(true); // 设置不用缓存 conn.setUseCaches(false); // 设置传递方式 conn.setRequestMethod("POST"); // 设置维持长连接 conn.setRequestProperty("Connection", "Keep-Alive"); // 设置文件字符集: conn.setRequestProperty("Charset", "UTF-8"); //转换为字节数组 byte[] data = xml.getBytes(); // 设置文件长度 conn.setRequestProperty("Content-Length", String.valueOf(data.length)); // 设置文件类型: conn.setRequestProperty("contentType", "text/xml"); // 开始连接请求 conn.connect(); OutputStream out = conn.getOutputStream(); // 写入请求的字符串 out.write(data); out.flush(); out.close(); System.out.println(conn.getResponseCode()); // 请求返回的状态 if (conn.getResponseCode() == 200) { System.out.println("连接成功"); // 请求返回的数据 InputStream in = conn.getInputStream(); String a = null; try { byte[] data1 = new byte[in.available()]; in.read(data1); // 转成字符串 a = new String(data1); System.out.println(a); } catch (Exception e1) { // TODO Auto-generated catch block e1.printStackTrace(); } return a; } else { System.out.println("no++"); } } catch (Exception e) { } return null; }
3、商户server调用再次签名,api参见公共api【再次签名】
通过统一下单接口我们可以拿到prepay_id,将prepay_id组装成package进行签名。
签名参数:字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 |
---|---|---|---|---|---|
小程序ID | appId | 是 | String | wxd678efh567hg6787 | 微信分配的小程序ID |
时间戳 | timeStamp | 是 | String | 1490840662 | 时间戳从1970年1月1日00:00:00至今的秒数,即当前的时间 |
随机串 | nonceStr | 是 | String | 5K8264ILTKCH16CQ2502SI8ZNMTM67VS | 随机字符串,不长于32位。推荐随机数生成算法 |
数据包 | package | 是 | String | prepay_id=wx2017033010242291fcfe0db70013231072 | 统一下单接口返回的 prepay_id 参数值,提交格式如:prepay_id=wx2017033010242291fcfe0db70013231072 |
签名方式 | signType | 是 | String | MD5 | 签名类型,默认为MD5,支持HMAC-SHA256和MD5。注意此处需与统一下单的签名类型一致 |
/** * 二次签名 */ private Map<String, Object> secondarySign(Map map) { SortedMap<String, Object> secondarySignParam = new TreeMap<>(); secondarySignParam.put("appId", weixinProperties.getMiniapp().getUser().getAppId()); secondarySignParam.put("timeStamp", new Date().getTime() + ""); secondarySignParam.put("nonceStr", StringUtil.getRandomStringByLength(24)); secondarySignParam.put("package", "prepay_id=" + map.get("prepay_id").toString()); secondarySignParam.put("signType", "MD5"); String paySign = getSign(secondarySignParam); secondarySignParam.put("paySign", paySign); //签完名去掉appId防止暴露账号 secondarySignParam.remove("appId"); return secondarySignParam; }
前端调起微信支付参数:
参数 | 类型 | 必填 | 说明 |
---|---|---|---|
timeStamp | String | 是 | 时间戳从1970年1月1日00:00:00至今的秒数,即当前的时间 |
nonceStr | String | 是 | 随机字符串,长度为32个字符以下。 |
package | String | 是 | 统一下单接口返回的 prepay_id 参数值,提交格式如:prepay_id=* |
signType | String | 是 | 签名类型,默认为MD5,支持HMAC-SHA256和MD5。注意此处需与统一下单的签名类型一致 |
paySign | String | 是 | 签名,具体签名方案参见微信公众号支付帮助文档; |
组装前端所需要的参数通过统一下单接口直接返回给前端,前端调用wx.requestPayment(OBJECT)发起微信支付。
4、商户server接收支付通知,api参见公共api【支付结果通知API】
接口地址为【统一下单API】中提交的参数notify_url设置,如果链接无法访问,商户将无法接收到微信通知。
/** * 微信支付回调 */ @RequestMapping(value = "/notify", produces = "application/xml; charset=UTF-8") @ResponseBody public void notify(HttpServletRequest request, HttpServletResponse response) throws Exception { BufferedReader reader = request.getReader(); StringBuffer inputString = new StringBuffer(); String line = ""; while ((line = reader.readLine()) != null) { inputString.append(line); } payWechatLogService.insertPayWechatLog(Constants.PAY_SUCCESS_RESULT_LOG, inputString.toString()); Map<String, String> map = XmlHelper.xmlToMap(inputString.toString()); String return_code = map.get("return_code"); //客户订单id(虚拟支付单号) String out_trade_no = map.get("out_trade_no"); //微信支付订单号(流水号) String transaction_id = map.get("transaction_id"); //todo 修改订单对应的状态 //商户处理后同步返回给微信参数 response.getWriter().print("<xml><return_code><![CDATA[" + return_code + "]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"); }
回调成功给微信发送通知,不然微信会继续回调该接口
二.微信退款
参考小程序支付开发文档:https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=9_4
账号支持:小程序appid,商户号mchid,商户secret,退款证书,退款证书密码
系统中有了支付肯定会设及到退款
申请退款接口地址:https://api.mch.weixin.qq.com/secapi/pay/refund(请求需要双向证书。 详见证书使用)
请求参数:
字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 |
小程序ID | appid | 是 | String(32) | wx8888888888888888 | 微信分配的小程序ID |
商户号 | mch_id | 是 | String(32) | 1900000109 | 微信支付分配的商户号 |
随机字符串 | nonce_str | 是 | String(32) | 5K8264ILTKCH16CQ2502SI8ZNMTM67VS | 随机字符串,不长于32位。推荐随机数生成算法 |
签名 | sign | 是 | String(32) | C380BEC2BFD727A4B6845133519F3AD6 | 签名,详见签名生成算法 |
微信订单号 | transaction_id | 二选一 | String(32) | 1217752501201407033233368018 | 微信生成的订单号,在支付通知中有返回 |
商户订单号 | out_trade_no | String(32) | 1217752501201407033233368018 | 商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|*@ ,且在同一个商户号下唯一。 | |
商户退款单号 | out_refund_no | 是 | String(64) | 1217752501201407033233368018 | 商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 |
订单金额 | total_fee | 是 | Int | 100 | 订单总金额,单位为分,只能为整数,详见支付金额 |
退款金额 | refund_fee | 是 | Int | 100 | 退款总金额,订单总金额,单位为分,只能为整数,详见支付金额 |
退款资金来源 | refund_account | 否 | String(30) | REFUND_SOURCE_RECHARGE_FUNDS | 仅针对老资金流商户使用 REFUND_SOURCE_UNSETTLED_FUNDS---未结算资金退款(默认使用未结算资金退款) REFUND_SOURCE_RECHARGE_FUNDS---可用余额退款 |
实现代码:
/** * 申请退款 * * @param orderId 商户订单号 * @param refundId 商户退款单号 * @param totalFee 订单金额 * @param refundFee 退款金额 * @param refundAccount 退款资金来源(默认传 "REFUND_SOURCE_UNSETTLED_FUNDS") */ public Map<String, String> refund(Long orderId, String refundId, Long totalFee, Long refundFee, String refundAccount) { checkConfig(weixinProperties); SortedMap<String, Object> params = new TreeMap<>(); params.put("appid", "************"); params.put("mch_id", "************"); params.put("nonce_str", StringUtil.getRandomStringByLength(24)); //商户订单号和微信订单号二选一 params.put("out_trade_no", fictitiousOrderService.findFictitiousIdByOrder(orderId)); params.put("out_refund_no", refundId); params.put("total_fee", totalFee); params.put("refund_fee", refundFee); params.put("refund_account", refundAccount); //签名算法 String sign = getSign(params); params.put("sign", sign); try { String xml = XmlHelper.mapToXml(params); String xmlStr = doRefund("https://api.mch.weixin.qq.com/secapi/pay/refund", xml); //加入微信支付日志 payWechatLogService.insertPayWechatLog(Constants.PAY_REFUND_RESULT_LOG, xmlStr); Map map = XmlHelper.xmlToMap(xmlStr); if (map == null || !Constants.REQUEST_SUCCESS.equals(map.get("result_code"))) { //消息通知 mqSendService.sendRobotMsg( String.format("微信消息-传入参数[%s];微信输出[%s]", JSON.toJSONString(params), JSON.toJSONString(map)), "申请退款"); } //未结算金额不足 使用余额退款 if (map != null && Constants.REQUEST_FAIL.equals(map.get("result_code")) && Constants.REQUEST_SUCCESS.equals(map.get("return_code")) && Constants.REFUND_NOT_ENOUGH_MONEY.equals(map.get("err_code")) && Constants.REFUND_SOURCE_UNSETTLED_FUNDS.equals(refundAccount)) { refund(orderId, refundId, totalFee, refundFee, Constants.REFUND_SOURCE_RECHARGE_FUNDS); } return map; } catch (Exception e) { //微信退款接口异常 mqSendService.sendRobotMsg(String.format("微信消息-传入参数[%s];异常信息-[%s]", JSON.toJSONString(params), e.getMessage()), "申请退款"); throw serviceExceptionService.createServiceException(ExceptionConstants.PAY_REFUND_INTERFACE_RRROR); } }
方法注意点:
其中getSign(params)获取签名方法和上面支付签名方法一致可共用;out_trade_no填写微信支付时对应的虚拟支付单号;这里我根据refund_account退款资金来源作了一个逻辑处理,退款资金优先退商户未结算资金,如果未结算资金不足退商户余额的资金。
退款请求及证书的使用:
/** * 申请退款 */ public String doRefund(String url, String data) throws Exception { KeyStore keyStore = KeyStore.getInstance("PKCS12"); FileInputStream is = new FileInputStream(new File("****证书文件存放的路劲*******")); try { keyStore.load(is, "********证书密码*******".toCharArray()); } finally { is.close(); } // Trust own CA and all self-signed certs SSLContext sslcontext = SSLContexts.custom().loadKeyMaterial( keyStore, "********证书密码*******".toCharArray()) .build(); // Allow TLSv1 protocol only SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory( sslcontext, new String[]{"TLSv1"}, null, SSLConnectionSocketFactory.BROWSER_COMPATIBLE_HOSTNAME_VERIFIER ); CloseableHttpClient httpclient = HttpClients.custom().setSSLSocketFactory(sslsf).build(); try { HttpPost httpost = new HttpPost(url); // 设置响应头信息 httpost.addHeader("Connection", "keep-alive"); httpost.addHeader("Accept", "*/*"); httpost.addHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); httpost.addHeader("Host", "api.mch.weixin.qq.com"); httpost.addHeader("X-Requested-With", "XMLHttpRequest"); httpost.addHeader("Cache-Control", "max-age=0"); httpost.addHeader("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0) "); httpost.setEntity(new StringEntity(data, "UTF-8")); CloseableHttpResponse response = httpclient.execute(httpost); try { HttpEntity entity = response.getEntity(); String jsonStr = EntityUtils.toString(response.getEntity(), "UTF-8"); EntityUtils.consume(entity); return jsonStr; } finally { response.close(); } } finally { httpclient.close(); } }证书密码如果没设置默认为商户号mchid