评论

一文搞懂微信支付 Api-v3 规则实现(附源码)

同时支持 Api-v3 以及 Api-v2 版本接口, 同时支持多商户多应用, 同时支持国内与境外的普通商户模式和服务商模式, v2 接口同时支持 MD5 以及 HMAC-SHA256 签名算法。

v2 与 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-v3 规则 官方文档 ,此规则需要你耐心细品,重复多此细品效果更佳。

以下是我细品后,总结的实现方案,在这里就分享给大家,干货多屁话少直接贴实现。

Talk is cheap. Show me the code.

获取证书序列号

通过代码获取

这里我们使用第三方的库 x509,如你知道其它获取方法欢迎留言

const cert = x509.parseCert('cert.pem 证书绝对路径')
console.log(`证书序列号:${cert.serial}`)

通过工具获取

构建请求头

1、构建请求签名参数

 /**
   * 构建请求签名参数
   * @param method {RequestMethod} Http 请求方式
   * @param url 请求接口 /v3/certificates
   * @param timestamp 获取发起请求时的系统当前时间戳
   * @param nonceStr 随机字符串
   * @param body 请求报文主体
   */
  public static buildReqSignMessage(method: RequestMethod, url: string, timestamp: string, nonceStr: string, body: string): string {
    return method
      .concat('\n')
      .concat(url)
      .concat('\n')
      .concat(timestamp)
      .concat('\n')
      .concat(nonceStr)
      .concat('\n')
      .concat(body)
      .concat('\n')
  }

2、使用 SHA256 with RSA 算法生成签名

 /**
   * SHA256withRSA
   * @param data 待加密字符
   * @param privatekey 私钥key  key.pem   fs.readFileSync(keyPath)
   */
  public static sha256WithRsa(data: string, privatekey: Buffer): string {
    return crypto
      .createSign('RSA-SHA256')
      .update(data)
      .sign(privatekey, 'base64')
  }

3、根据平台规则生成请求头 authorization

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

4、Show Time

/**
   * 构建 v3 接口所需的 Authorization
   *
   * @param method    {RequestMethod} 请求方法
   * @param urlSuffix 可通过 WxApiType 来获取,URL挂载参数需要自行拼接
   * @param mchId     商户Id
   * @param serialNo  商户 API 证书序列号
   * @param key       key.pem 证书
   * @param body      接口请求参数
   */
  public static async buildAuthorization(method: RequestMethod, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, body: string): Promise<string> {
    let timestamp: string = parseInt((Date.now() / 1000).toString()).toString()

    let authType: string = 'WECHATPAY2-SHA256-RSA2048'
    let nonceStr: string = Kits.generateStr()

    // 构建签名参数
    let buildSignMessage: string = this.buildReqSignMessage(method, urlSuffix, timestamp, nonceStr, body)
    // 生成签名
    let signature: string = this.sha256WithRsa(key, buildSignMessage)
    // 根据平台规则生成请求头 authorization
    return this.getAuthorization(mchId, serialNo, nonceStr, timestamp, signature, authType)
  }

封装网络请求

每个人都有个性,可使用的网络库也比较多(Axios、Fetch、Request 等),为了适配能适配这里做一代理封装。具体实现如下,网络请求库默认是使用的 Axios

1、抽离抽象接口

/**
 * @author Javen
 * @copyright javendev@126.com
 * @description 封装网络请求工具
 */
export class HttpKit {
  private static delegate: HttpDelegate = new AxiosHttpKit()

  public static get getHttpDelegate(): HttpDelegate {
    return this.delegate
  }

  public static set setHttpDelegate(delegate: HttpDelegate) {
    this.delegate = delegate
  }
}

export interface HttpDelegate {
  httpGet(url: string, options?: any): Promise<any>
  httpGetToResponse(url: string, options?: any): Promise<any>
  httpPost(url: string, data: string, options?: any): Promise<any>
  httpPostToResponse(url: string, data: string, options?: any): Promise<any>
  httpDeleteToResponse(url: string, options?: any): Promise<any>
  httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any>
  upload(url: string, filePath: string, params?: string): Promise<any>
}

2、Axios 具体实现

/**
 * @author Javen
 * @copyright javendev@126.com
 * @description 使用 Axios 实现网络请求
 */
import axios from 'axios'
import * as fs from 'fs'
import { HttpDelegate } from './HttpKit'
import * as FormData from 'form-data'
import * as https from 'https'
import concat = require('concat-stream')

export class AxiosHttpKit implements HttpDelegate {
  httpGet(url: string, options?: any): Promise<any> {
    return new Promise((resolve, reject) => {
      axios
        .get(url, options)
        .then(response => {
          if (response.status === 200) {
            resolve(response.data)
          } else {
            reject(`error code ${response.status}`)
          }
        })
        .catch(error => {
          reject(error)
        })
    })
  }

