feat(M05-C): 完成团购验券与第三方直订

This commit is contained in:
Codex
2026-06-22 11:28:50 +08:00
parent 3ec74bb751
commit cda640baa0
17 changed files with 1654 additions and 13 deletions
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -30,6 +31,8 @@ import {
import {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -42,6 +45,9 @@ const expectedTables = [
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_devices',
'qipai_direct_bookings',
'qipai_group_redemptions',
'qipai_group_vouchers',
'qipai_holiday_calendar',
'qipai_legacy_table_mappings',
'qipai_media_assets',
@@ -77,6 +83,8 @@ const expectedTables = [
'qipai_tenant_apps',
'qipai_tenant_configs',
'qipai_tenants',
'qipai_third_party_configs',
'qipai_third_party_mappings',
'qipai_user_admin_profiles',
'qipai_user_identities',
'qipai_user_roles',
@@ -102,12 +110,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', '2026062216']
'2026062015', '2026062216', '2026062217']
);
return rows;
}
@@ -1152,6 +1160,186 @@ async function assertPaymentDomain(pool, context) {
assert.equal(configRows.some((row) => /secret|private.key/i.test(row.settings)), false);
}
async function assertThirdPartyDomain(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);
await pool.query(
`INSERT INTO qipai_third_party_configs
(tenant_id, store_id, provider, mode, credential_ref, settings)
VALUES (?, NULL, 'MEITUAN', 'MOCK', 'env:TP_TEST', JSON_OBJECT())`,
[context.tenantId]
);
const pricing = new PricingRepository(pool);
const service = new ThirdPartyService(
pool,
pricing,
new ThirdPartyClient({
async request() {
throw new Error('Live M05-C test must not call an external provider.');
}
}),
new Map([['TP_TEST', { webhookSecret: 'm05c-webhook-secret' }]])
);
const startAt = new Date(Date.now() + 27 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await pricing.reserve({
tenantId: context.tenantId,
userId: customerId,
roomId,
startAt,
endAt,
pricingMode: 'HOURLY'
});
const redeemed = await service.redeemVoucher({
tenantId: context.tenantId,
userId: customerId,
provider: 'MEITUAN',
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
orderId: order.orderId,
clientRequestId: 'm05c-redeem-request-001'
});
assert.equal(redeemed.status, 'SUCCEEDED');
const duplicate = await service.redeemVoucher({
tenantId: context.tenantId,
userId: customerId,
provider: 'MEITUAN',
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
orderId: order.orderId,
clientRequestId: 'm05c-redeem-request-001'
});
assert.equal(duplicate.idempotent, true);
const [voucherRows] = await pool.query(
`SELECT voucher_hash AS voucherHash, voucher_masked AS voucherMasked,
(SELECT COUNT(*) FROM qipai_group_redemptions r
WHERE r.voucher_id = v.id) AS redemptionCount
FROM qipai_group_vouchers v WHERE tenant_id = ?`,
[context.tenantId]
);
assert.match(voucherRows[0].voucherHash, /^[a-f0-9]{64}$/);
assert.equal(voucherRows[0].voucherMasked.includes('SENSITIVE'), false);
assert.equal(Number(voucherRows[0].redemptionCount), 1);
await pool.query(
`INSERT INTO qipai_third_party_mappings
(tenant_id, provider, resource_type, external_ref, local_resource_id)
VALUES (?, 'MEITUAN', 'STORE', 'external-store-001', ?),
(?, 'MEITUAN', 'ROOM', 'external-room-001', ?)`,
[context.tenantId, storeId, context.tenantId, roomId]
);
const bookingStart = new Date(Date.now() + 29 * 86400000);
bookingStart.setUTCHours(2, 0, 0, 0);
const bookingEnd = new Date(bookingStart.getTime() + 2 * 3600000);
const quote = await pricing.quote({
tenantId: context.tenantId,
roomId,
startAt: bookingStart,
endAt: bookingEnd,
pricingMode: 'HOURLY'
});
const payload = {
eventId: 'm05c-booking-event-001',
externalBookingNo: 'm05c-booking-001',
externalStoreRef: 'external-store-001',
externalRoomRef: 'external-room-001',
customerRef: 'private-customer-ref',
startsAt: bookingStart.toISOString(),
endsAt: bookingEnd.toISOString(),
amountCents: quote.totalCents
};
const rawBody = JSON.stringify(payload);
const signature = createHmac('sha256', 'm05c-webhook-secret')
.update(rawBody).digest('hex');
const received = await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature,
rawBody,
eventId: payload.eventId,
externalBookingNo: payload.externalBookingNo,
externalStoreRef: payload.externalStoreRef,
externalRoomRef: payload.externalRoomRef,
customerRef: payload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: payload.amountCents,
payload
});
assert.equal(received.status, 'READY_TO_CLAIM');
assert.equal((await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature,
rawBody,
eventId: payload.eventId,
externalBookingNo: payload.externalBookingNo,
externalStoreRef: payload.externalStoreRef,
externalRoomRef: payload.externalRoomRef,
customerRef: payload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: payload.amountCents,
payload
})).idempotent, true);
const claimed = await service.claimDirectBooking({
tenantId: context.tenantId,
userId: customerId,
bookingId: received.bookingId
});
const [claimedRows] = await pool.query(
`SELECT b.status, b.customer_ref_hash AS customerRefHash,
o.status AS orderStatus, o.paid_amount_cents AS paidAmountCents,
o.total_amount_cents AS totalAmountCents
FROM qipai_direct_bookings b
INNER JOIN qipai_orders o ON o.id = b.order_id AND o.tenant_id = b.tenant_id
WHERE b.id = ?`,
[received.bookingId]
);
assert.equal(claimedRows[0].status, 'CLAIMED');
assert.match(claimedRows[0].customerRefHash, /^[a-f0-9]{64}$/);
assert.equal(claimedRows[0].orderStatus, 'PAID');
assert.equal(claimedRows[0].paidAmountCents, claimedRows[0].totalAmountCents);
assert.match(claimed.orderId, /^[1-9]\d*$/);
const unmappedPayload = {
...payload,
eventId: 'm05c-booking-event-unmapped',
externalBookingNo: 'm05c-booking-unmapped',
externalRoomRef: 'missing-room'
};
const unmappedBody = JSON.stringify(unmappedPayload);
const unmapped = await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature: createHmac('sha256', 'm05c-webhook-secret')
.update(unmappedBody).digest('hex'),
rawBody: unmappedBody,
eventId: unmappedPayload.eventId,
externalBookingNo: unmappedPayload.externalBookingNo,
externalStoreRef: unmappedPayload.externalStoreRef,
externalRoomRef: unmappedPayload.externalRoomRef,
customerRef: unmappedPayload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: unmappedPayload.amountCents,
payload: unmappedPayload
});
assert.equal(unmapped.status, 'PENDING_MAPPING');
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -1255,7 +1443,8 @@ try {
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1270,13 +1459,14 @@ try {
await assertOrderAdjustments(pool, loginContext);
await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext);
await assertThirdPartyDomain(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 M05-B tables.');
console.log('PASS: down removed all M01-B through M05-C tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1297,7 +1487,8 @@ try {
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1380,6 +1571,11 @@ try {
'test adapter explicit non-production gate',
'Wechat refund idempotency and callback indexes',
'Wechat reconciliation request history'
,
'group voucher hash-only storage and single redemption',
'third-party booking webhook idempotency',
'mapped booking claim creates a paid order',
'unmapped booking enters manual queue'
]
}, null, 2));
} finally {