一文搞懂「微信支付 Api-v3」接口规则所有知识点

文章目录

    • 简介
    • v2 与 v3 的区别
    • API 密钥设置
    • 获取 API 证书
    • 请求签名
      • 示例代码
        • 构造签名串
        • 构造 HTTP 头中的 Authorization
    • 获取证书序列号
      • 通过工具获取
      • 通过代码获取
    • SHA256 with RSA 签名
      • 获取商户私钥
      • 私钥签名
    • 执行请求
      • GET 请求
      • POST 请求
      • DELETE 请求
      • 上传文件
      • 构建 Http 请求头
      • 构建 Http 请求返回值
    • 验证签名
      • 构建签名参数
    • 证书和回调报文解密
      • 验证签名

简介

为了在保证支付安全的前提下,带给商户简单、一致且易用的开发体验,我们推出了全新的微信支付API v3。

其实还要一个主要因素是「为了符合监管的要求」。

主要是为了符合监管的要求,保证更高的安全级别。《中华人民共和国电子签名法》、《金融电子认证规范》及《非银行支付机构网络支付业务管理办法》中规定 “电子签名需要第三方认证的,由依法设立的电子认证服务提供者提供认证服务。”,所以需使用第三方 CA 来确保数字证书的唯一性、完整性及交易的不可抵赖性。

支付宝支付也是如此,从之前的「普通公钥方式」新增了 「公钥证书方式」。今天的主角是微信支付 Api v3 这里就不展开讲支付宝支付了。

微信支付 Api v3 接口规则 官方文档

v2 与 v3 的区别

V3 规则差异 V2
JSON 参数格式 XML
POST、GET 或 DELETE 提交方式 POST
AES-256-GCM加密 回调加密 无需加密
RSA 加密 敏感加密 无需加密
UTF-8 编码方式 UTF-8
非对称密钥SHA256-RSA 签名方式 MD5 或 HMAC-SHA256

微信支付 Api-v2 版本详细介绍请参数之前博客 微信支付,你想知道的一切都在这里

干货多,屁话少,下面直接进入主题,读完全文你将 Get 到以下知识点

  • 如何获取证书序列号
  • 非对称密钥 SHA256-RSA 加密与验证签名
  • AES-256-GCM 如何解密

API 密钥设置

请登录商户平台进入【账户中心】->【账户设置】->【API安全】->【APIv3密钥】中设置 API 密钥。

具体操作步骤请参见:什么是APIv3密钥?如何设置?

获取 API 证书

请登录商户平台进入【账户中心】->【账户设置】->【API安全】根据提示指引下载证书。

具体操作步骤请参见:什么是API证书?如何获取API证书?

按照以上步骤操作后你将获取如下内容:

  • apiKey API 密钥
  • apiKey3 APIv3 密钥
  • mchId 商户号
  • apiclient_key.pem X.509 标准证书的密钥
  • apiclient_cert.p12 X.509 标准的证书+密钥
  • apiclient_cert.pem X.509 标准的证书

请求签名

如何生成签名参数?官方文档 描述得非常清楚这里就不啰嗦了。

示例代码

构造签名串

    /**
     * 构造签名串
     *
     * @param method    {@link RequestMethod} GET,POST,PUT等
     * @param url       请求接口 /v3/certificates
     * @param timestamp 获取发起请求时的系统当前时间戳
     * @param nonceStr  随机字符串
     * @param body      请求报文主体
     * @return 待签名字符串
     */
    public static String buildSignMessage(RequestMethod method, String url, long timestamp, String nonceStr, String body) {
        return new StringBuilder()
                .append(method.toString())
                .append("\n")
                .append(url)
                .append("\n")
                .append(timestamp)
                .append("\n")
                .append(nonceStr)
                .append("\n")
                .append(body)
                .append("\n")
                .toString();
    }

构造 HTTP 头中的 Authorization

/**
 * 构建 v3 接口所需的 Authorization
 *
 * @param method    {@link RequestMethod} 请求方法
 * @param urlSuffix 可通过 WxApiType 来获取,URL挂载参数需要自行拼接
 * @param mchId     商户Id
 * @param serialNo  商户 API 证书序列号
 * @param keyPath   key.pem 证书路径
 * @param body      接口请求参数
 * @param nonceStr  随机字符库
 * @param timestamp 时间戳
 * @param authType  认证类型
 * @return {@link String} 返回 v3 所需的 Authorization
 * @throws Exception 异常信息
 */
