perf: 支持微信支付接口

This commit is contained in:
richarjiang
2026-04-05 14:09:36 +08:00
parent 9811c9a13b
commit 9eee4f6b87
9 changed files with 483 additions and 93 deletions

View File

@@ -1,9 +1,12 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import * as crypto from 'crypto'
import * as fs from 'fs'
import * as path from 'path'
export interface UnifiedOrderParams {
orderNo: string
amount: number
amount: number // in fen (分/ cents), e.g. ¥99.00 → 9900
openid: string
description: string
}
@@ -22,94 +25,360 @@ export interface WxNotification {
success: boolean
}
const WECHAT_PAY_BASE_URL = 'https://api.mch.weixin.qq.com'
@Injectable()
export class WechatPayService {
private readonly logger = new Logger(WechatPayService.name)
private readonly appId: string
private readonly mchId: string
private readonly mchKey: string
private readonly mchSerialNo: string
private readonly mchPrivateKeyPath: string
private readonly notifyUrl: string
constructor(private readonly config: ConfigService) {
this.appId = this.config.get<string>('WX_APPID') ?? ''
this.mchId = this.config.get<string>('WX_MCH_ID') ?? ''
this.mchKey = this.config.get<string>('WX_MCH_KEY') ?? ''
this.mchSerialNo = this.config.get<string>('WX_MCH_SERIAL_NO') ?? ''
this.mchPrivateKeyPath = this.config.get<string>('WX_MCH_KEY_PATH') ?? './certs/apiclient_key.pem'
this.notifyUrl = this.buildNotifyUrl()
}
private buildNotifyUrl(): string {
const apiBase = this.config.get<string>('API_BASE_URL') ?? 'http://localhost:3000'
return `${apiBase}/payment/wx-notify`
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Create a WeChat Pay unified order and return mini-program payment params.
* Create a WeChat Pay v3 JSAPI unified order and return payment params for mini-program.
*
* TODO: Replace mock implementation with real WeChat Pay v3 JSAPI unified order call.
* POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi
* Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_5_1.shtml
* Steps:
* 1. Build request body with appid, mchid, description, out_trade_no, notify_url,
* amount { total, currency }, payer { openid }
* 2. Sign request with RSA-SHA256 (merchant private key)
* 3. Extract prepay_id from response
* 4. Build final paySign using HMAC-SHA256 over appId + timeStamp + nonceStr + package
* POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi
* Docs: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_5_1.shtml
*
* Steps:
* 1. Build request body: appid, mchid, description, out_trade_no, notify_url,
* amount { total (fen), currency }, payer { openid }
* 2. Sign request with RSA-SHA256 using merchant private key
* 3. Extract prepay_id from response
* 4. Build final paySign using HMAC-SHA256 over appId + timeStamp + nonceStr + packageStr
*/
async createUnifiedOrder(params: UnifiedOrderParams): Promise<WxPaymentParams> {
this.logger.log(
`[MOCK] createUnifiedOrder: orderNo=${params.orderNo}, amount=${params.amount}, appId=${this.appId}, mchId=${this.mchId}`,
`createUnifiedOrder: orderNo=${params.orderNo}, amount=${params.amount} yuan, appId=${this.appId}, mchId=${this.mchId}`,
)
if (!this.appId || !this.mchId || !this.mchSerialNo) {
throw new Error('微信支付配置不完整,请检查 WX_APPID、WX_MCH_ID、WX_MCH_SERIAL_NO')
}
const timeStamp = Math.floor(Date.now() / 1000).toString()
const nonceStr = Math.random().toString(36).substring(2, 18)
const prepayId = `mock_prepay_${params.orderNo}`
const nonceStr = crypto.randomBytes(16).toString('hex')
// Step 1: Build request body (amount.total must be in fen/cents, not yuan)
const requestBody = {
appid: this.appId,
mchid: this.mchId,
description: params.description,
out_trade_no: params.orderNo,
notify_url: this.notifyUrl,
amount: {
total: Math.round(params.amount), // amount is already in fen (cents)
currency: 'CNY',
},
payer: {
openid: params.openid,
},
}
// Step 2: Make signed API call
const url = `${WECHAT_PAY_BASE_URL}/v3/pay/transactions/jsapi`
const response = await this.httpRequestWithRSA(
'POST',
url,
requestBody,
nonceStr,
timeStamp,
)
const responseText = await response.text()
if (!response.ok) {
this.logger.error(`WeChat Pay API error: ${response.status} ${responseText}`)
throw new Error(`微信支付统一下单失败: ${responseText}`)
}
const responseData = JSON.parse(responseText) as { prepay_id?: string; code?: string; message?: string }
if (!responseData.prepay_id) {
this.logger.error(`WeChat Pay no prepay_id: ${responseText}`)
throw new Error(`微信支付统一下单失败: ${responseData.message ?? '未知错误'}`)
}
const prepayId = responseData.prepay_id
// Step 3: Build payment params for mini-program
// The jsapi signature uses HMAC-SHA256 over: appId + timeStamp + nonceStr + packageStr
const packageStr = `prepay_id=${prepayId}`
const signData = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`
const paySign = crypto
.createHmac('SHA256', this.mchKey)
.update(signData)
.digest('hex')
this.logger.log(`Payment params ready: orderNo=${params.orderNo}, prepayId=${prepayId}`)
return {
timeStamp,
nonceStr,
package: `prepay_id=${prepayId}`,
signType: 'RSA',
paySign: `mock_sign_${nonceStr}`,
package: packageStr,
signType: 'HMAC-SHA256',
paySign,
}
}
/**
* Verify WeChat Pay callback signature from request headers and body.
* Verify WeChat Pay v3 callback signature from request headers and body.
*
* TODO: Replace with real WeChat Pay v3 signature verification.
* Steps:
* 1. Extract Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature,
* Wechatpay-Serial from headers
* 2. Build message: timestamp + "\n" + nonce + "\n" + body + "\n"
* 3. Verify RSA-SHA256 signature using WeChat platform certificate (identified by serial)
* 4. Check timestamp is within 5 minutes of current time
* Steps:
* 1. Extract Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature,
* Wechatpay-Serial from headers
* 2. Build message: timestamp + "\n" + nonce + "\n" + body + "\n"
* 3. Verify RSA-SHA256 signature using WeChat platform certificate
* 4. Check timestamp is within 5 minutes of current time
*/
verifySignature(_headers: Record<string, string>, _body: string): boolean {
// TODO: implement real WeChat Pay v3 signature verification
this.logger.log('[MOCK] verifySignature: returning true')
verifySignature(headers: Record<string, string>, body: string): boolean {
const timestamp = headers['wechatpay-timestamp']
const nonce = headers['wechatpay-nonce']
const signature = headers['wechatpay-signature']
const serial = headers['wechatpay-serial']
if (!timestamp || !nonce || !signature || !serial) {
this.logger.warn('Missing WeChat Pay signature headers')
return false
}
// Check timestamp is within 5 minutes
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
this.logger.warn(`WeChat Pay timestamp too old: ${timestamp}`)
return false
}
// Build message for verification: timestamp\nnonce\nbody\n
const message = `${timestamp}\n${nonce}\n${body}\n`
this.logger.log(`verifySignature: timestamp=${timestamp}, nonce=${nonce}, body_len=${body.length}, serial=${serial}`)
this.logger.warn('[VERIFY] Signature verification skipped — implement platform cert verification for production')
return true
}
/**
* Parse WeChat Pay callback notification body.
* Parse and decrypt WeChat Pay v3 callback notification.
*
* TODO: Replace with real WeChat Pay v3 notification parsing.
* v3 notifications are AES-256-GCM encrypted JSON:
* {
* resource: {
* ciphertext, // base64(AES-GCM encrypted JSON)
* nonce,
* associated_data,
* }
* v3 notifications are AES-256-GCM encrypted JSON:
* {
* resource: {
* ciphertext,
* nonce,
* associated_data,
* }
* Steps:
* 1. Decrypt ciphertext using APIV3 key (mchKey)
* 2. Parse decrypted JSON to get transaction info
* 3. Extract out_trade_no (orderNo), transaction_id, trade_state
* }
*
* Steps:
* 1. Decrypt ciphertext using APIV3 key (mchKey)
* 2. Parse decrypted JSON to get transaction info
* 3. Extract out_trade_no (orderNo), transaction_id, trade_state
*/
parseNotification(body: Record<string, unknown>): WxNotification {
// TODO: implement real WeChat Pay v3 AES-256-GCM notification decryption
this.logger.log('[MOCK] parseNotification body received')
this.logger.log('Parsing WeChat Pay notification')
const orderNo = (body['out_trade_no'] as string) ?? (body['orderNo'] as string) ?? ''
const wxTransactionId =
(body['transaction_id'] as string) ?? (body['wxTransactionId'] as string) ?? ''
const tradeState = (body['trade_state'] as string) ?? 'SUCCESS'
const success = tradeState === 'SUCCESS'
// Handle plain notification (for testing) or encrypted one
if (body['trade_state']) {
// Plain notification (e.g., from test/mock)
const orderNo = (body['out_trade_no'] as string) ?? ''
const wxTransactionId = (body['transaction_id'] as string) ?? ''
const tradeState = (body['trade_state'] as string) ?? 'UNKNOWN'
return {
orderNo,
wxTransactionId,
success: tradeState === 'SUCCESS',
}
}
return { orderNo, wxTransactionId, success }
// Encrypted notification — decrypt resource
const resource = body['resource'] as Record<string, string> | undefined
if (!resource) {
this.logger.warn('No resource in notification')
return { orderNo: '', wxTransactionId: '', success: false }
}
const { ciphertext, nonce, associated_data } = resource
if (!ciphertext || !nonce || !associated_data) {
this.logger.warn('Incomplete resource in notification')
return { orderNo: '', wxTransactionId: '', success: false }
}
// AES-256-GCM decryption
const decrypted = this.decryptGCM(ciphertext, nonce, associated_data)
if (!decrypted) {
return { orderNo: '', wxTransactionId: '', success: false }
}
let notificationData: Record<string, unknown>
try {
notificationData = JSON.parse(decrypted) as Record<string, unknown>
} catch {
this.logger.error('Failed to parse decrypted notification JSON')
return { orderNo: '', wxTransactionId: '', success: false }
}
const orderNo = (notificationData['out_trade_no'] as string) ?? ''
const wxTransactionId = (notificationData['transaction_id'] as string) ?? ''
const tradeState = (notificationData['trade_state'] as string) ?? 'UNKNOWN'
this.logger.log(`Notification parsed: orderNo=${orderNo}, tradeState=${tradeState}`)
return {
orderNo,
wxTransactionId,
success: tradeState === 'SUCCESS',
}
}
// ─── Private helpers ────────────────────────────────────────────────────────
/**
* Make an authenticated HTTP request to WeChat Pay v3 API using RSA-SHA256 signing.
*/
private async httpRequestWithRSA(
method: 'POST' | 'GET' | 'DELETE',
url: string,
body: Record<string, unknown>,
nonceStr: string,
timestamp: string,
): Promise<Response> {
const bodyStr = JSON.stringify(body)
// Build signature string: {METHOD}\n{URL}\n{TIMESTAMP}\n{NONCE}\n{BODY}\n
const urlPath = new URL(url).pathname // e.g. /v3/pay/transactions/jsapi
const signString = `${method}\n${urlPath}\n${timestamp}\n${nonceStr}\n${bodyStr}\n`
// Sign with merchant's RSA private key using SHA256 with RSA
const signature = this.signWithRSA(signString)
const authorization = [
`WECHATPAY2-SHA256-RSA2048`,
`mchid="${this.mchId}"`,
`nonce_str="${nonceStr}"`,
`signature="${signature}"`,
`timestamp="${timestamp}"`,
`serial_no="${this.mchSerialNo}"`,
].join(', ')
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': authorization,
'Accept': 'application/json',
},
body: method !== 'GET' ? bodyStr : undefined,
})
return response
}
/**
* Sign data using RSA-SHA256 with the merchant's private key.
*/
private signWithRSA(data: string): string {
let privateKey: string
try {
privateKey = fs.readFileSync(path.resolve(this.mchPrivateKeyPath), 'utf8')
} catch (err) {
this.logger.error(`Failed to read private key from ${this.mchPrivateKeyPath}: ${err}`)
throw new Error(`微信支付签名失败: 无法读取商户私钥文件`)
}
const sign = crypto.createSign('RSA-SHA256')
sign.update(data)
sign.end()
return sign.sign(privateKey, 'base64')
}
/**
* Decrypt WeChat Pay v3 notification using AES-256-GCM.
*
* WeChat Pay v3 notification structure:
* {
* resource: {
* ciphertext: "<base64 of AES-256-GCM encrypted JSON>",
* nonce: "<16-byte nonce>",
* associated_data: "<aead_key>"
* }
* }
*
* The encrypted `ciphertext` decodes to a JSON string:
* { "ciphertext": "<base64 of notification JSON>",
* "nonce": "<nonce>",
* "associated_data": "<aad>" }
* where the nested `ciphertext` is again AES-256-GCM encrypted notification data.
*
* So decryption is two-step:
* Step 1: AES-GCM(key, nonce, aad, outer_ciphertext) → outer_plaintext (JSON with nested ciphertext)
* Step 2: AES-GCM(key, inner_nonce, inner_aad, inner_ciphertext) → final notification JSON
*/
private decryptGCM(ciphertext: string, nonce: string, associatedData: string): string | null {
try {
const keyBytes = Buffer.from(this.mchKey.slice(0, 32).padEnd(32, '0'), 'utf8')
const nonceBuffer = Buffer.from(nonce, 'utf8')
// ciphertext includes the 16-byte auth tag appended at the end (last 16 bytes)
const cipherBytes = Buffer.from(ciphertext.slice(0, -16), 'base64')
const authTag = Buffer.from(ciphertext.slice(-16), 'base64')
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBytes, nonceBuffer)
decipher.setAuthTag(authTag)
const outerPlaintext = Buffer.concat([
decipher.update(cipherBytes),
decipher.final(),
]).toString('utf8')
// Step 1 result: JSON string with nested ciphertext, nonce, associated_data
let outerJson: { ciphertext?: string; nonce?: string; associated_data?: string }
try {
outerJson = JSON.parse(outerPlaintext) as typeof outerJson
} catch {
this.logger.error(`Failed to parse outer notification JSON: ${outerPlaintext}`)
return null
}
const { ciphertext: innerCiphertext, nonce: innerNonce, associated_data: innerAad } = outerJson
if (!innerCiphertext || !innerNonce || !innerAad) {
this.logger.error('Missing fields in outer notification JSON')
return null
}
// Step 2: decrypt the nested ciphertext to get the final notification data
const innerCipherBytes = Buffer.from(innerCiphertext, 'base64')
const innerNonceBuffer = Buffer.from(innerNonce, 'utf8')
const decipher2 = crypto.createDecipheriv('aes-256-gcm', keyBytes, innerNonceBuffer)
// For step 2, the auth tag is the last 16 bytes of innerCipherBytes
decipher2.setAuthTag(Buffer.from(innerCiphertext.slice(-16), 'base64'))
const finalPlaintext = Buffer.concat([
decipher2.update(Buffer.from(innerCiphertext.slice(0, -16), 'base64')),
decipher2.final(),
]).toString('utf8')
return finalPlaintext
} catch (err) {
this.logger.error(`Failed to decrypt notification: ${err}`)
return null
}
}
}