feat: 接入微信支付

This commit is contained in:
richarjiang
2026-04-05 18:23:23 +08:00
parent 9eee4f6b87
commit 694330b7a6
2 changed files with 46 additions and 76 deletions

View File

@@ -34,7 +34,7 @@ export class WechatPayService {
private readonly mchId: string private readonly mchId: string
private readonly mchKey: string private readonly mchKey: string
private readonly mchSerialNo: string private readonly mchSerialNo: string
private readonly mchPrivateKeyPath: string private readonly mchPrivateKey: string
private readonly notifyUrl: string private readonly notifyUrl: string
constructor(private readonly config: ConfigService) { constructor(private readonly config: ConfigService) {
@@ -42,13 +42,24 @@ export class WechatPayService {
this.mchId = this.config.get<string>('WX_MCH_ID') ?? '' this.mchId = this.config.get<string>('WX_MCH_ID') ?? ''
this.mchKey = this.config.get<string>('WX_MCH_KEY') ?? '' this.mchKey = this.config.get<string>('WX_MCH_KEY') ?? ''
this.mchSerialNo = this.config.get<string>('WX_MCH_SERIAL_NO') ?? '' 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.mchPrivateKey = this.loadPrivateKey(
this.config.get<string>('WX_MCH_KEY_PATH') ?? './certs/apiclient_key.pem',
)
this.notifyUrl = this.buildNotifyUrl() this.notifyUrl = this.buildNotifyUrl()
} }
private loadPrivateKey(keyPath: string): string {
try {
return fs.readFileSync(path.resolve(keyPath), 'utf8')
} catch (err) {
this.logger.error(`Failed to read private key from ${keyPath}: ${err}`)
throw new Error('微信支付初始化失败: 无法读取商户私钥文件')
}
}
private buildNotifyUrl(): string { private buildNotifyUrl(): string {
const apiBase = this.config.get<string>('API_BASE_URL') ?? 'http://localhost:3000' const apiBase = this.config.get<string>('API_BASE_URL') ?? 'http://localhost:3000'
return `${apiBase}/payment/wx-notify` return `${apiBase}/api/payment/wx-notify`
} }
// ─── Public API ──────────────────────────────────────────────────────────── // ─── Public API ────────────────────────────────────────────────────────────
@@ -64,7 +75,7 @@ export class WechatPayService {
* amount { total (fen), currency }, payer { openid } * amount { total (fen), currency }, payer { openid }
* 2. Sign request with RSA-SHA256 using merchant private key * 2. Sign request with RSA-SHA256 using merchant private key
* 3. Extract prepay_id from response * 3. Extract prepay_id from response
* 4. Build final paySign using HMAC-SHA256 over appId + timeStamp + nonceStr + packageStr * 4. Build final paySign using RSA-SHA256 over appId + timeStamp + nonceStr + packageStr
*/ */
async createUnifiedOrder(params: UnifiedOrderParams): Promise<WxPaymentParams> { async createUnifiedOrder(params: UnifiedOrderParams): Promise<WxPaymentParams> {
this.logger.log( this.logger.log(
@@ -119,13 +130,10 @@ export class WechatPayService {
const prepayId = responseData.prepay_id const prepayId = responseData.prepay_id
// Step 3: Build payment params for mini-program // Step 3: Build payment params for mini-program
// The jsapi signature uses HMAC-SHA256 over: appId + timeStamp + nonceStr + packageStr // V3 API uses RSA-SHA256 for mini-program payment signing
const packageStr = `prepay_id=${prepayId}` const packageStr = `prepay_id=${prepayId}`
const signData = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n` const paySignData = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`
const paySign = crypto const paySign = this.signWithRSA(paySignData)
.createHmac('SHA256', this.mchKey)
.update(signData)
.digest('hex')
this.logger.log(`Payment params ready: orderNo=${params.orderNo}, prepayId=${prepayId}`) this.logger.log(`Payment params ready: orderNo=${params.orderNo}, prepayId=${prepayId}`)
@@ -133,7 +141,7 @@ export class WechatPayService {
timeStamp, timeStamp,
nonceStr, nonceStr,
package: packageStr, package: packageStr,
signType: 'HMAC-SHA256', signType: 'RSA',
paySign, paySign,
} }
} }
@@ -268,14 +276,8 @@ export class WechatPayService {
// Sign with merchant's RSA private key using SHA256 with RSA // Sign with merchant's RSA private key using SHA256 with RSA
const signature = this.signWithRSA(signString) const signature = this.signWithRSA(signString)
const authorization = [ const authorization =
`WECHATPAY2-SHA256-RSA2048`, `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonceStr}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`
`mchid="${this.mchId}"`,
`nonce_str="${nonceStr}"`,
`signature="${signature}"`,
`timestamp="${timestamp}"`,
`serial_no="${this.mchSerialNo}"`,
].join(', ')
const response = await fetch(url, { const response = await fetch(url, {
method, method,
@@ -294,18 +296,10 @@ export class WechatPayService {
* Sign data using RSA-SHA256 with the merchant's private key. * Sign data using RSA-SHA256 with the merchant's private key.
*/ */
private signWithRSA(data: string): string { 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') const sign = crypto.createSign('RSA-SHA256')
sign.update(data) sign.update(data)
sign.end() sign.end()
return sign.sign(privateKey, 'base64') return sign.sign(this.mchPrivateKey, 'base64')
} }
/** /**
@@ -314,68 +308,44 @@ export class WechatPayService {
* WeChat Pay v3 notification structure: * WeChat Pay v3 notification structure:
* { * {
* resource: { * resource: {
* ciphertext: "<base64 of AES-256-GCM encrypted JSON>", * ciphertext: "<base64 of AES-256-GCM encrypted JSON + auth tag>",
* nonce: "<16-byte nonce>", * nonce: "<12-byte nonce>",
* associated_data: "<aead_key>" * associated_data: "<aad>"
* } * }
* } * }
* *
* The encrypted `ciphertext` decodes to a JSON string: * The APIV3 key (mchKey, 32 bytes) is used as the AES-256-GCM key.
* { "ciphertext": "<base64 of notification JSON>", * The base64 decoded ciphertext has the 16-byte GCM auth tag appended at the end.
* "nonce": "<nonce>", * Decryption yields the plain JSON notification data directly (single layer).
* "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 { private decryptGCM(ciphertext: string, nonce: string, associatedData: string): string | null {
try { try {
const keyBytes = Buffer.from(this.mchKey.slice(0, 32).padEnd(32, '0'), 'utf8') // APIv3 key must be exactly 32 bytes
const keyBytes = Buffer.from(this.mchKey, 'utf8')
if (keyBytes.length !== 32) {
this.logger.error(`APIv3 key must be 32 bytes, got ${keyBytes.length}`)
return null
}
const nonceBuffer = Buffer.from(nonce, 'utf8') const nonceBuffer = Buffer.from(nonce, 'utf8')
// ciphertext includes the 16-byte auth tag appended at the end (last 16 bytes) // Decode base64 ciphertext first, then split: last 16 bytes are auth tag
const cipherBytes = Buffer.from(ciphertext.slice(0, -16), 'base64') const cipherBuffer = Buffer.from(ciphertext, 'base64')
const authTag = Buffer.from(ciphertext.slice(-16), 'base64') const authTag = cipherBuffer.subarray(cipherBuffer.length - 16)
const encryptedData = cipherBuffer.subarray(0, cipherBuffer.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBytes, nonceBuffer) const decipher = crypto.createDecipheriv('aes-256-gcm', keyBytes, nonceBuffer)
decipher.setAuthTag(authTag) decipher.setAuthTag(authTag)
if (associatedData) {
decipher.setAAD(Buffer.from(associatedData, 'utf8'))
}
const outerPlaintext = Buffer.concat([ const plaintext = Buffer.concat([
decipher.update(cipherBytes), decipher.update(encryptedData),
decipher.final(), decipher.final(),
]).toString('utf8') ]).toString('utf8')
// Step 1 result: JSON string with nested ciphertext, nonce, associated_data return plaintext
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) { } catch (err) {
this.logger.error(`Failed to decrypt notification: ${err}`) this.logger.error(`Failed to decrypt notification: ${err}`)
return null return null

File diff suppressed because one or more lines are too long