public static String buildAuthorization(RequestMethod method, String urlSuffix, String mchId,
                                        String serialNo, String keyPath, String body, String nonceStr,
                                        long timestamp, String authType) throws Exception {
    // 构建签名参数
    String buildSignMessage = PayKit.buildSignMessage(method, urlSuffix, timestamp, nonceStr, body);
    // 获取商户私钥
    String key = PayKit.getPrivateKey(keyPath);
    // 生成签名
    String signature = RsaKit.encryptByPrivateKey(buildSignMessage, key);
    // 根据平台规则生成请求头 authorization
    return PayKit.getAuthorization(mchId, serialNo, nonceStr, String.valueOf(timestamp), signature, authType);
}


/**
 * 获取授权认证信息
 *
 * @param mchId     商户号
 * @param serialNo  商户API证书序列号
 * @param nonceStr  请求随机串
 * @param timestamp 时间戳
 * @param signature 签名值
 * @param authType  认证类型,目前为WECHATPAY2-SHA256-RSA2048
 * @return 请求头 Authorization
 */
public static String getAuthorization(String mchId, String serialNo, String nonceStr, String timestamp, String signature, String authType) {
    Map<String, String> params = new HashMap<>(5);
    params.put("mchid", mchId);
    params.put("serial_no", serialNo);
    params.put("nonce_str", nonceStr);
    params.put("timestamp", timestamp);
    params.put("signature", signature);
    return authType.concat(" ").concat(createLinkString(params, ",", false, true));
}

拼接参数

    public static String createLinkString(Map<String, String> params, String connStr, boolean encode, boolean quotes) {
        List<String> keys = new ArrayList<String>(params.keySet());
        Collections.sort(keys);
        StringBuilder content = new StringBuilder();
        for (int i = 0; i < keys.size(); i++) {
            String key = keys.get(i);
            String value = params.get(key);
            // 拼接时,不包括最后一个&字符
            if (i == keys.size() - 1) {
                if (quotes) {
                    content.append(key).append("=").append('"').append(encode ? urlEncode(value) : value).append('"');
                } else {
                    content.append(key).append("=").append(encode ? urlEncode(value) : value);
                }
            } else {
                if (quotes) {
                    content.append(key).append("=").append('"').append(encode ? urlEncode(value) : value).append('"').append(connStr);
                } else {
                    content.append(key).append("=").append(encode ? urlEncode(value) : value).append(connStr);
                }
            }
        }
        return content.toString();
    }

从上面示例来看我们还差两个参数

  • serial_no 证书序列号
  • signature 使用商户私钥对待签名串进行 SHA256 with RSA 签名

如何获取呢?不要着急,容我喝杯 「89年的咖啡」提提神。

获取证书序列号

通过工具获取

  • openssl x509 -in apiclient_cert.pem -noout -serial
  • 使用证书解析工具 https://myssl.com/cert_decode.html

通过代码获取

// 获取证书序列号
X509Certificate certificate = PayKit.getCertificate(FileUtil.getInputStream("apiclient_cert.pem 证书路径"));

System.out.println("输出证书信息:\n" + certificate.toString());
System.out.println("证书序列号:" + certificate.getSerialNumber().toString(16));
System.out.println("版本号:" + certificate.getVersion());
System.out.println("签发者:" + certificate.getIssuerDN());
System.out.println("有效起始日期:" + certificate.getNotBefore());
System.out.println("有效终止日期:" + certificate.getNotAfter());
System.out.println("主体名:" + certificate.getSubjectDN());
System.out.println("签名算法:" + certificate.getSigAlgName());
System.out.println("签名:" + certificate.getSignature().toString());


/**
 * 获取证书
 *
 * @param inputStream 证书文件
 * @return {@link X509Certificate} 获取证书
 */
