feat(M05-C): 完成团购验券与第三方直订
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user