小程序接入微信支付笔记

最近折腾了一下小程序接入微信支付,对接入的流程有个大概的了解,也踩了不少坑,为了避免以后重复踩坑,这里记录接入流程。

小程序完成支付需要进行以下几个流程

  1. 通过小程序的 login 接口获取「临时登陆凭证」。
  2. 使用「临时凭证」 jscode 获取 openid。
  3. 调用商户后台「预支付接口」,上传需要的参数(openid,商品描述,价格)。
  4. 商户后台通过微信的「统一下单接口」获取 prepay_id (预支付交易标示)。
  5. 对「统一下单接口」返回的参数进行二次签名,支付需要用到的参数返回给小程序端。
  6. 小程序获取到参数,通过 requestPayment 方法调起支付。
  7. 用户操作支付,微信通知后台完成支付,向微信通知获取通知成功。

以下分步骤对上述流程进行详细描述

在为小程序接入支付之前,我们需要以下几个材料:

  1. 小程序id AppId
  2. 小程序密钥 AppSecret
  3. 与小程序一对一绑定的商户号 MchId
  4. 商户号密钥 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,当然,一个支付账单还需要有其他的参数
为了完成下单还需要以下信息

  1. 当前交易描述 body
  2. 当前交易需要支付的金额 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 步

  1. 将需要发送的参数按照参数名的首字母 ASCII 升序排序,并以 key=value 的格式用 & 连接

首字母排序可以使用 TreeMap 的 compareTo 方法,具体实现代码

//按 key 的首字母的 ASCII 排序 静态变量 
//在完成 key=value 格式转换后一定要清空集合内的信息,否则会造成数据混乱
static Map<String, String> paramKV = new TreeMap<>(String::compareTo);
  1. 将 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();
}
  1. 获取到发送参数的 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[]]> 标示一定要加,否则微信服务器不能识别接收确认的结果。
以上便完成了小程序微信支付的全部流程

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容

  • 引言 相信大家都会对微信支付非常熟悉,什么样的开发场景能少得了支付的环节呢? Let's do it~ 开发接入官...
    OzanShareing阅读 2,804评论 0 3
  • 关于微信支付 生活中的微信支付 目前我们日常生活中接触得比较多的线上电子支付方式主要有两种,一种是支付宝,另一种就...
    积_渐阅读 3,888评论 3 26
  • 你可以这样,可以那样,可以随便,但是你不能让别人知道,不能广而告之,不能让别人知道,我难受的不是你对我如何了,而是...
    兮兮AX阅读 155评论 0 0
  • 我是中科院的一名材料工程硕士生,虽然本专业是材料工程,但是这一年却做了很多跟本专业八竿子打不着的事情。我学习数据分...
    九日照林阅读 1,740评论 0 5
  • 清明时节雨纷绯, 路上行人欲断魂。 鞭炮声声冲九霄, 泪眼婆娑慰英灵!
    滠水情深阅读 362评论 0 5