  httpGetToResponse(url: string, options?: any): Promise<any> {
    return new Promise(resolve => {
      axios
        .get(url, options)
        .then(response => {
          resolve(response)
        })
        .catch(error => {
          resolve(error.response)
        })
    })
  }

  httpPost(url: string, data: string, options?: any): Promise<any> {
    return new Promise((resolve, reject) => {
      axios
        .post(url, data, options)
        .then(response => {
          if (response.status === 200) {
            resolve(response.data)
          } else {
            reject(`error code ${response.status}`)
          }
        })
        .catch(error => {
          reject(error)
        })
    })
  }

  httpPostToResponse(url: string, data: string, options?: any): Promise<any> {
    return new Promise(resolve => {
      axios
        .post(url, data, options)
        .then(response => {
          resolve(response)
        })
        .catch(error => {
          resolve(error.response)
        })
    })
  }

  httpDeleteToResponse(url: string, options?: any): Promise<any> {
    return new Promise(resolve => {
      axios
        .delete(url, options)
        .then(response => {
          resolve(response)
        })
        .catch(error => {
          resolve(error.response)
        })
    })
  }

  httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any> {
    return new Promise((resolve, reject) => {
      let httpsAgent = new https.Agent({
        pfx: certFileContent,
        passphrase
      })

      axios
        .post(url, data, { httpsAgent })
        .then(response => {
          if (response.status === 200) {
            resolve(response.data)
          } else {
            reject(`error code ${response.status}`)
          }
        })
        .catch(error => {
          reject(error)
        })
    })
  }

  upload(url: string, filePath: string, params?: string): Promise<any> {
    return new Promise((resolve, reject) => {
      let formData = new FormData()
      formData.append('media', fs.createReadStream(filePath))
      if (params) {
        formData.append('description', params)
      }
      formData.pipe(
        concat({ encoding: 'buffer' }, async data => {
          axios
            .post(url, data, {
              headers: {
                'Content-Type': 'multipart/form-data'
              }
            })
            .then(response => {
              if (response.status === 200) {
                resolve(response.data)
              } else {
                reject(`error code ${response.status}`)
              }
            })
            .catch(error => {
              reject(error)
            })
        })
      )
    })
  }
}

3、使其支持 Api-v3 接口规则

/**
   * 微信支付 Api-v3 get 请求
   * @param urlPrefix
   * @param urlSuffix
   * @param mchId
   * @param serialNo
   * @param key
   * @param params
   */
  public static async exeGet(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, params?: Map<string, string>): Promise<any> {
    if (params && params.size > 0) {
      urlSuffix = urlSuffix.concat('?').concat(this.createLinkString(params, '&', true, false))
    }
    let authorization = await this.buildAuthorization(RequestMethod.GET, urlSuffix, mchId, serialNo, key, '')
    return await this.get(urlPrefix.concat(urlSuffix), authorization, serialNo)
  }

  /**
   * 微信支付 Api-v3 post 请求
   * @param urlPrefix
   * @param urlSuffix
   * @param mchId
   * @param serialNo
   * @param key
   * @param data
   */
  public static async exePost(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, data: string): Promise<any> {
    let authorization = await this.buildAuthorization(RequestMethod.POST, urlSuffix, mchId, serialNo, key, data)
    return await this.post(urlPrefix.concat(urlSuffix), data, authorization, serialNo)
  }

  /**
   * 微信支付 Api-v3 delete 请求
   * @param urlPrefix
   * @param urlSuffix
   * @param mchId
   * @param serialNo
   * @param key
   */
  public static async exeDelete(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer): Promise<any> {
    let authorization = await this.buildAuthorization(RequestMethod.DELETE, urlSuffix, mchId, serialNo, key, '')
    return await this.delete(urlPrefix.concat(urlSuffix), authorization, serialNo)
  }

  /**
   * get 方法
   * @param url           请求 url
   * @param authorization 授权信息
   * @param serialNumber  证书序列号
   */
  public static async get(url: string, authorization: string, serialNumber?: string) {
    return await HttpKit.getHttpDelegate.httpGetToResponse(url, {
      headers: this.getHeaders(authorization, serialNumber)
    })
  }

  /**
   * post 方法
   * @param url           请求 url
   * @param authorization 授权信息
   * @param serialNumber  证书序列号
   */
  public static async post(url: string, data: string, authorization: string, serialNumber?: string) {
    return await HttpKit.getHttpDelegate.httpPostToResponse(url, data, {
      headers: this.getHeaders(authorization, serialNumber)
    })
  }

  /**
   * delete 方法
   * @param url           请求 url
   * @param authorization 授权信息
   * @param serialNumber  证书序列号
   */
  public static async delete(url: string, authorization: string, serialNumber?: string) {
    return await HttpKit.getHttpDelegate.httpDeleteToResponse(url, {
      headers: this.getHeaders(authorization, serialNumber)
    })
  }

  /**
   * 获取请求头
   * @param authorization 授权信息
   * @param serialNumber  证书序列号
   */
  private static getHeaders(authorization: string, serialNumber: string): Object {
    let userAgent: string = 'WeChatPay-TNWX-HttpClient/%s (%s) nodejs/%s'
    userAgent = util.format(
      userAgent,
      '2.4.0',
      os
        .platform()
        .concat('/')
        .concat(os.release()),
      process.version
    )
    return {
      Authorization: authorization,
      Accept: 'application/json',
      'Content-type': 'application/json',
      'Wechatpay-Serial': serialNumber,
      'User-Agent': userAgent
    }
  }