public static X509Certificate getCertificate(InputStream inputStream) {
    try {
        CertificateFactory cf = CertificateFactory.getInstance("X509");
        X509Certificate cert = (X509Certificate) cf.generateCertificate(inputStream);
        cert.checkValidity();
        return cert;
    } catch (CertificateExpiredException e) {
        throw new RuntimeException("证书已过期", e);
    } catch (CertificateNotYetValidException e) {
        throw new RuntimeException("证书尚未生效", e);
    } catch (CertificateException e) {
        throw new RuntimeException("无效的证书", e);
    }
}

SHA256 with RSA 签名

获取商户私钥

 /**
  * 获取商户私钥
  *
  * @param keyPath 商户私钥证书路径
  * @return 商户私钥
  * @throws Exception 解析 key 异常
  */
 public static String getPrivateKey(String keyPath) throws Exception {
     String originalKey = FileUtil.readUtf8String(keyPath);
     String privateKey = originalKey
             .replace("-----BEGIN PRIVATE KEY-----", "")
             .replace("-----END PRIVATE KEY-----", "")
             .replaceAll("\\s+", "");
     return RsaKit.getPrivateKeyStr(RsaKit.loadPrivateKey(privateKey));
 }


public static String getPrivateKeyStr(PrivateKey privateKey) {
    return Base64.encode(privateKey.getEncoded());
}

 
 /**
  * 从字符串中加载私钥
  *
  * @param privateKeyStr 私钥
  * @return {@link PrivateKey}
  * @throws Exception 异常信息
  */
 public static PrivateKey loadPrivateKey(String privateKeyStr) throws Exception {
     try {
         byte[] buffer = Base64.decode(privateKeyStr);
         PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(buffer);
         KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
         return keyFactory.generatePrivate(keySpec);
     } catch (NoSuchAlgorithmException e) {
         throw new Exception("无此算法");
     } catch (InvalidKeySpecException e) {
         throw new Exception("私钥非法");
     } catch (NullPointerException e) {
         throw new Exception("私钥数据为空");
     }
 }

私钥签名

/**
  * 私钥签名
  *
  * @param data       需要加密的数据
  * @param privateKey 私钥
  * @return 加密后的数据
  * @throws Exception 异常信息
  */
 public static String encryptByPrivateKey(String data, String privateKey) throws Exception {
     PKCS8EncodedKeySpec priPkcs8 = new PKCS8EncodedKeySpec(Base64.decode(privateKey));
     KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
     PrivateKey priKey = keyFactory.generatePrivate(priPkcs8);
     java.security.Signature signature = java.security.Signature.getInstance("SHA256WithRSA");

     signature.initSign(priKey);
     signature.update(data.getBytes(StandardCharsets.UTF_8));
     byte[] signed = signature.sign();
     return StrUtil.str(Base64.encode(signed));
 }

至此微信支付 Api-v3 接口请求参数已封装完成。

执行请求

/**
 * V3 接口统一执行入口
 *
 * @param method    {@link RequestMethod} 请求方法
 * @param urlPrefix 可通过 {@link WxDomain}来获取
 * @param urlSuffix 可通过 {@link WxApiType} 来获取,URL挂载参数需要自行拼接
 * @param mchId     商户Id
 * @param serialNo  商户 API 证书序列号
 * @param keyPath   apiclient_key.pem 证书路径
 * @param body      接口请求参数
 * @param nonceStr  随机字符库
 * @param timestamp 时间戳
 * @param authType  认证类型
 * @param file      文件
 * @return {@link String} 请求返回的结果
 * @throws Exception 接口执行异常
 */
public static Map<String, Object> v3Execution(RequestMethod method, String urlPrefix, String urlSuffix,
                                              String mchId, String serialNo, String keyPath, String body,
                                              String nonceStr, long timestamp, String authType,
                                              File file) throws Exception {
    // 构建 Authorization
    String authorization = WxPayKit.buildAuthorization(method, urlSuffix, mchId, serialNo,
            keyPath, body, nonceStr, timestamp, authType);

    if (method == RequestMethod.GET) {
        return doGet(urlPrefix.concat(urlSuffix), authorization, serialNo, null);
    } else if (method == RequestMethod.POST) {
        return doPost(urlPrefix.concat(urlSuffix), authorization, serialNo, body);
    } else if (method == RequestMethod.DELETE) {
        return doDelete(urlPrefix.concat(urlSuffix), authorization, serialNo, body);
    } else if (method == RequestMethod.UPLOAD) {
        return doUpload(urlPrefix.concat(urlSuffix), authorization, serialNo, body, file);
    }
    return null;
}

