一文搞懂「微信支付 Api-v3」接口規則所有知識點

簡介

爲了在保證支付安全的前提下,帶給商戶簡單、一致且易用的開發體驗,我們推出了全新的微信支付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年的咖啡」提提神。

獲取證書序列號

通過工具獲取

通過代碼獲取

// 獲取證書序列號
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

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章