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
+11 -1
View File
@@ -54,6 +54,9 @@ const shareVerifySql = read('database/migrations/2026062014_m04d_order_shares.ve
const paymentUpSql = read('database/migrations/2026062015_m05a_payment_domain.up.sql');
const paymentDownSql = read('database/migrations/2026062015_m05a_payment_domain.down.sql');
const paymentVerifySql = read('database/migrations/2026062015_m05a_payment_domain.verify.sql');
const wechatRefundUpSql = read('database/migrations/2026062216_m05b_wechat_refunds.up.sql');
const wechatRefundDownSql = read('database/migrations/2026062216_m05b_wechat_refunds.down.sql');
const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_refunds.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -242,5 +245,12 @@ assert.match(paymentUpSql, /UNIQUE KEY uq_qipai_payment_callback_provider/);
assert.match(paymentUpSql, /credential_ref VARCHAR/);
assert.match(paymentUpSql, /scope_key VARCHAR/);
assert.doesNotMatch(paymentUpSql, /credential_secret|private_key|api_secret/i);
assert.match(wechatRefundUpSql, /CREATE TABLE IF NOT EXISTS qipai_reconciliation_runs/);
assert.match(wechatRefundDownSql, /DROP TABLE IF EXISTS qipai_reconciliation_runs/);
assert.match(wechatRefundVerifySql, /'qipai_reconciliation_runs'/);
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_client_request/);
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_callback/);
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_reconciliation_request/);
assert.doesNotMatch(wechatRefundUpSql, /private_key|api_v3_key|certificate_pem/i);
console.log('PASS: M01-B through M05-A migration contracts are present.');
console.log('PASS: M01-B through M05-B migration contracts are present.');
+2 -1
View File
@@ -26,7 +26,8 @@ assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/);
assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/);
assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/);
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql$/);
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -60,6 +60,7 @@ const expectedTables = [
'qipai_permissions',
'qipai_platform_apps',
'qipai_profit_shares',
'qipai_reconciliation_runs',
'qipai_refunds',
'qipai_role_permissions',
'qipai_roles',
@@ -101,12 +102,12 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015']
'2026062015', '2026062216']
);
return rows;
}
@@ -1253,7 +1254,8 @@ try {
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1274,7 +1276,7 @@ try {
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M05-A tables.');
console.log('PASS: down removed all M01-B through M05-B tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1294,7 +1296,8 @@ try {
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1374,7 +1377,9 @@ try {
'idempotent payment creation',
'mismatched callback retained without accounting',
'duplicate success callback does not double account',
'test adapter explicit non-production gate'
'test adapter explicit non-production gate',
'Wechat refund idempotency and callback indexes',
'Wechat reconciliation request history'
]
}, null, 2));
} finally {
+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');
}