feat(M05-A): 建立统一支付领域与幂等回调
This commit is contained in:
@@ -51,6 +51,9 @@ const adjustmentVerifySql = read('database/migrations/2026062013_m04c_order_adju
|
||||
const shareUpSql = read('database/migrations/2026062014_m04d_order_shares.up.sql');
|
||||
const shareDownSql = read('database/migrations/2026062014_m04d_order_shares.down.sql');
|
||||
const shareVerifySql = read('database/migrations/2026062014_m04d_order_shares.verify.sql');
|
||||
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 coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -226,5 +229,18 @@ assert.match(shareUpSql, /token_hash CHAR\(64\)/);
|
||||
assert.match(shareUpSql, /allow_open_door TINYINT/);
|
||||
assert.match(shareUpSql, /allow_renew TINYINT/);
|
||||
assert.match(shareUpSql, /UNIQUE KEY uq_qipai_order_share_token_hash/);
|
||||
for (const table of [
|
||||
'qipai_payment_attempts', 'qipai_payment_callbacks', 'qipai_refunds',
|
||||
'qipai_profit_shares', 'qipai_payment_configs'
|
||||
]) {
|
||||
assert.match(paymentUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(paymentDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(paymentVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(paymentUpSql, /client_request_id VARCHAR/);
|
||||
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);
|
||||
|
||||
console.log('PASS: M01-B through M04-D migration contracts are present.');
|
||||
console.log('PASS: M01-B through M05-A migration contracts are present.');
|
||||
|
||||
@@ -25,7 +25,8 @@ assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/);
|
||||
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, /2026062014_m04d_order_shares\.up\.sql/);
|
||||
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
QIPAI_JWT_SECRET: 'production-test-secret-that-is-long-enough',
|
||||
QIPAI_TEST_PAYMENT_ENABLED: 'true'
|
||||
}).payment.testAdapterEnabled, false);
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
QIPAI_TEST_PAYMENT_ENABLED: 'true'
|
||||
}).payment.testAdapterEnabled, true);
|
||||
|
||||
const secret = 'test-only-payment-jwt-secret-32-chars';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let createInput;
|
||||
let callbackInput;
|
||||
const authRepository = {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
const app = await buildApp({
|
||||
payment: {
|
||||
jwtSecret: secret,
|
||||
authRepository,
|
||||
testAdapterEnabled: true,
|
||||
repository: {
|
||||
async createPayment(input) {
|
||||
createInput = input;
|
||||
return {
|
||||
paymentId: '51', orderId: input.orderId, provider: input.provider,
|
||||
status: 'PENDING', amountCents: 3600
|
||||
};
|
||||
},
|
||||
async processTestCallback(input) {
|
||||
callbackInput = input;
|
||||
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rejectedAmount = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/payments',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
orderId: '31', provider: 'TEST', clientRequestId: 'request-0001',
|
||||
amountCents: 1
|
||||
}
|
||||
});
|
||||
assert.equal(rejectedAmount.statusCode, 400);
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/payments',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { orderId: '31', provider: 'TEST', clientRequestId: 'request-0001' }
|
||||
});
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(createInput.platformAppId, '9');
|
||||
assert.equal('amountCents' in createInput, false);
|
||||
|
||||
const completed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/payments/51/test-complete',
|
||||
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm05a-test-callback' },
|
||||
payload: { callbackId: 'callback-0001', amountCents: 3600 }
|
||||
});
|
||||
assert.equal(completed.statusCode, 200);
|
||||
assert.equal(callbackInput.traceId, 'm05a-test-callback');
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M05-A payment routes trust server amounts and gate the test adapter.');
|
||||
Reference in New Issue
Block a user