如何使用?

这里以「获取平台证书」为例,来演示上面封装的系列方法如何使用

try {
    let result = await PayKit.exeGet(
      WX_DOMAIN.CHINA, //
      WX_API_TYPE.GET_CERTIFICATES,
      config.mchId,
      x509.parseCert(config.certPath).serial,
      fs.readFileSync(config.keyPath)
    )
    console.log(`result.data:${result.data}`)

    // 应答报文主体
    let data = JSON.stringify(result.data)
    // 应答状态码
    console.log(`status:${result.status}`)
    console.log(`data:${data}`)
    // http 请求头
    let headers = result.headers
    // 证书序列号
    let serial = headers['wechatpay-serial']
    // 应答时间戳
    let timestamp = headers['wechatpay-timestamp']
    // 应答随机串
    let nonce = headers['wechatpay-nonce']
    // 应答签名
    let signature = headers['wechatpay-signature']

    console.log(`serial:\n${serial}`)
    console.log(`timestamp:\n${timestamp}`)
    console.log(`nonce:\n${nonce}`)
    console.log(`signature:\n${signature}`)

    ctx.body = data
  } catch (error) {
    console.log(error)
  }

至此微信支付 Api-v3 规则的接口已经测试通过。
但还有其他细节如要我们继续完善,比如 验证签名、证书和回调报文解密

证书和回调报文解密

AEAD_AES_256_GCM 解密算法实现

 /**
   * AEAD_AES_256_GCM 解密
   * @param key  apiKey3
   * @param nonce  加密使用的随机串初始化向量
   * @param associatedData  附加数据包
   * @param ciphertext   密文
   */
  public static aes256gcmDecrypt(key: string, nonce: string, associatedData: string, ciphertext: string): string {
    let ciphertextBuffer = Buffer.from(ciphertext, 'base64')
    let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16)
    let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16)
    let decipherIv = crypto.createDecipheriv('aes-256-gcm', key, nonce)
    decipherIv.setAuthTag(Buffer.from(authTag))
    decipherIv.setAAD(Buffer.from(associatedData))
    let decryptStr = decipherIv.update(data, null, 'utf8')
    decipherIv.final()
    return decryptStr
  }

保存微信平台证书示例


// 证书和回调报文解密
let certPath = '/Users/Javen/cert/platform_cert.pem'
try {
  let decrypt = PayKit.aes256gcmDecrypt(
    config.apiKey3,
    ctx.app.config.AEAD_AES_256_GCM.nonce,
    ctx.app.config.AEAD_AES_256_GCM.associated_data,
    ctx.app.config.AEAD_AES_256_GCM.ciphertext
  )
  // 保存证书
  fs.writeFileSync(certPath, decrypt)
  ctx.body = decrypt
} catch (error) {
  console.log(error)
}

验证签名

示例

// 根据序列号查证书  验证签名
let verifySignature: boolean = PayKit.verifySignature(signature, data, nonce, timestamp, fs.readFileSync(ctx.app.config.WxPayConfig.wxCertPath))
console.log(`verifySignature:${verifySignature}`)
          

构建应答签名参数

/**
   * 构建应答签名参数
   * @param timestamp 应答时间戳
   * @param nonceStr 应答随机串
   * @param body 应答报文主体
   */
  public static buildRepSignMessage(timestamp: string, nonceStr: string, body: string): string {
    return timestamp
      .concat('\n')
      .concat(nonceStr)
      .concat('\n')
      .concat(body)
      .concat('\n')
  }