网络请求库默认是使用的 Hutool 封装的一套 Java 工具集合来实现

GET 请求

/**
 * @param url           请求url
 * @param authorization 授权信息
 * @param serialNumber  公钥证书序列号
 * @param jsonData      请求参数
 * @return {@link HttpResponse} 请求返回的结果
 */
private HttpResponse doGet(String url, String authorization, String serialNumber, String jsonData) {
    return HttpRequest.post(url)
            .addHeaders(getHeaders(authorization, serialNumber))
            .body(jsonData)
            .execute();
}

POST 请求

 /**
  * @param url           请求url
  * @param authorization 授权信息
  * @param serialNumber  公钥证书序列号
  * @param jsonData      请求参数
  * @return {@link HttpResponse} 请求返回的结果
  */
 private HttpResponse doPost(String url, String authorization, String serialNumber, String jsonData) {
     return HttpRequest.post(url)
             .addHeaders(getHeaders(authorization, serialNumber))
             .body(jsonData)
             .execute();
 }

DELETE 请求

/**
 * delete 请求
 *
 * @param url           请求url
 * @param authorization 授权信息
 * @param serialNumber  公钥证书序列号
 * @param jsonData      请求参数
 * @return {@link HttpResponse} 请求返回的结果
 */
private HttpResponse doDelete(String url, String authorization, String serialNumber, String jsonData) {
    return HttpRequest.delete(url)
            .addHeaders(getHeaders(authorization, serialNumber))
            .body(jsonData)
            .execute();
}

上传文件

 /**
   * @param url           请求url
   * @param authorization 授权信息
   * @param serialNumber  公钥证书序列号
   * @param jsonData      请求参数
   * @param file          上传的文件
   * @return {@link HttpResponse} 请求返回的结果
   */
  private HttpResponse doUpload(String url, String authorization, String serialNumber, String jsonData, File file) {
      return HttpRequest.post(url)
              .addHeaders(getUploadHeaders(authorization, serialNumber))
              .form("file", file)
              .form("meta", jsonData)
              .execute();
  }

构建 Http 请求头

private Map<String, String> getBaseHeaders(String authorization) {
    String userAgent = String.format(
            "WeChatPay-IJPay-HttpClient/%s (%s) Java/%s",
            getClass().getPackage().getImplementationVersion(),
            OS,
            VERSION == null ? "Unknown" : VERSION);

    Map<String, String> headers = new HashMap<>(3);
    headers.put("Accept", ContentType.JSON.toString());
    headers.put("Authorization", authorization);
    headers.put("User-Agent", userAgent);
    return headers;
}

private Map<String, String> getHeaders(String authorization, String serialNumber) {
    Map<String, String> headers = getBaseHeaders(authorization);
    headers.put("Content-Type", ContentType.JSON.toString());
    if (StrUtil.isNotEmpty(serialNumber)) {
        headers.put("Wechatpay-Serial", serialNumber);
    }
    return headers;
}

private Map<String, String> getUploadHeaders(String authorization, String serialNumber) {
    Map<String, String> headers = getBaseHeaders(authorization);
    headers.put("Content-Type", "multipart/form-data;boundary=\"boundary\"");
    if (StrUtil.isNotEmpty(serialNumber)) {
        headers.put("Wechatpay-Serial", serialNumber);
    }
    return headers;
}

构建 Http 请求返回值

从响应的 HttpResponse 中获取微信响应头信息、状态码以及 body

/**
 * 构建返回参数
 *
 * @param httpResponse {@link HttpResponse}
 * @return {@link Map}
 */
