feat(M05-A): 建立统一支付领域与幂等回调

This commit is contained in:
Codex
2026-06-20 14:30:35 +08:00
parent f9ec0af2ee
commit 6c506ff862
19 changed files with 905 additions and 14 deletions
@@ -27,6 +27,9 @@ import {
import {
OrderShareError, OrderShareRepository
} from '../dist/orders/order-share-repository.js';
import {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -50,9 +53,14 @@ const expectedTables = [
'qipai_order_user_access',
'qipai_orders',
'qipai_outbox_events',
'qipai_payment_attempts',
'qipai_payment_callbacks',
'qipai_payment_configs',
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_profit_shares',
'qipai_refunds',
'qipai_role_permissions',
'qipai_roles',
'qipai_room_categories',
@@ -93,11 +101,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']
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015']
);
return rows;
}
@@ -1021,6 +1030,127 @@ async function assertOrderShares(pool, context) {
);
}
async function assertPaymentDomain(pool, context) {
const [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [roomRows] = await pool.query(
`SELECT store_id AS storeId, id AS roomId FROM qipai_rooms
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
[context.tenantId]
);
const customerId = String(customerRows[0].id);
const storeId = String(roomRows[0].storeId);
const roomId = String(roomRows[0].roomId);
const startAt = new Date(Date.now() + 25 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await new PricingRepository(pool).reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
});
await pool.query(
`INSERT INTO qipai_payment_configs
(tenant_id, platform_app_id, store_id, provider, scope_key, credential_ref, settings)
VALUES
(NULL, ?, NULL, 'WECHAT', ?, 'env:WX_PLATFORM', JSON_OBJECT('level', 'app')),
(?, ?, NULL, 'WECHAT', ?, 'env:WX_TENANT', JSON_OBJECT('level', 'tenant')),
(?, ?, ?, 'WECHAT', ?, 'env:WX_STORE', JSON_OBJECT('level', 'store'))`,
[context.platformAppId, `app:${context.platformAppId}`,
context.tenantId, context.platformAppId,
`tenant:${context.tenantId}:app:${context.platformAppId}`,
context.tenantId, context.platformAppId, storeId,
`tenant:${context.tenantId}:app:${context.platformAppId}:store:${storeId}`]
);
const repository = new PaymentRepository(pool);
const resolved = await repository.resolveConfig(
pool, context.tenantId, context.platformAppId, storeId, 'WECHAT'
);
assert.equal(resolved.credentialRef, 'env:WX_STORE');
assert.equal(resolved.settings.level, 'store');
const created = await repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-request-1', testAdapterEnabled: true
});
assert.equal(created.amountCents, order.quote.totalCents);
const duplicateCreate = await repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-request-1', testAdapterEnabled: true
});
assert.equal(duplicateCreate.paymentId, created.paymentId);
assert.equal(duplicateCreate.idempotent, true);
await assert.rejects(
() => repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-disabled', testAdapterEnabled: false
}),
(error) => error instanceof PaymentError && error.code === 'TEST_PAYMENT_DISABLED'
);
const rejected = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-wrong-amount', amountCents: created.amountCents - 1,
testAdapterEnabled: true, traceId: 'm05a-wrong-amount'
});
assert.equal(rejected.status, 'REJECTED');
const [afterRejected] = await pool.query(
`SELECT o.status, o.paid_amount_cents AS paidAmountCents,
c.processing_status AS callbackStatus
FROM qipai_orders o
INNER JOIN qipai_payment_callbacks c ON c.payment_id = ?
WHERE o.id = ? AND c.callback_id = 'm05a-callback-wrong-amount'`,
[created.paymentId, order.orderId]
);
assert.equal(afterRejected[0].status, 'PENDING_PAYMENT');
assert.equal(afterRejected[0].paidAmountCents, 0);
assert.equal(afterRejected[0].callbackStatus, 'REJECTED');
const succeeded = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-success', amountCents: created.amountCents,
testAdapterEnabled: true, traceId: 'm05a-payment-success'
});
assert.equal(succeeded.status, 'SUCCEEDED');
const duplicateCallback = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-success', amountCents: created.amountCents,
testAdapterEnabled: true, traceId: 'm05a-payment-success-duplicate'
});
assert.equal(duplicateCallback.idempotent, true);
const [paidRows] = await pool.query(
`SELECT o.status, o.paid_amount_cents AS paidAmountCents,
p.status AS paymentStatus,
(SELECT COUNT(*) FROM qipai_order_status_history h
WHERE h.order_id = o.id AND h.to_status = 'PAID') AS paidHistoryCount,
(SELECT COUNT(*) FROM qipai_payment_attempts a
WHERE a.payment_id = p.id) AS attemptCount
FROM qipai_orders o
INNER JOIN qipai_payments p ON p.order_id = o.id
WHERE o.id = ? AND p.id = ?`,
[order.orderId, created.paymentId]
);
assert.equal(paidRows[0].status, 'PAID');
assert.equal(paidRows[0].paidAmountCents, created.amountCents);
assert.equal(paidRows[0].paymentStatus, 'SUCCEEDED');
assert.equal(Number(paidRows[0].paidHistoryCount), 1);
assert.equal(Number(paidRows[0].attemptCount), 1);
const [configRows] = await pool.query(
`SELECT credential_ref AS credentialRef, CAST(settings AS CHAR) AS settings
FROM qipai_payment_configs WHERE tenant_id = ?`,
[context.tenantId]
);
assert.equal(configRows.every((row) => row.credentialRef.startsWith('env:')), true);
assert.equal(configRows.some((row) => /secret|private.key/i.test(row.settings)), false);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -1122,7 +1252,8 @@ try {
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' }
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1136,13 +1267,14 @@ try {
await assertOrderStateMachine(pool, loginContext);
await assertOrderAdjustments(pool, loginContext);
await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M04-D tables.');
console.log('PASS: down removed all M01-B through M05-A tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1161,7 +1293,8 @@ try {
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' }
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1235,7 +1368,13 @@ try {
'renew permission denied by default',
'explicit renew permission without room disclosure',
'share revocation and expiry',
'terminal order invalidates share'
'terminal order invalidates share',
'payment config store precedence',
'server-derived payment amount',
'idempotent payment creation',
'mismatched callback retained without accounting',
'duplicate success callback does not double account',
'test adapter explicit non-production gate'
]
}, null, 2));
} finally {