feat: add payment provider webhooks

This commit is contained in:
Codex
2026-06-28 22:42:27 +08:00
parent dee82e670f
commit db65295ddc
11 changed files with 1286 additions and 39 deletions

View File

@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import http from 'node:http';
import net from 'node:net';
@@ -32,12 +33,31 @@ const ids = {
scorelineSchool: '00000000-0000-0000-0000-000000000831',
};
const paymentFixture = (() => {
const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const wechatPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const alipayApp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const alipayPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
return {
wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
wechatMerchantPublicKey: wechatMerchant.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
wechatPlatformPrivateKey: wechatPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
wechatPlatformPublicKey: wechatPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
alipayAppPrivateKey: alipayApp.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
alipayAppPublicKey: alipayApp.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
alipayPlatformPrivateKey: alipayPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
alipayPlatformPublicKey: alipayPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
wechatApiV3Key: '12345678901234567890123456789012',
};
})();
let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787';
let serverProcess = null;
let serverLogs = '';
let legacyDisabledServer = null;
let legacyDisabledServerLogs = '';
let fakeWechatServer = null;
let fakeWechatPayServer = null;
function buildUrl(path, query = {}) {
return buildUrlAt(apiBase, path, query);
@@ -252,6 +272,76 @@ async function startFakeWechatServer() {
};
}
async function startFakeWechatPayServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
fakeWechatPayServer = http.createServer((req, res) => {
const url = new URL(req.url || '/', baseUrl);
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
const body = raw ? JSON.parse(raw) : {};
requests.push({
method: req.method,
pathname: url.pathname,
headers: req.headers,
body,
});
if (url.pathname !== '/v3/pay/transactions/jsapi') {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ prepay_id: `prepay-${body.out_trade_no || 'unknown'}` }));
});
});
await new Promise((resolve, reject) => {
fakeWechatPayServer.once('error', reject);
fakeWechatPayServer.listen(port, '127.0.0.1', resolve);
});
return {
endpoint: `${baseUrl}/v3/pay/transactions/jsapi`,
requests,
};
}
function encryptWechatResource(plain) {
const nonce = crypto.randomBytes(12).toString('base64url');
const aad = 'transaction';
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(paymentFixture.wechatApiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
cipher.setAAD(Buffer.from(aad, 'utf8'));
const encrypted = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
algorithm: 'AEAD_AES_256_GCM',
nonce,
associated_data: aad,
ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'),
};
}
function signWechatNotification(rawBody, timestamp, nonce) {
const message = `${timestamp}\n${nonce}\n${rawBody}\n`;
return crypto.createSign('RSA-SHA256').update(message).sign(paymentFixture.wechatPlatformPrivateKey, 'base64');
}
function signAlipayParams(params) {
const canonical = Object.keys(params)
.filter(key => !['sign', 'sign_type'].includes(key) && params[key] !== undefined && params[key] !== null && params[key] !== '')
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64');
}
async function waitForProcessExit(child, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -422,10 +512,14 @@ function stopServer() {
fakeWechatServer.close();
fakeWechatServer = null;
}
if (fakeWechatPayServer) {
fakeWechatPayServer.close();
fakeWechatPayServer = null;
}
}
async function testCatalogAndLearning() {
const questions = await request('/api/catalog/questions', { query: { limit: 20 } });
const questions = await request('/api/catalog/questions', { query: { limit: 500 } });
const question = questions.items?.find(item => item.id === ids.question);
assert.ok(question, 'main tenant should return smoke question');
assert.equal(question.hasVideoExplanation, true, 'smoke question should expose video marker');
@@ -597,6 +691,182 @@ async function testCommerce() {
assert.ok(Array.isArray(entitlements.items), 'entitlements should return a list');
assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary');
assert.equal(entitlements.summary.isSvip, true, 'redeemed activation code should make smoke user SVIP');
const fakeWechatPay = await startFakeWechatPayServer();
const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'wechat_pay',
mode: 'tenant_collect',
displayName: '集成测试微信支付',
status: 'active',
configPublic: {
appId: 'wx-pay-smoke-appid',
merchantId: '1900000001',
merchantSerialNo: 'serial-smoke',
notifyUrl: 'https://pay.example.test/wechat/notify',
endpoint: fakeWechatPay.endpoint,
wechatpayPublicKey: paymentFixture.wechatPlatformPublicKey,
},
secret: {
secretJson: {
privateKey: paymentFixture.wechatMerchantPrivateKey,
apiV3Key: paymentFixture.wechatApiV3Key,
},
},
},
});
assert.equal(wechatAccount.item?.provider, 'wechat_pay', 'tenant admin should configure active WeChat Pay account');
assert.ok(!JSON.stringify(wechatAccount).includes(paymentFixture.wechatApiV3Key), 'payment account response must not leak apiV3Key');
const wechatOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: '00000000-0000-0000-0000-000000000201',
payProvider: 'wechat_pay',
payMethod: 'jsapi',
regionId: ids.region,
},
});
assert.ok(wechatOrder.item?.orderNo, 'student should create a WeChat Pay order');
const wechatPayment = await request('/api/commerce/payments/create', {
method: 'POST',
body: {
orderNo: wechatOrder.item.orderNo,
provider: 'wechat_pay',
openId: 'openid-pay-smoke',
},
});
assert.equal(wechatPayment.item?.provider, 'wechat_pay', 'payment create should use WeChat Pay provider');
assert.ok(wechatPayment.item?.paymentParams?.paySign, 'WeChat Pay create should return signed JSAPI params');
assert.equal(fakeWechatPay.requests.at(-1)?.body?.out_trade_no, wechatOrder.item.orderNo, 'WeChat Pay provider should call transaction endpoint with order number');
const wechatNotificationBody = {
id: `evt-${wechatOrder.item.orderNo}`,
create_time: '2026-06-28T00:00:00+08:00',
event_type: 'TRANSACTION.SUCCESS',
resource_type: 'encrypt-resource',
resource: encryptWechatResource({
appid: 'wx-pay-smoke-appid',
mchid: '1900000001',
out_trade_no: wechatOrder.item.orderNo,
transaction_id: `wx-trade-${wechatOrder.item.orderNo}`,
trade_state: 'SUCCESS',
success_time: '2026-06-28T00:00:00+08:00',
amount: { total: wechatOrder.item.amountCents, currency: 'CNY' },
}),
};
const wechatRaw = JSON.stringify(wechatNotificationBody);
const wechatTimestamp = String(Math.floor(Date.now() / 1000));
const wechatNonce = 'nonce-pay-smoke';
const wechatNotify = await request('/api/commerce/payments/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatTimestamp,
'wechatpay-nonce': wechatNonce,
'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatNotificationBody,
});
assert.equal(wechatNotify.item?.status, 'paid', 'WeChat Pay notify should mark order paid');
assert.ok(wechatNotify.item?.entitlement?.id, 'WeChat Pay notify should grant entitlement');
const wechatNotifyAgain = await request('/api/commerce/payments/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatTimestamp,
'wechatpay-nonce': wechatNonce,
'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatNotificationBody,
});
assert.equal(wechatNotifyAgain.item?.idempotent, true, 'duplicate WeChat Pay notify should be idempotent');
const alipayAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'alipay',
mode: 'tenant_collect',
displayName: '集成测试支付宝',
status: 'active',
configPublic: {
appId: 'alipay-smoke-appid',
notifyUrl: 'https://pay.example.test/alipay/notify',
returnUrl: 'https://app.example.test/pay/success',
},
secret: {
secretJson: {
privateKey: paymentFixture.alipayAppPrivateKey,
alipayPublicKey: paymentFixture.alipayPlatformPublicKey,
},
},
},
});
assert.equal(alipayAccount.item?.provider, 'alipay', 'tenant admin should configure active Alipay account');
assert.ok(!JSON.stringify(alipayAccount).includes('PRIVATE KEY'), 'Alipay account response must not leak private key');
const alipayOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: '00000000-0000-0000-0000-000000000201',
payProvider: 'alipay',
payMethod: 'wap',
regionId: ids.region,
},
});
const alipayPayment = await request('/api/commerce/payments/create', {
method: 'POST',
body: {
orderNo: alipayOrder.item.orderNo,
provider: 'alipay',
},
});
assert.equal(alipayPayment.item?.provider, 'alipay', 'payment create should use Alipay provider');
assert.ok(alipayPayment.item?.paymentParams?.url?.includes('alipay.trade.wap.pay'), 'Alipay create should return WAP URL');
const alipayNotifyBody = {
notify_id: `alipay-notify-${alipayOrder.item.orderNo}`,
notify_time: '2026-06-28 00:00:00',
app_id: 'alipay-smoke-appid',
trade_no: `ali-trade-${alipayOrder.item.orderNo}`,
out_trade_no: alipayOrder.item.orderNo,
trade_status: 'TRADE_SUCCESS',
total_amount: (alipayOrder.item.amountCents / 100).toFixed(2),
receipt_amount: (alipayOrder.item.amountCents / 100).toFixed(2),
charset: 'utf-8',
version: '1.0',
};
alipayNotifyBody.sign_type = 'RSA2';
alipayNotifyBody.sign = signAlipayParams(alipayNotifyBody);
const alipayNotify = await request('/api/commerce/payments/notify/alipay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
body: alipayNotifyBody,
});
assert.equal(alipayNotify.item?.status, 'paid', 'Alipay notify should mark order paid');
assert.ok(alipayNotify.item?.entitlement?.id, 'Alipay notify should grant entitlement');
const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
body: {
...alipayNotifyBody,
total_amount: '0.01',
},
expectStatus: 400,
});
assert.equal(tamperedAlipayNotify.code, 'PAYMENT_SIGNATURE_INVALID', 'tampered Alipay notify must fail signature verification');
}
async function testTenantIsolation() {