private Map<String, Object> buildResMap(HttpResponse httpResponse) {
    Map<String, Object> map = new HashMap<>();
    String timestamp = httpResponse.header("Wechatpay-Timestamp");
    String nonceStr = httpResponse.header("Wechatpay-Nonce");
    String serialNo = httpResponse.header("Wechatpay-Serial");
    String signature = httpResponse.header("Wechatpay-Signature");
    String body = httpResponse.body();
    int status = httpResponse.getStatus();

    map.put("timestamp", timestamp);
    map.put("nonceStr", nonceStr);
    map.put("serialNumber", serialNo);
    map.put("signature", signature);
    map.put("body", body);
    map.put("status", status);

    return map;
}

至此已完成构建请求参数,执行请求。接下来我们就要实现响应数据的解密以及响应结果的验证签名

对应的官方文档

  • 证书和回调报文解密
  • 签名验证

验证签名

构建签名参数

/**
 * 构造签名串
 *
 * @param timestamp 应答时间戳
 * @param nonceStr  应答随机串
 * @param body      应答报文主体
 * @return 应答待签名字符串
 */
public static String buildSignMessage(String timestamp, String nonceStr, String body) {
    return new StringBuilder()
            .append(timestamp)
            .append("\n")
            .append(nonceStr)
            .append("\n")
            .append(body)
            .append("\n")
            .toString();
}

证书和回调报文解密

官方文档文末有完整的源码这里就不贴了。贴一个示例大家参数一下

try {
      String associatedData = "certificate";
      String nonce = "80d28946a64a";
      String cipherText = "DwAqW4+4TeUaOEylfKEXhw+XqGh/YTRhUmLw/tBfQ5nM9DZ9d+9aGEghycwV1jwo52vXb/t6ueBvBRHRIW5JgDRcXmTHw9IMTrIK6HxTt2qiaGTWJU9whsF+GGeQdA7gBCHZm3AJUwrzerAGW1mclXBTvXqaCl6haE7AOHJ2g4RtQThi3nxOI63/yc3WaiAlSR22GuCpy6wJBfljBq5Bx2xXDZXlF2TNbDIeodiEnJEG2m9eBWKuvKPyUPyClRXG1fdOkKnCZZ6u+ipb4IJx28n3MmhEtuc2heqqlFUbeONaRpXv6KOZmH/IdEL6nqNDP2D7cXutNVCi0TtSfC7ojnO/+PKRu3MGO2Z9q3zyZXmkWHCSms/C3ACatPUKHIK+92MxjSQDc1E/8faghTc9bDgn8cqWpVKcL3GHK+RfuYKiMcdSkUDJyMJOwEXMYNUdseQMJ3gL4pfxuQu6QrVvJ17q3ZjzkexkPNU4PNSlIBJg+KX61cyBTBumaHy/EbHiP9V2GeM729a0h5UYYJVedSo1guIGjMZ4tA3WgwQrlpp3VAMKEBLRJMcnHd4pH5YQ/4hiUlHGEHttWtnxKFwnJ6jHr3OmFLV1FiUUOZEDAqR0U1KhtGjOffnmB9tymWF8FwRNiH2Tee/cCDBaHhNtfPI5129SrlSR7bZc+h7uzz9z+1OOkNrWHzAoWEe3XVGKAywpn5HGbcL+9nsEVZRJLvV7aOxAZBkxhg8H5Fjt1ioTJL+qXgRzse1BX1iiwfCR0fzEWT9ldDTDW0Y1b3tb419MhdmTQB5FsMXYOzqp5h+Tz1FwEGsa6TJsmdjJQSNz+7qPSg5D6C2gc9/6PkysSu/6XfsWXD7cQkuZ+TJ/Xb6Q1Uu7ZB90SauA8uPQUIchW5zQ6UfK5dwMkOuEcE/141/Aw2rlDqjtsE17u1dQ6TCax/ZQTDQ2MDUaBPEaDIMPcgL7fCeijoRgovkBY92m86leZvQ+HVbxlFx5CoPhz4a81kt9XJuEYOztSIKlm7QNfW0BvSUhLmxDNCjcxqwyydtKbLzA+EBb2gG4ORiH8IOTbV0+G4S6BqetU7RrO+/nKt21nXVqXUmdkhkBakLN8FUcHygyWnVxbA7OI2RGnJJUnxqHd3kTbzD5Wxco4JIQsTOV6KtO5c960oVYUARZIP1SdQhqwELm27AktEN7kzg/ew/blnTys/eauGyw78XCROb9F1wbZBToUZ7L+8/m/2tyyyqNid+sC9fYqJoIOGfFOe6COWzTI/XPytCHwgHeUxmgk7NYfU0ukR223RPUOym6kLzSMMBKCivnNg68tbLRJHEOpQTXFBaFFHt2qpceJpJgw5sKFqx3eQnIFuyvA1i8s2zKLhULZio9hpsDJQREOcNeHVjEZazdCGnbe3Vjg7uqOoVHdE/YbNzJNQEsB3/erYJB+eGzyFwFmdAHenG5RE6FhCutjszwRiSvW9F7wvRK36gm7NnVJZkvlbGwh0UHr0pbcrOmxT81xtNSvMzT0VZNLTUX2ur3AGLwi2ej8BIC0H41nw4ToxTnwtFR1Xy55+pUiwpB7JzraA08dCXdFdtZ72Tw/dNBy5h1P7EtQYiKzXp6rndfOEWgNOsan7e1XRpCnX7xoAkdPvy40OuQ5gNbDKry5gVDEZhmEk/WRuGGaX06CG9m7NfErUsnQYrDJVjXWKYuARd9R7W0aa5nUXqz/Pjul/LAatJgWhZgFBGXhNr9iAoade/0FPpBj0QWa8SWqKYKiOqXqhfhppUq35FIa0a1Vvxcn3E38XYpVZVTDEXcEcD0RLCu/ezdOa6vRcB7hjgXFIRZQAka0aXnQxwOZwE2Rt3yWXqc+Q1ah2oOrg8Lg3ETc644X9QP4FxOtDwz/A==";

      AesUtil aesUtil = new AesUtil(wxPayV3Bean.getApiKey3().getBytes(StandardCharsets.UTF_8));
      // 平台证书密文解密
      // encrypt_certificate 中的  associated_data nonce  ciphertext
      String publicKey = aesUtil.decryptToString(
              associatedData.getBytes(StandardCharsets.UTF_8),
              nonce.getBytes(StandardCharsets.UTF_8),
              cipherText
      );
      // 保存证书
      FileWriter writer = new FileWriter(wxPayV3Bean.getPlatformCertPath());
      writer.write(publicKey);
      // 获取平台证书序列号
      X509Certificate certificate = PayKit.getCertificate(new ByteArrayInputStream(publicKey.getBytes()));
      return certificate.getSerialNumber().toString(16).toUpperCase();
  } catch (Exception e) {
      e.printStackTrace();
  }

