feat: 接入微信支付
This commit is contained in:
@@ -34,7 +34,7 @@ export class WechatPayService {
|
||||
private readonly mchId: string
|
||||
private readonly mchKey: string
|
||||
private readonly mchSerialNo: string
|
||||
private readonly mchPrivateKeyPath: string
|
||||
private readonly mchPrivateKey: string
|
||||
private readonly notifyUrl: string
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
@@ -42,13 +42,24 @@ export class WechatPayService {
|
||||
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.mchPrivateKey = this.loadPrivateKey(
|
||||
this.config.get<string>('WX_MCH_KEY_PATH') ?? './certs/apiclient_key.pem',
|
||||
)
|
||||
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 {
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -64,7 +75,7 @@ export class WechatPayService {
|
||||
* 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
|
||||
* 4. Build final paySign using RSA-SHA256 over appId + timeStamp + nonceStr + packageStr
|
||||
*/
|
||||
async createUnifiedOrder(params: UnifiedOrderParams): Promise<WxPaymentParams> {
|
||||
this.logger.log(
|
||||
@@ -119,13 +130,10 @@ export class WechatPayService {
|
||||
const prepayId = responseData.prepay_id
|
||||
|
||||
// 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 signData = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`
|
||||
const paySign = crypto
|
||||
.createHmac('SHA256', this.mchKey)
|
||||
.update(signData)
|
||||
.digest('hex')
|
||||
const paySignData = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`
|
||||
const paySign = this.signWithRSA(paySignData)
|
||||
|
||||
this.logger.log(`Payment params ready: orderNo=${params.orderNo}, prepayId=${prepayId}`)
|
||||
|
||||
@@ -133,7 +141,7 @@ export class WechatPayService {
|
||||
timeStamp,
|
||||
nonceStr,
|
||||
package: packageStr,
|
||||
signType: 'HMAC-SHA256',
|
||||
signType: 'RSA',
|
||||
paySign,
|
||||
}
|
||||
}
|
||||
@@ -268,14 +276,8 @@ export class WechatPayService {
|
||||
// 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 authorization =
|
||||
`WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonceStr}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
@@ -294,18 +296,10 @@ export class WechatPayService {
|
||||
* 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')
|
||||
return sign.sign(this.mchPrivateKey, 'base64')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,68 +308,44 @@ export class WechatPayService {
|
||||
* WeChat Pay v3 notification structure:
|
||||
* {
|
||||
* resource: {
|
||||
* ciphertext: "<base64 of AES-256-GCM encrypted JSON>",
|
||||
* nonce: "<16-byte nonce>",
|
||||
* associated_data: "<aead_key>"
|
||||
* ciphertext: "<base64 of AES-256-GCM encrypted JSON + auth tag>",
|
||||
* nonce: "<12-byte nonce>",
|
||||
* associated_data: "<aad>"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* 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
|
||||
* The APIV3 key (mchKey, 32 bytes) is used as the AES-256-GCM key.
|
||||
* The base64 decoded ciphertext has the 16-byte GCM auth tag appended at the end.
|
||||
* Decryption yields the plain JSON notification data directly (single layer).
|
||||
*/
|
||||
private decryptGCM(ciphertext: string, nonce: string, associatedData: string): string | null {
|
||||
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')
|
||||
|
||||
// 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')
|
||||
// Decode base64 ciphertext first, then split: last 16 bytes are auth tag
|
||||
const cipherBuffer = Buffer.from(ciphertext, '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)
|
||||
decipher.setAuthTag(authTag)
|
||||
if (associatedData) {
|
||||
decipher.setAAD(Buffer.from(associatedData, 'utf8'))
|
||||
}
|
||||
|
||||
const outerPlaintext = Buffer.concat([
|
||||
decipher.update(cipherBytes),
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(encryptedData),
|
||||
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
|
||||
return plaintext
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to decrypt notification: ${err}`)
|
||||
return null
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user