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:
yuzhiran
2026-06-18 19:33:10 +08:00
parent 7e1bf669ab
commit c161ffbc3c
5 changed files with 207 additions and 18 deletions
@@ -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
}
}