feat: 接入微信支付
This commit is contained in:
@@ -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
Reference in New Issue
Block a user