feat(M05-A): 建立统一支付领域与幂等回调
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user