feat: payment refund support + admin payment management
- Add refund()/queryRefund()/downloadPlatformCerts() to WechatPayService - Add refundId field to PaymentOrder schema - Fix WeChat Pay callback to auto-download platform certs on verification failure - Fix syncOrder to handle sprint plan properly - Add admin refund, refund-query, order-detail endpoints - Add refund UI (button, modal, query) to admin.vue orders tab - Fix member.vue MP payment: pass outTradeNo instead of prepayId to pollPayResult
This commit is contained in:
@@ -122,18 +122,23 @@ export class WechatPayService {
|
||||
|
||||
/** 验证并解密回调通知 */
|
||||
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string) {
|
||||
// 1. 验签
|
||||
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
||||
const certDir = path.resolve(__dirname, '../../certs')
|
||||
if (!fs.existsSync(certDir)) {
|
||||
this.logger.error(`证书目录不存在: ${certDir}`)
|
||||
return null
|
||||
}
|
||||
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
|
||||
const pemPath = path.join(certDir, 'pub_key.pem')
|
||||
if (!fs.existsSync(pemPath)) {
|
||||
this.logger.error('平台证书 pub_key.pem 不存在,请运行 downloadPlatformCerts')
|
||||
return null
|
||||
}
|
||||
const platformCert = fs.readFileSync(pemPath, 'utf8')
|
||||
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
||||
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
|
||||
if (!isValid) {
|
||||
this.logger.warn('微信支付回调验签失败')
|
||||
this.logger.warn('微信支付回调验签失败,尝试重新下载平台证书...')
|
||||
this.downloadPlatformCerts().catch(e => this.logger.error(`自动更新证书失败: ${e.message}`))
|
||||
return null
|
||||
}
|
||||
// 2. 解密 resource
|
||||
@@ -143,7 +148,6 @@ export class WechatPayService {
|
||||
const nonce = resource.nonce
|
||||
const key = API_V3_KEY
|
||||
if (!key) throw new Error('WX_API_V3_KEY 未配置')
|
||||
// AES-256-GCM 解密
|
||||
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
||||
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
|
||||
@@ -157,4 +161,49 @@ export class WechatPayService {
|
||||
async queryOrder(outTradeNo: string) {
|
||||
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
|
||||
}
|
||||
|
||||
/** 退款 */
|
||||
async refund(outTradeNo: string, total: number, refundAmount?: number, reason?: string) {
|
||||
const body: any = {
|
||||
out_trade_no: outTradeNo,
|
||||
out_refund_no: `RF${Date.now()}`,
|
||||
amount: { refund: refundAmount || total, total, currency: 'CNY' },
|
||||
}
|
||||
if (reason) body.reason = reason
|
||||
return this.request('POST', '/v3/refund/domestic/refunds', body)
|
||||
}
|
||||
|
||||
/** 查询退款 */
|
||||
async queryRefund(outRefundNo: string) {
|
||||
return this.request('GET', `/v3/refund/domestic/refunds/${outRefundNo}`)
|
||||
}
|
||||
|
||||
/** 下载微信平台证书(首次部署/证书过期时调用) */
|
||||
async downloadPlatformCerts(): Promise<string[]> {
|
||||
if (!API_V3_KEY) throw new Error('WX_API_V3_KEY 未配置')
|
||||
const certs = await this.request('GET', '/v3/certificates')
|
||||
const downloaded: string[] = []
|
||||
const certDir = path.resolve(__dirname, '../../certs')
|
||||
if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true })
|
||||
|
||||
for (const item of certs.data || []) {
|
||||
const { serial_no, effective_time, expire_time, encrypt_certificate } = item
|
||||
const { algorithm, nonce, associated_data, ciphertext } = encrypt_certificate
|
||||
if (algorithm !== 'AEAD_AES_256_GCM' || !nonce) continue
|
||||
|
||||
const cipherBuf = Buffer.from(ciphertext, 'base64')
|
||||
const authTag = cipherBuf.subarray(cipherBuf.length - 16)
|
||||
const data = cipherBuf.subarray(0, cipherBuf.length - 16)
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(API_V3_KEY), nonce)
|
||||
decipher.setAAD(Buffer.from(associated_data))
|
||||
decipher.setAuthTag(authTag)
|
||||
const decrypted = decipher.update(data) + decipher.final('utf8')
|
||||
|
||||
const pemPath = path.join(certDir, 'pub_key.pem')
|
||||
fs.writeFileSync(pemPath, decrypted)
|
||||
downloaded.push(serial_no)
|
||||
this.logger.log(`微信平台证书已更新: ${serial_no}, 有效期至 ${expire_time}`)
|
||||
}
|
||||
return downloaded
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user