feat(M05-B): 完成微信支付退款与对账基础

This commit is contained in:
Codex
2026-06-22 11:15:22 +08:00
parent dea2ee5ee1
commit 4c66e192b9
17 changed files with 1316 additions and 18 deletions
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import {
createCipheriv, createSign, generateKeyPairSync, randomBytes
} from 'node:crypto';
import {
WechatPayClient, WechatPayError
} from '../dist/payments/wechat-pay-client.js';
const merchantKeys = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
const platformKeys = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
const credential = {
appId: 'wx-test-app',
merchantId: '1900000109',
serialNo: 'MERCHANT-SERIAL',
privateKeyPem: merchantKeys.privateKey,
apiV3Key: '0123456789abcdef0123456789abcdef',
platformCertificates: { 'PLATFORM-SERIAL': platformKeys.publicKey }
};
const requests = [];
const responses = [
{ prepay_id: 'wx-prepay-test' },
{ trade_state: 'SUCCESS', transaction_id: 'wx-transaction-test' },
{ refund_id: 'wx-refund-test', status: 'PROCESSING' },
{ download_url: 'https://api.mch.weixin.qq.com/v3/billdownload/file?token=sanitized' }
];
const transport = {
async request(input) {
requests.push(input);
return {
status: 200,
headers: {},
body: JSON.stringify(responses.shift())
};
}
};
const client = new WechatPayClient(transport);
const prepay = await client.createJsapiPrepay(credential, {
description: 'M05-B sanitized order',
outTradeNo: 'PAY-M05B-001',
notifyUrl: 'https://api.txyundm.cn/app-api/pay/wechat/notify',
amountCents: 3600,
payerOpenId: 'openid-sanitized'
});
assert.equal(prepay.prepayId, 'wx-prepay-test');
assert.equal(prepay.paymentParams.package, 'prepay_id=wx-prepay-test');
assert.equal(prepay.paymentParams.signType, 'RSA');
assert.match(prepay.paymentParams.paySign, /^[A-Za-z0-9+/]+=*$/);
assert.match(requests[0].headers.Authorization, /mchid="1900000109"/);
assert.equal(requests[0].body.includes(merchantKeys.privateKey), false);
assert.equal(
(await client.queryTransaction(credential, 'PAY-M05B-001')).trade_state,
'SUCCESS'
);
assert.equal((await client.createRefund(credential, {
outTradeNo: 'PAY-M05B-001',
outRefundNo: 'REF-M05B-001',
reason: 'sanitized refund',
notifyUrl: 'https://api.txyundm.cn/app-api/pay/wechat/refund-notify',
refundCents: 1200,
totalCents: 3600
})).status, 'PROCESSING');
assert.match(requests[2].body, /"refund":1200/);
assert.match((await client.downloadTradeBill(
credential, '2026-06-21', 'ALL'
)).download_url, /^https:/);
const notification = encryptedNotification({
out_trade_no: 'PAY-M05B-001',
transaction_id: 'wx-transaction-test',
trade_state: 'SUCCESS',
amount: { total: 3600, currency: 'CNY' },
payer: { openid: 'must-not-be-persisted' }
}, credential.apiV3Key);
const timestamp = '1782057600';
const nonce = 'notification-nonce';
const signature = sign(
platformKeys.privateKey,
`${timestamp}\n${nonce}\n${notification}\n`
);
const decrypted = client.verifyAndDecrypt(credential, {
timestamp,
nonce,
serial: 'PLATFORM-SERIAL',
signature
}, notification);
assert.equal(decrypted.transaction_id, 'wx-transaction-test');
assert.equal(decrypted.amount.total, 3600);
assert.throws(
() => client.verifyAndDecrypt(credential, {
timestamp,
nonce,
serial: 'PLATFORM-SERIAL',
signature
}, `${notification} `),
(error) => error instanceof WechatPayError
&& error.code === 'WECHAT_SIGNATURE_INVALID'
);
console.log('PASS: M05-B Wechat Pay signing, API requests, notification verification and AES-GCM decryption.');
function encryptedNotification(resource, key) {
const nonce = randomBytes(12).toString('base64url').slice(0, 12);
const associatedData = 'transaction';
const cipher = createCipheriv('aes-256-gcm', Buffer.from(key), Buffer.from(nonce));
cipher.setAAD(Buffer.from(associatedData));
const ciphertext = Buffer.concat([
cipher.update(JSON.stringify(resource), 'utf8'),
cipher.final(),
cipher.getAuthTag()
]).toString('base64');
return JSON.stringify({
id: 'notification-m05b-001',
event_type: 'TRANSACTION.SUCCESS',
resource: {
algorithm: 'AEAD_AES_256_GCM',
ciphertext,
nonce,
associated_data: associatedData
}
});
}
function sign(privateKey, message) {
const signer = createSign('RSA-SHA256');
signer.update(message);
signer.end();
return signer.sign(privateKey, 'base64');
}