xzz2021
Published on 2025-06-20 / 2 Visits
0
0

微信扫码支付功能Native对接流程及实现(nodejs)

功能需求: 在自己的网站让用户扫码进行支付
整体流程
1. 前端发起请求, 传递订单或支付参数给后端
2. 后端将参数整合, 包含回调url, 加密签名后调用微信api, 成功响应后会收到微信返回的二维码字符信息, 交给前端
3. 前端自行将字符生成二维码, 用户扫码后, 微信会请求回调url通知结果
4. 后端回调接口进行验签,然后解密数据, 获取反馈,变更订单支付状态
5. 前端轮询状态, 成功跳转页面
后端请求二维码
  1. 必要条件

    通过商户号和商户名称((需与营业执照名称一致)), 申请api证书,获取api证书的序列号, 拿到公私匙证书文件apicilent_cert.pemapicilent_key.pem, 后续还要获取APIv3密匙用于回调验签

  2. 必传参数

        const appid = 'wxb01a75674330';  // 商户绑定的appid
        const mchid = '12453234569';   // 商户号
        const description = 'Image形象店-深圳腾大-QQ公仔';
        const out_trade_no = '121775465663233368018';  // 订单号
        const notify_url = 'https://www.weixin.qq.com/wxpay/pay.php';  // 自己的后端回调通知地址
        const amount = { total: 1, currency: 'CNY' };  // 金额及数据格式  微信货币单位是分,故只有Int整数
        const serial_no = '24EB7FF415E5DD7FF6F20517520428FE78091650'; // 商户证书序列号
    
  3. 证书序列号验证, 下面命令读取证书文件, 会输出正确序列号

    openssl x509 -in apiclient_cert.pem -noout -serial
    
  4. 额外的必须混淆参数

    // nonce_str 需要32位随机字符,最好大写     下面传入16 会生成32位字符
    const nonce_str = crypto.randomBytes(16).toString('hex').toUpperCase(); 
    //  timestamp 时间戳  秒级
    const timestamp = Math.floor(Date.now() / 1000).toString();
    
    //  signature 签名参数  请求方法  请求url  传递参数 body
    
  5. signature需要使用SHA256加密, 返回base64编码, 加密函数, 注意: 签名需要读取证书文件, privateKeyPath为证书项目目录

    signWithPrivateKey(bodyStr: string, privateKeyPath: string = 'src/table/payment/cert/apiclient_key.pem'): string {
        // 1. 根据私匙文件路径读取私钥
        const privateKey = fs.readFileSync(privateKeyPath, 'utf8');
        // 2. 创建签名器
        const sign = crypto.createSign('RSA-SHA256');
        sign.update(bodyStr);
        sign.end();
        // 3. 生成签名(Base64编码)
        const signature = sign.sign(privateKey, 'base64');
        return signature;
      }
    
  6. 核心代码

    // body 为传参对象  注意``模板字符串内是空格没有换行   一定要设置超时时间  微信一般响应时间>=10秒
    const message = `POST\n/v3/pay/transactions/native\n${timestamp}\n${nonce_str}\n${JSON.stringify(body)}\n`;
    const signature = this.signWithPrivateKey(message);
    const Authorization = `WECHATPAY2-SHA256-RSA2048 mchid="${mchid}",nonce_str="${nonce_str}",signature="${signature}",timestamp="${timestamp}",serial_no="${serial_no}"`;
    const WX_PAY_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/native"
    const response = await this.httpService.axiosRef.post(WX_PAY_URL, body, {
            headers: {
              'Content-Type': 'application/json',
              Authorization,
              Accept: 'application/json',
            },
            timeout: 30000,
          });
    //  如果成功 会返回 { "code_url" : "weixin://wxpay/bizpayurl/up?pr=NwY5Mz9&groupid=00" }
    
