feat(M05-B): 完成微信支付退款与对账基础
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user