最近折腾了一下小程序接入微信支付,对接入的流程有个大概的了解,也踩了不少坑,为了避免以后重复踩坑,这里记录接入流程。
小程序完成支付需要进行以下几个流程
- 通过小程序的 login 接口获取「临时登陆凭证」。
- 使用「临时凭证」 jscode 获取 openid。
- 调用商户后台「预支付接口」,上传需要的参数(openid,商品描述,价格)。
- 商户后台通过微信的「统一下单接口」获取 prepay_id (预支付交易标示)。
- 对「统一下单接口」返回的参数进行二次签名,支付需要用到的参数返回给小程序端。
- 小程序获取到参数,通过 requestPayment 方法调起支付。
- 用户操作支付,微信通知后台完成支付,向微信通知获取通知成功。
以下分步骤对上述流程进行详细描述
在为小程序接入支付之前,我们需要以下几个材料:
- 小程序id AppId
- 小程序密钥 AppSecret
- 与小程序一对一绑定的商户号 MchId
- 商户号密钥 key
使用的商户号必须跟已经通过微信支付的小程序一一对应(一个小程序对应一个商户号)
小程序 AppId 与密钥 AppSecret 在小程序管理后台的「设置」>「开发设置」>「开发者ID」中即可获取
商户号可以在微信商户平台的「账户中心」>「账户设置」>「商户信息」>「账户信息」中获取
商户号密钥需要在「API安全」>「API密钥中」进行设置,设置 API密钥 需要由商户平台账户的超级管理员来操作,密钥要求为长度为32位的字符串(值允许包含数字,大小写字母),设置前一定要保存好自己设置的 API密钥 设置成功之后,商户平台不提供密钥查询,只能够修改密钥
-
通过小程序的 login 接口获取「临时登陆凭证」,openid
openid 在统一下单接口中需要使用
在小程序中通过 wx.login() 接口获取 「临时登陆凭证」jscode
凭证只可以使用一次,因此每次请求下单都需要重新申请调用,然后调用 openid 请求api 请求所需的 openid
请求地址为
https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
请求openid的小程序代码如下:
//使用 login 接口获取 临时登陆凭证
wx.login({
success: function(res) {
console.log(res.code)
// 通过 jscode 获取 openid
wx.request({
url: 'https://api.weixin.qq.com/sns/jscode2session?' +
'appid=「小程序 AppId」'+
'&secret=「小程序密钥 AppSecret」'+
'&js_code=' + res.code +
'&grant_type=authorization_code',
success: function(res){
that.setData({
openId : res.data.openid
})
}
})
}
})
通过上述例子即可拿到 统一下单 接口所需的 openid,当然,一个支付账单还需要有其他的参数
为了完成下单还需要以下信息
- 当前交易描述 body
- 当前交易需要支付的金额 total_fee (无符号整型,单位「分」)
-
调用商户后台「预支付接口」。
到这一步,我们应该有了以下一个参数 「openid」「body」「total_fee」,将以上信息上传给商户后台,接下来就是商户服务器的工作了
微信为小程序准备了统一下单接口(接口地址:https://api.mch.weixin.qq.com/pay/unifiedorder)
在向微信服务器接口下单之前 需要先准备订单的一些信息
参考微信支付开发文档的统一下单接口API文档(https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=9_1)
我们需要向统一下单接口提供以下必要信息
变量名 | 字段名 | 描述 |
---|---|---|
appid | 小程序ID | 微信分配的小程序ID |
mch_id | 商户号 | 小程序一对一绑定的微信商户号 |
nonce_str | 随机字符串 | 随机生成的字符串 长度在32位之内 用于保证签名结果不可预测 |
sign | 签名 | 生成方法详见「签名」 |
body | 商品描述 | 商品或交易信息的详细描述 |
out_trade_no | 商户订单号 | 商户自定义的订单号 商户服务器自己管理 需要避免重复 |
total_fee | 标价金额 | 发起交易的价格 |
spbill_create_ip | 终端ip | 发起支付申请的客户终端ip地址 |
notify_url | 通知地址 | 当用户完成支付时需要通知的地址 |
trade_type | 交易类型 | 小程序取值 JSAPI |
openid | 用户标示 | 使用「临时登陆凭证」 通过 openid 接口 获取openid |
接口文档中提及但是不是必须填写的字段名可以根据具体业务逻辑选择是否添加
变量中的「openid」,「body」,「total_fee」就是刚刚小程序上传的参数,其他参数在后台生成赋值。
需要后台生成的参数有「随机字符串」 「商户订单号」以及「签名」,下面是相关参考生成算法(这里只提供最简单的示例,具体可以根据业务需求设计算法)
-
随机字符串 nonce_str
/**
* 获取32位内的随机字符串 nonce_str
*/
private final static int RANDOM_LENGTH = 32;
public static String getRandomString() {
String seed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456";
StringBuilder sb = new StringBuilder();
for (int count = 0; count < RANDOM_LENGTH; count++) {
int randomIndex = (int) (Math.random() * seed.length());
sb.append(seed.charAt(randomIndex));
}
Log.info(TAG, "随机字符串 --> " + sb.toString());
return sb.toString();
}
-
商户号 out_trade_no
/**
* 获取订单号 根据当前时间戳生成 避免重复 out_trade_no
* 订单号由 当前年月日时分秒 「 20180809120521 」+ 随机数 (0 - 100000)
*/
public static String getOrderNum(){
String randomNum = String.format("%05d", (int) (Math.random() * 10000));
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss");
String orderNum = String.format("%s%s",
dateFormat.format(new Date(System.currentTimeMillis())),
randomNum);
Log.info(TAG, "订单号 --> " + orderNum);
return orderNum;
}
-
签名 sign
签名生成算法总共分为 3 步
- 将需要发送的参数按照参数名的首字母 ASCII 升序排序,并以 key=value 的格式用 & 连接
首字母排序可以使用 TreeMap 的 compareTo 方法,具体实现代码
//按 key 的首字母的 ASCII 排序 静态变量
//在完成 key=value 格式转换后一定要清空集合内的信息,否则会造成数据混乱
static Map<String, String> paramKV = new TreeMap<>(String::compareTo);
- 将 map 集合转换成 key=value 的格式,并以 & 连接
/**
* 将map转换成 key=value 格式 并以 & 连接
*/
private static String mapToKeyValue() {
StringBuilder sb = new StringBuilder();
for (String key : paramKV.keySet()) {
System.out.println(key + ":" + paramKV.get(key));
sb.append(key).append("=").append(paramKV.get(key));
//添加连接符
sb.append("&");
}
//去除末尾多余的连接符
sb.deleteCharAt(sb.length() - 1);
Log.info(TAG, "kv字符串 --> " + sb.toString());
return sb.toString();
}
- 获取到发送参数的 key value 字符串之后,需要将商户号API密钥拼接到 kv 字符串后面
/**
* 获取签名
*/
//商户API密钥
private final static String SECRET_KEY = "商户号API密钥";
public static String getMD5Sign() {
//对参数按照 key=value 的格式,并按 key 的 ASCII 字典排序组合成字符串
String kvString = mapToKeyValue();
//拼接API密钥
String signTemp = kvString + "&key=" + SECRET_KEY;
String sign = MD5(signTemp);
Log.info(TAG, "MD5签名 --> " + sign);
return sign;
}
MD5加密方法
private static String MD5(String s) {
char hexDigits[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
try {
byte[] btInput = s.getBytes();
// 获得MD5摘要算法的 MessageDigest 对象
MessageDigest mdInst = MessageDigest.getInstance("MD5");
// 使用指定的字节更新摘要
mdInst.update(btInput);
// 获得密文
byte[] md = mdInst.digest();
// 把密文转换成十六进制的字符串形式
int j = md.length;
char str[] = new char[j * 2];
int k = 0;
for (byte byte0 : md) {
str[k++] = hexDigits[byte0 >>> 4 & 0xf];
str[k++] = hexDigits[byte0 & 0xf];
}
return new String(str);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
同时我们还需要获取到客户换的IP地址「终端ip」
//获取客户端IP
String ipAddresses = req.getHeader("X-Real-IP");
接下来,我们就可以开始向微信统一下单服务进行下单请求
-
通过微信的「统一下单接口」获取 prepay_id
准备好以上数据之后,将需要发送的参数封装成 xml 格式数据,通过 「统一下单接口」提交给微信服务器
xml 数据格式化代码
/**
* 将 map 转换成 xml 数据
*/
private static String mapToXml(Map<String, String> map) {
StringBuilder sb = new StringBuilder();
sb.append("<xml>");
for (String key : map.keySet()) {
sb.append("<").append(key).append(">")
.append(map.get(key))
.append("</").append(key).append(">");
}
sb.append("</xml>");
Log.info(TAG, "生成的xml数据 --> " + sb.toString());
return sb.toString();
}
格式化完成之后就可以开始向接口请求下单,接口地址:https://api.mch.weixin.qq.com/pay/unifiedorder
下面提供使用 HttpURLConnection 向接口请求下单的示例
/**
* @param urls 接口地址
* @param param 接口参数
* @return 接口返回数据
*/
public static String getRemotePortData(String urls, String param) {
try {
URL url = new URL(urls);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// 设置连接超时时间
conn.setConnectTimeout(30000);
// 设置读取超时时间
conn.setReadTimeout(30000);
conn.setRequestMethod("POST");
if (!param.isEmpty()) {
conn.setRequestProperty("Origin", "https://sirius.searates.com");// 主要参数
conn.setRequestProperty("Referer","https://sirius.searates.com/cn/port?A=ChIJP1j2OhRahjURNsllbOuKc3Y&D=567&G=16959&shipment=1&container=20st&weight=1&product=0&equest=&weightcargo=1&");
conn.setRequestProperty("X-Requested-With", "XMLHttpRequest");// 主要参数
}
// 需要输出
conn.setDoInput(true);
// 需要输入
conn.setDoOutput(true);
// 设置是否使用缓存
conn.setUseCaches(false);
// 设置请求属性
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.setRequestProperty("Connection", "Keep-Alive");// 维持长连接
conn.setRequestProperty("Charset", "UTF-8");
if (!param.isEmpty()) {
// 建立输入流,向指向的URL传入参数
DataOutputStream dos = new DataOutputStream(conn.getOutputStream());
dos.writeBytes(param);
dos.flush();
dos.close();
}
// 输出返回结果
InputStream input = conn.getInputStream();
int resLen;
byte[] res = new byte[1024];
StringBuilder sb = new StringBuilder();
while ((resLen = input.read(res)) != -1) {
sb.append(new String(res, 0, resLen));
}
return sb.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
接口返回得同样是 xml 格式的数据,首先对数据进行解析
private Element doXMLParse(String xml) {
Element root = null;
try {
Document myDoc = DocumentHelper.parseText(xml);
root = myDoc.getRootElement();
} catch (DocumentException e) {
e.printStackTrace();
}
return root;
}
/**
* 获取节点数据
*/
public String getElementText(String xml, String keyName){
return doXmlParse(xml).element(keyName).getText();
}
「统一下单接口」返回的参数有
变量名 | 字段名 | 描述 |
---|---|---|
return_code | 返回状态码 | 通信标示,标示是否回调成功 |
result_code | 业务结果 | 代表交易是否成功 |
return_msg | 返回信息 | 代表通信出错的时候的错误原因 |
appid | 小程序ID | 调用接口提交的小程序ID |
mch_id | 商户号 | 调用接口提交的商户号 |
nonce_str | 随机字符串 | 微信返回的随机字符串 |
sign | 签名 | 微信返回的签名值(这个签名在二次签名中不做使用) |
prepay_id | 预支付交易会话标示 | 用于小程序发起支付申请,有效期为 2 小时 |
trade_type | 交易类型 | 小程序的交易类型定为 JSAPI |
解析上述参数,先拿到 return_code 的值,判断通信是否成功,如果值的字段等于 「SUCCESS」,则获取 result_code 携带的值,如果同样为 「SUCCESS」则代表交易成功,可以进行下一步操作
-
对「统一下单接口」返回的参数进行二次签名
拿到「统一下单接口」返回的数据之后,需要对小程序接口上传的参数进行「二次签名」,需要进行二次签名的参数变量有以下这些(注意变量名区分大小写)
变量名 | 字段名 | 描述 |
---|---|---|
appId | 小程序ID | 当前小程序客户端的小程序ID |
tiemeStamp | 当前时间戳 | 当前已秒为单位的时间戳字符串 |
package | 预支付id字符 | key=value格式的数据字符,例:「prepay_id:预支付会话标示字符串」 |
signType | 签名加密类型 | 当前签名的加密方式,例:MD5 |
nonceStr | 随机字符串 | 随机生成的字符串,长度在32位之内,用于使生成的 MD5 结果不可预测 |
将上述参数按照之前的方法转化成 key=value 的格式字符串,并且在生成字符串末尾拼接 「商户号密钥」,使用MD5 加密算法加密获取「签名」paySign。
小程序通过 requestPayment 方法调起支付
完成二次签名之后,就可以把小程序 requestPayment 接口所需的几个参数变量发回小程序端
通过查看小程序API 文档,我们可以看到 wx.requestPayment 方法需要以下几个参数变量
变量名 | 字段名 | 描述 |
---|---|---|
tiemeStamp | 当前时间戳 | 当前已秒为单位的时间戳字符串 |
paySign | 支付签名 | 二次签名获得的签名字符串 |
nonceStr | 随机字符串 | 随机生成的字符串,长度在32位之内,用于使生成的 MD5 结果不可预测 |
package | 预支付id字符 | key=value格式的数据字符,例:「prepay_id:预支付会话标示字符串」 |
上述4个参数变量,我们都在后台生成封装发送给小程序,小程序接收参数,并调用对应接口即可
小程序发起支付请求
wx.requestPayment({
timeStamp: timeStamp,
nonceStr: nonceStr,
package: 'prepay_id=' + prepayId,
signType: 'MD5',
paySign: sign,
})
-
用户操作支付,通知后台完成支付
这时候在小程序端会弹窗提示支付信息,完成支付后,微信服务器就会通知之前在「统一下单接口」配置的 「notify_url」地址,返回交易情况,后台可根据返回的交易情况进行后台逻辑操作,返回具体参数详见
「支付结果通知」 https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=9_7
在获取到 result_code 等于 「SUCCESS」 时,标示当前交易成功,此时需要告知微信服务器接收交易结果成功,否则微信后台会不断得发送交易结果通知
需要返回的结果:
"<xml>" +
"<return_code><![CDATA[SUCCESS]]></return_code>" +
"<return_msg><![CDATA[OK]]></return_msg>" +
"</xml>"
注意子节点的 <![CDATA[]]> 标示一定要加,否则微信服务器不能识别接收确认的结果。
以上便完成了小程序微信支付的全部流程