后端回调处理
  1. 必要条件

    从请求头获取参数加上原始body请求体, 使用证书校验签名,确保信息来自微信官方

    再解析请求体中的resource加密信息

    最后确保响应状态码为200或204

  2. 签名验证需要: 先调用官方接口拿到自己的所有证书(或许可以本地读取跳过???), 进行解密后再以 {序列号:publicKey}形式存储好,后续校验公匙进行签名验证

        const timestamp = headers['Wechatpay-Timestamp'];
        const nonce = headers['Wechatpay-Nonce'];
        const serial = headers['Wechatpay-Serial'];
        const signature = headers['Wechatpay-Signature'];
        const headerData = { timestamp, nonce, serial, signature };
    	const body = bodyData;
    

    核心代码

    // 校验签名
    async verifySign(params: {
        timestamp: string | number;
        nonce: string;
        serial: string;
        signature: string;
        body: Record<string, any> | string;
      }): Promise<boolean> {
        const { timestamp, nonce, serial, signature, body } = params;
        // 获取平台证书公钥
        let publicKey = this.certificates[serial];
        if (!publicKey) {
          await this.fetchCertificates();
          publicKey = this.certificates[serial];
          if (!publicKey) {
            throw new Error(`未找到平台证书序列号: ${serial}`);
          }
        }
        // 构造签名字符串
        const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
        const signStr = `${timestamp}\n${nonce}\n${bodyStr}\n`;
    
        try {
          const verify = crypto.createVerify('RSA-SHA256');
          verify.update(signStr);
          verify.end();
          return verify.verify(publicKey as crypto.KeyLike, signature, 'base64');
        } catch (err) {
          console.error('签名验证失败:', err);
          return false;
        }
      }
      
    //  获取微信官方 返回的 商户自己的  平台证书   并存储publicKey到 this.certificates
      async fetchCertificates() {
        const url = `https://api.mch.weixin.qq.com/v3/certificates`;
        try {
          const response = await axios.get(url, {
            headers: {
              Authorization: this.generateAuthorization('certificates')?.Authorization,
              Accept: 'application/json',
            },
          });
          const certificatesArray = (response?.data?.data as CertificateItem[]) || [];
          certificatesArray.forEach(item => {
            this.certificates[item.serial_no] = this.aesGcmDecrypt({
              associatedData: item.encrypt_certificate.associated_data,
              nonce: item.encrypt_certificate.nonce,
              ciphertext: item.encrypt_certificate.ciphertext,
            });
          });
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
        } catch (_err) {
          throw new Error('获取平台证书失败');
        }
      }
    
    //  私匙签名  需要注意证书的文件路径
      signWithPrivateKey(data: string, privateKeyPath: string = 'src/table/payment/cert/apiclient_key.pem'): string {
        //  如果是测试 使用文件apiclient_test_key.pem
        // 1. 读取私钥
        const privateKey = fs.readFileSync(privateKeyPath, 'utf8');
        // 2. 创建签名器
        const sign = crypto.createSign('RSA-SHA256');
        // const sign = crypto.createSign('SHA256');
        sign.update(data);
        sign.end();
        // 3. 生成签名(Base64编码)
        const signature = sign.sign(privateKey, 'base64');
        return signature;
      }
    
    // 生成加密数据 Authorization
      generateAuthorization(type: string = 'native', body: Partial<WxPayData> = {}) {
        const newBody = {
          ...body,
          appid: this.appid,
          mchid: this.mchid,
          notify_url: this.notify_url,
        };
        const nonce_str = crypto.randomBytes(16).toString('hex').toUpperCase();
        const timestamp = Math.floor(Date.now() / 1000).toString();
        let message = '';
        switch (type) {
          case 'native':
            message = `POST\n/v3/pay/transactions/native\n${timestamp}\n${nonce_str}\n${JSON.stringify(newBody)}\n`;
            break;
          case 'certificates':
            message = `GET\n/v3/certificates\n${timestamp}\n${nonce_str}\n\n`;
            break;
          default:
            throw new Error('不支持的类型');
        }
        const signature = this.signWithPrivateKey(message);
        const Authorization = `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchid}",nonce_str="${nonce_str}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.serial_no}"`;
        return { Authorization, body: newBody };
      }
    
  3. 解密报文resource, 需要3个参数加上apiV3Secret

    //  解密 报文 resource
      async decryptAESGCM(
        base64Ciphertext: string,
        nonce: string,
        associatedData: string,
      ): Promise<string> {
        const key = apiV3Secret; // Must be 32 bytes (for AES-256)
        const enc = new TextEncoder();
        const keyBytes = enc.encode(key);
        const nonceBytes = enc.encode(nonce);
        const adBytes = enc.encode(associatedData);
        const cipherBytes = Uint8Array.from(atob(base64Ciphertext), (c) =>
          c.charCodeAt(0),
        );
        const cryptoKey = await crypto.subtle.importKey(
          'raw',
          keyBytes,
          'AES-GCM',
          false,
          ['decrypt'],
        );
        const decrypted = await crypto.subtle.decrypt(
          {
            name: 'AES-GCM',
            iv: nonceBytes,
            additionalData: adBytes,
            tagLength: 128,
          },
          cryptoKey,
          cipherBytes,
        );
        return new TextDecoder().decode(decrypted);
      }
    
  4. 完整封装, 待优化

    import * as fs from 'fs';
    import * as crypto from 'crypto';
    import axios from 'axios';
    
    interface WxPayData {
      appid: string;
      mchid: string;
      description: string;
      out_trade_no: string;
      notify_url: string;
      amount: { total: number; currency: string };
    }
    
    interface CertificateItem {
      effective_time: string;
      expire_time: string;
      serial_no: string;
      encrypt_certificate: {
        algorithm: string;
        associated_data: string;
        ciphertext: string;
        nonce: string;
      };
    }
    
    export class WxPay {
      private readonly appid: string = 'wxb04564746truya6fa0';
      private readonly mchid: string = '1673452369';
      private readonly notify_url: string = 'https://xzz2021.github.com';
      private readonly serial_no: string = '24EB734642564FE78091650';
      private readonly apiV3Secret: string = '4564563453546562384626433832';
      private readonly wxNativePayUrl: string = 'https://api.mch.weixin.qq.com/v3/pay/transactions/native';
      private readonly privateKeyPath: string = 'src/cert/apiclient_key.pem';  
      private certificates: any = {};
    
      signWithPrivateKey(data: string, privateKeyPath: string = this.privateKeyPath): string {
        //  如果是测试 使用文件apiclient_test_key.pem
        // 1. 读取私钥
        const privateKey = fs.readFileSync(privateKeyPath, 'utf8');
        // 2. 创建签名器
        const sign = crypto.createSign('RSA-SHA256');
        // const sign = crypto.createSign('SHA256');
        sign.update(data);
        sign.end();
        // 3. 生成签名(Base64编码)
        const signature = sign.sign(privateKey, 'base64');
        return signature;
      }
    
      generateAuthorization(type: string = 'native', body: Partial<WxPayData> = {}) {
        const newBody = {
          ...body,
          appid: this.appid,
          mchid: this.mchid,
          notify_url: this.notify_url,
        };
        const nonce_str = crypto.randomBytes(16).toString('hex').toUpperCase();
        const timestamp = Math.floor(Date.now() / 1000).toString();
        let message = '';
        switch (type) {
          case 'native':
            message = `POST\n/v3/pay/transactions/native\n${timestamp}\n${nonce_str}\n${JSON.stringify(newBody)}\n`;
            break;
          case 'certificates':
            message = `GET\n/v3/certificates\n${timestamp}\n${nonce_str}\n\n`;
            break;
          default:
            throw new Error('不支持的类型');
        }
        const signature = this.signWithPrivateKey(message);
        const Authorization = `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchid}",nonce_str="${nonce_str}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.serial_no}"`;
    
        return { Authorization, body: newBody };
      }
    
      //  解密 报文 resource
      async decryptAESGCM(base64Ciphertext: string, nonce: string, associatedData: string): Promise<string> {
        const key = this.apiV3Secret; // Must be 32 bytes (for AES-256)
        const enc = new TextEncoder();
        const keyBytes = enc.encode(key);
        const nonceBytes = enc.encode(nonce);
        const adBytes = enc.encode(associatedData);
        const cipherBytes = Uint8Array.from(atob(base64Ciphertext), c => c.charCodeAt(0));
        const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']);
        const decrypted = await crypto.subtle.decrypt(
          {
            name: 'AES-GCM',
            iv: nonceBytes,
            additionalData: adBytes,
            tagLength: 128,
          },
          cryptoKey,
          cipherBytes,
        );
        return new TextDecoder().decode(decrypted);
      }
    
      //  解密 证书 获得publicKey
      aesGcmDecrypt({ associatedData, nonce, ciphertext }: { associatedData: string; nonce: string; ciphertext: string }): string {
        const key = Buffer.from(this.apiV3Secret, 'utf8');
        const nonceBuf = Buffer.from(nonce, 'utf8');
        const aadBuf = Buffer.from(associatedData, 'utf8');
        const cipherBuf = Buffer.from(ciphertext, 'base64');
    
        const tag = cipherBuf.subarray(cipherBuf.length - 16);
        const data = cipherBuf.subarray(0, cipherBuf.length - 16);
    
        const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonceBuf);
        decipher.setAuthTag(tag);
        decipher.setAAD(aadBuf);
    
        const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
        return decrypted.toString('utf8');
      }
    
      //  获取微信官方 返回的 商户自己的  平台证书   并存储publicKey
      async fetchCertificates() {
        const url = `https://api.mch.weixin.qq.com/v3/certificates`;
        try {
          const response = await axios.get(url, {
            headers: {
              Authorization: this.generateAuthorization('certificates')?.Authorization,
              Accept: 'application/json',
            },
          });
          const certificatesArray = (response?.data?.data as CertificateItem[]) || [];
          certificatesArray.forEach(item => {
            this.certificates[item.serial_no] = this.aesGcmDecrypt({
              associatedData: item.encrypt_certificate.associated_data,
              nonce: item.encrypt_certificate.nonce,
              ciphertext: item.encrypt_certificate.ciphertext,
            });
          });
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
        } catch (_err) {
          throw new Error('获取平台证书失败');
        }
      }
    
      async verifySign(params: { timestamp: string | number; nonce: string; serial: string; signature: string; body: Record<string, any> | string }) {
        const { timestamp, nonce, serial, signature, body } = params;
        if (!serial || !signature || !timestamp || !nonce) {
          throw new Error('请求头解析出的参数错误或者有遗漏!');
        }
        // 获取平台证书公钥
        let publicKey = this.certificates[serial] || '';
        if (!publicKey) {
          await this.fetchCertificates();
          publicKey = this.certificates[serial] || '';
          if (!publicKey) {
            throw new Error(`未找到平台证书序列号: ${serial}`);
          }
        }
        // 构造签名字符串
        const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
        const signStr = `${timestamp}\n${nonce}\n${bodyStr}\n`;
    
        const verify = crypto.createVerify('RSA-SHA256');
        verify.update(signStr);
        verify.end();
        const isVerify = verify.verify(publicKey as crypto.KeyLike, signature, 'base64');
        if (!isVerify) {
          throw new Error('签名验证失败');
        }
      }
    
      async getWxQrcode(objdata: any) {
        const { Authorization, body } = this.generateAuthorization('native', objdata as WxPayData);
        try {
          const response = await axios.post(this.wxNativePayUrl, body, {
            headers: {
              Authorization,
              'Content-Type': 'application/json',
              Accept: 'application/json',
            },
            timeout: 30000,
          });
          return response.data;
        } catch (error) {
          //   console.log('🚀 ~ WxPay ~ getWxQrcode ~ error:', error);
          // return {
          //   code: 400,
          //   message: '获取微信支付二维码失败',
          //   error: error?.response?.data,
          // };
          throw new Error('获取微信支付二维码失败, 原因: ' + error?.response?.data);
        }
      }
    }
    
    

Comment