功能需求: 在自己的网站让用户扫码进行支付
整体流程
1. 前端发起请求, 传递订单或支付参数给后端
2. 后端将参数整合, 包含回调url, 加密签名后调用微信api, 成功响应后会收到微信返回的二维码字符信息, 交给前端
3. 前端自行将字符生成二维码, 用户扫码后, 微信会请求回调url通知结果
4. 后端回调接口进行验签,然后解密数据, 获取反馈,变更订单支付状态
5. 前端轮询状态, 成功跳转页面
后端请求二维码
-
必要条件
通过商户号和商户名称(
(需与营业执照名称一致)
), 申请api证书,获取api证书的序列号, 拿到公私匙证书文件apicilent_cert.pem
和apicilent_key.pem
, 后续还要获取APIv3密匙用于回调验签 -
必传参数
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'; // 商户证书序列号
-
证书序列号验证, 下面命令读取证书文件, 会输出正确序列号
openssl x509 -in apiclient_cert.pem -noout -serial
-
额外的必须混淆参数
// 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
-
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; }
-
核心代码
// 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" }
后端回调处理
-
必要条件
从请求头获取参数加上原始body请求体, 使用证书校验签名,确保信息来自微信官方
再解析请求体中的resource加密信息
最后确保响应状态码为200或204
-
签名验证需要: 先调用官方接口拿到自己的所有证书(或许可以本地读取跳过???), 进行解密后再以
{序列号: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 }; }
-
解密报文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); }
-
完整封装, 待优化
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); } } }