使用平台证书验证

 /**
   * 验证签名
   * @param signature   待验证的签名
   * @param body        应答主体
   * @param nonce       随机串
   * @param timestamp   时间戳
   * @param publicKey   平台公钥
   */
  public static verifySignature(signature: string, body: string, nonce: string, timestamp: string, publicKey: Buffer): boolean {
    // 构建响应体中待签名数据
    let buildSignMessage: string = this.buildRepSignMessage(timestamp, nonce, body)
    return Kits.sha256WithRsaVerify(publicKey, signature, buildSignMessage)
  }
 /**
   * SHA256withRSA 验证签名
   * @param publicKey 公钥key
   * @param signature 待验证的签名串
   * @param data 需要验证的字符串
   */
  public static sha256WithRsaVerify(publicKey: Buffer, signature: string, data: string) {
    return crypto
      .createVerify('RSA-SHA256')
      .update(data)
      .verify(publicKey, signature, 'base64')
  }

完整示例代码 Egg-TNWX

TNWX: TypeScript + Node.js + WeiXin 微信系开发脚手架,支持微信公众号、微信支付、微信小游戏、微信小程序、企业微信/企业号、企业微信开放平台。最最最重要的是能快速的集成至任何 Node.js 框架(Express、Nest、Egg、Koa 等)

微信支付已支持 Api-v3 以及 Api-v2 版本接口,同时支持多商户多应用,国内与境外的普通商户模式和服务商模式,v2 接口同时支持 MD5 以及 HMAC-SHA256 签名算法。

如有疑问欢迎留言或者站内私信。

最后一次编辑于  2020-04-21  
点赞 5
收藏
评论

6 个评论

  • 博诺
    博诺
    2022-12-09

    问题:Error: Unsupported state or unable to authenticate data

        at Decipheriv.final (node:internal/crypto/cipher:193:29)

    描述:用nodejs的crypto解密支付成功消息回调报错

    nodejs版本:v18.12.1

    
          let ciphertextBuffer = Buffer.from(ciphertext, "base64");
        
        
         let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16);
         let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16);
    
    
        let decipherIv = crypto.createDecipheriv("aes-256-gcm", APIv3Key, nonce);
        decipherIv.setAuthTag(authTag);
        decipherIv.setAAD(Buffer.from(associatedData, "utf-8"));
    
    
        let plaintextString = decipherIv.update(data, undefined, "utf-8")
        decipherIv.final("utf-8")
    
    
        return JSON.parse(plaintextString);
    


    2022-12-09
    赞同
    回复
  • Seven
    Seven
    2021-11-08

    解密算法,

    let ciphertextBuffer = Buffer.from(ciphertext, 'base64')

    let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16)

    let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16

    这里的authTag为什么是 ciphertextBuffer的长度减16?是官方规定的吗?看了文档没有说明

    2021-11-08
    赞同
    回复
  • 鹏宇zero
    鹏宇zero
    2021-04-21

    请问 Note.js 版本有吗?

    2021-04-21
    赞同
    回复
  • 本王今年八岁
    本王今年八岁
    2020-07-14

    我这边遇到的问题是,应答没有带上签名,这种情况怎么处理呢

    2020-07-14
    赞同
    回复
  • 森
    2020-04-03

    请问一下multipart/form-data 类型的主体该怎么写,弄那个图片上传api 总感觉奇奇怪怪的。

    2020-04-03
    赞同
    回复 6
    • Javen
      Javen
      2020-04-03
      Java 版本可以参考:https://gitee.com/javen205/IJPay
      Node.js 版本我这边测试还有点问题暂未公开
      2020-04-03
      2
      回复
    • 森
      2020-04-04回复Javen
      我又个很奇怪的问题,在签名那个地方 应该 方式都是同样的,但是很奇怪,获取证书那里没问题,upload 这里一直报签名错误,而且我核对来 签名 都是 请求方式+地址+时间挫+随机串+data ~ postman测试用官方方法也是签名错误
      2020-04-04
      回复
    • Javen
      Javen
      2020-04-04回复
      我测试也是签名错误,估计文件上传方式不对,导致TX后台解析的文件数据与我们设置的数据不一致导致的
      2020-04-04
      2
      回复
    • Javen
      Javen
      2020-04-04
      解决了 https://gitee.com/javen205/TNWX/tree/dev/
      2020-04-04
      3
      回复
    • 森
      2020-04-05回复Javen
      此规则需要你耐心细品,重复多此细品效果更佳。  这句话我是深刻体会到了~!
      2020-04-05
      回复
    查看更多(1)
  • Fu 付引
    Fu 付引
    2020-03-17

    问下 publicKey 平台公钥 是从哪获取的

    2020-03-17
    赞同
    回复 4
登录 后发表内容