验证签名

/**
 * 验证签名
 *
 * @param signature       待验证的签名
 * @param body            应答主体
 * @param nonce           随机串
 * @param timestamp       时间戳
 * @param certInputStream 微信支付平台证书输入流
 * @return 签名结果
 * @throws Exception 异常信息
 */
public static boolean verifySignature(String signature, String body, String nonce, String timestamp, InputStream certInputStream) throws Exception {
    String buildSignMessage = PayKit.buildSignMessage(timestamp, nonce, body);
    // 获取证书
    X509Certificate certificate = PayKit.getCertificate(certInputStream);
    PublicKey publicKey = certificate.getPublicKey();
    return RsaKit.checkByPublicKey(buildSignMessage, signature, publicKey);
}

/**
 * 公钥验证签名
 *
 * @param data      需要加密的数据
 * @param sign      签名
 * @param publicKey 公钥
 * @return 验证结果
 * @throws Exception 异常信息
 */
public static boolean checkByPublicKey(String data, String sign, PublicKey publicKey) throws Exception {
    java.security.Signature signature = java.security.Signature.getInstance("SHA256WithRSA");
    signature.initVerify(publicKey);
    signature.update(data.getBytes(StandardCharsets.UTF_8));
    return signature.verify(Base64.decode(sign.getBytes(StandardCharsets.UTF_8)));
}

至此微信支付 Api-v3 接口已介绍完,如有疑问欢迎留言一起探讨。

完整示例 SpringBoot

参考资料

  • 你真的了解 HTTPS 吗?
  • WechatPay-API-v3

你可能感兴趣的:(让支付触手可及)