diff --git a/backend/.env.example b/backend/.env.example index 08868d7..653de07 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,7 @@ QIPAI_SESSION_TTL_SECONDS=604800 QIPAI_WECHAT_APP_SECRETS={} QIPAI_TEST_PAYMENT_ENABLED=false QIPAI_WECHAT_PAY_CREDENTIALS={} +QIPAI_THIRD_PARTY_CREDENTIALS={} QIPAI_MQTT_URL=mqtt://101.42.38.246:1883 QIPAI_MQTT_USERNAME= QIPAI_MQTT_PASSWORD= diff --git a/backend/package.json b/backend/package.json index 69274ad..69cf0da 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 51cd465..9f20d97 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -38,6 +38,9 @@ import { } from './routes/order-management.js'; import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js'; import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js'; +import { + registerThirdPartyRoutes, type ThirdPartyRouteOptions +} from './routes/third-party.js'; export interface BuildAppOptions { config?: AppConfig; @@ -53,6 +56,7 @@ export interface BuildAppOptions { orderManagement?: OrderManagementRouteOptions; orderShare?: OrderShareRouteOptions; payment?: PaymentRouteOptions; + thirdParty?: ThirdPartyRouteOptions; } declare module 'fastify' { @@ -133,6 +137,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise 0, diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 52c3070..2466f35 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -36,7 +36,8 @@ const migrationFiles: Record = { 'database/migrations/2026062013_m04c_order_adjustments.up.sql', 'database/migrations/2026062014_m04d_order_shares.up.sql', 'database/migrations/2026062015_m05a_payment_domain.up.sql', - 'database/migrations/2026062216_m05b_wechat_refunds.up.sql' + 'database/migrations/2026062216_m05b_wechat_refunds.up.sql', + 'database/migrations/2026062217_m05c_third_party.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -54,9 +55,11 @@ const migrationFiles: Record = { 'database/migrations/2026062013_m04c_order_adjustments.verify.sql', 'database/migrations/2026062014_m04d_order_shares.verify.sql', 'database/migrations/2026062015_m05a_payment_domain.verify.sql', - 'database/migrations/2026062216_m05b_wechat_refunds.verify.sql' + 'database/migrations/2026062216_m05b_wechat_refunds.verify.sql', + 'database/migrations/2026062217_m05c_third_party.verify.sql' ], down: [ + 'database/migrations/2026062217_m05c_third_party.down.sql', 'database/migrations/2026062216_m05b_wechat_refunds.down.sql', 'database/migrations/2026062015_m05a_payment_domain.down.sql', 'database/migrations/2026062014_m04d_order_shares.down.sql', @@ -204,7 +207,8 @@ export async function executeMigrationPlan( 2, 2, 1, 2, 1, 1, 8, 3, 1, 5, 8, 4, 1, - 1, 5, 3, 1 + 1, 5, 3, 1, + 5, 6, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/routes/third-party.ts b/backend/src/routes/third-party.ts new file mode 100644 index 0000000..b71d5c5 --- /dev/null +++ b/backend/src/routes/third-party.ts @@ -0,0 +1,244 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import type { AuthRepository } from '../auth/auth-repository.js'; +import { authenticateAccessToken } from '../auth/authenticate.js'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import { + ThirdPartyError, type ThirdPartyProvider +} from '../third-party/third-party-client.js'; +import type { ThirdPartyService } from '../third-party/third-party-service.js'; + +const providerSchema = z.enum(['MEITUAN', 'DIANPING', 'DOUYIN', 'KUAISHOU']); +const redeemSchema = z.object({ + provider: providerSchema, + voucherCode: z.string().min(4).max(128), + orderId: z.string().regex(/^[1-9]\d{0,19}$/), + clientRequestId: z.string().min(8).max(128) +}).strict(); +const manualSchema = redeemSchema.extend({ + amountCents: z.number().int().positive(), + note: z.string().min(1).max(512) +}).strict(); +const notifyParams = z.object({ + tenantId: z.string().regex(/^[1-9]\d{0,19}$/), + provider: providerSchema +}); +const bookingSchema = z.object({ + eventId: z.string().min(4).max(128), + externalBookingNo: z.string().min(4).max(128), + externalStoreRef: z.string().min(1).max(128), + externalRoomRef: z.string().min(1).max(128), + customerRef: z.string().max(255).default(''), + startsAt: z.coerce.date(), + endsAt: z.coerce.date(), + amountCents: z.number().int().nonnegative() +}).strict().refine((value) => value.endsAt > value.startsAt); +const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) }); +const recordsQuery = z.object({ + provider: providerSchema.optional(), + status: z.string().min(1).max(32).optional() +}); +const configSchema = z.object({ + provider: providerSchema, + storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null), + mode: z.enum(['MANUAL', 'MOCK', 'API']), + enabled: z.boolean().default(true), + credentialRef: z.string().max(255).default(''), + settings: z.record(z.unknown()).default({}) +}).strict(); +const mappingSchema = z.object({ + provider: providerSchema, + resourceType: z.enum(['STORE', 'ROOM']), + externalRef: z.string().min(1).max(128), + localResourceId: z.string().regex(/^[1-9]\d{0,19}$/) +}).strict(); + +export interface ThirdPartyRouteOptions { + service: ThirdPartyService; + authRepository: Pick; + accessControl: { + getAccessProfile(tenantId: string, userId: string): Promise; + }; + jwtSecret: string; +} + +export async function registerThirdPartyRoutes( + app: FastifyInstance, + options: ThirdPartyRouteOptions +) { + app.post('/app-api/group-vouchers/redeem', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, false); + const body = redeemSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.redeemVoucher({ + tenantId: auth.tenantId, + userId: auth.userId, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, true); + const body = manualSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.redeemVoucherManually({ + tenantId: auth.tenantId, + actorId: auth.userId, + access: auth.access!, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.post( + '/app-api/third-party/:provider/tenants/:tenantId/bookings/notify', + async (request, reply) => { + const params = notifyParams.safeParse(request.params); + const body = bookingSchema.safeParse(request.body); + const signature = request.headers['x-third-party-signature']; + if (!params.success || !body.success || typeof signature !== 'string') { + return invalid(reply, request.traceId); + } + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.receiveDirectBooking({ + tenantId: params.data.tenantId, + provider: params.data.provider, + signature, + rawBody: JSON.stringify(request.body), + payload: request.body as Record, + ...body.data + }), + traceId: request.traceId + })); + } + ); + + app.post('/app-api/third-party/bookings/:bookingId/claim', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, false); + const params = bookingParams.safeParse(request.params); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.claimDirectBooking({ + tenantId: auth.tenantId, + userId: auth.userId, + bookingId: params.data.bookingId + }), + traceId: request.traceId + })); + }); + + app.get('/admin-api/third-party/records', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, true); + const query = recordsQuery.safeParse(request.query); + if (!auth) return unauthorized(reply, request.traceId); + if (!query.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.listRecords({ + tenantId: auth.tenantId, + access: auth.access!, + provider: query.data.provider as ThirdPartyProvider | undefined, + status: query.data.status + }), + traceId: request.traceId + })); + }); + + app.put('/admin-api/third-party/config', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, true); + const body = configSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.saveConfig({ + tenantId: auth.tenantId, + actorId: auth.userId, + access: auth.access!, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.put('/admin-api/third-party/mappings', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options, true); + const body = mappingSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.saveMapping({ + tenantId: auth.tenantId, + access: auth.access!, + ...body.data + }), + traceId: request.traceId + })); + }); +} + +async function authenticate( + authorization: string | undefined, + options: ThirdPartyRouteOptions, + withAccess: boolean +) { + const result = await authenticateAccessToken( + authorization, options.authRepository, options.jwtSecret + ); + if (!result) return null; + const tenantId = result.session.tenantId; + const userId = result.session.user.id; + return { + tenantId, + userId, + access: withAccess + ? await options.accessControl.getAccessProfile(tenantId, userId) + : undefined + }; +} + +async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof ThirdPartyError)) throw error; + const status = error.code.includes('NOT_FOUND') ? 404 + : error.code.includes('CONFLICT') || error.code.includes('ALREADY') ? 409 + : error.code.includes('FORBIDDEN') ? 403 + : error.code.includes('SIGNATURE') ? 401 : 400; + return reply.status(status).send({ + code: error.code, + message: 'The third-party request is not available.', + traceId + }); + } +} + +function unauthorized(reply: FastifyReply, traceId: string) { + return reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', + message: 'Authentication required.', + traceId + }); +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_THIRD_PARTY_REQUEST', + message: 'The third-party request is invalid.', + traceId + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index c59aa01..159885f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -21,6 +21,10 @@ import { FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient } from './payments/wechat-pay-client.js'; import { WechatPaymentService } from './payments/wechat-payment-service.js'; +import { + FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient +} from './third-party/third-party-client.js'; +import { ThirdPartyService } from './third-party/third-party-service.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -29,6 +33,7 @@ const accessControl = new RbacRepository(pool); const orderManagementRepository = new OrderManagementRepository(pool); const paymentRepository = new PaymentRepository(pool); const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson); +const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson); const app = await buildApp({ config, platformConfigRepository: new PlatformConfigRepository(pool), @@ -106,6 +111,17 @@ const app = await buildApp({ accessControl, jwtSecret: config.auth.jwtSecret, testAdapterEnabled: config.payment.testAdapterEnabled + }, + thirdParty: { + service: new ThirdPartyService( + pool, + new PricingRepository(pool), + new ThirdPartyClient(new FetchThirdPartyTransport()), + thirdPartyCredentials + ), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/src/third-party/third-party-client.ts b/backend/src/third-party/third-party-client.ts new file mode 100644 index 0000000..15e2080 --- /dev/null +++ b/backend/src/third-party/third-party-client.ts @@ -0,0 +1,174 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU'; +export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API'; + +export interface ThirdPartyCredential { + webhookSecret?: string; + apiToken?: string; +} + +export interface ThirdPartyTransport { + request(input: { + url: string; + method: 'POST'; + headers: Record; + body: string; + }): Promise<{ status: number; body: string }>; +} + +export interface VoucherRedeemResult { + status: 'SUCCEEDED' | 'FAILED' | 'PENDING'; + amountCents: number; + externalProductId?: string; + failureCode?: string; + response?: Record; +} + +export class ThirdPartyError extends Error { + constructor(public readonly code: string, message = code) { + super(message); + } +} + +export class FetchThirdPartyTransport implements ThirdPartyTransport { + async request(input: { + url: string; + method: 'POST'; + headers: Record; + body: string; + }) { + const response = await fetch(input.url, { + method: input.method, + headers: input.headers, + body: input.body + }); + return { status: response.status, body: await response.text() }; + } +} + +export class ThirdPartyClient { + constructor(private readonly transport: ThirdPartyTransport) {} + + verifyWebhook(secret: string, rawBody: string, signature: string) { + const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); + const left = Buffer.from(expected, 'utf8'); + const right = Buffer.from(signature.toLowerCase(), 'utf8'); + if (left.length !== right.length || !timingSafeEqual(left, right)) { + throw new ThirdPartyError('THIRD_PARTY_SIGNATURE_INVALID'); + } + } + + async redeemVoucher(input: { + mode: ThirdPartyMode; + provider: ThirdPartyProvider; + voucherCode: string; + orderNo: string; + expectedAmountCents: number; + settings: Record; + credential?: ThirdPartyCredential; + }): Promise { + if (input.mode === 'MANUAL') { + throw new ThirdPartyError('THIRD_PARTY_MANUAL_ONLY'); + } + if (input.mode === 'MOCK') { + if (input.voucherCode.startsWith('FAIL-')) { + return { + status: 'FAILED', + amountCents: 0, + failureCode: 'MOCK_VOUCHER_REJECTED', + response: { adapter: 'mock', accepted: false } + }; + } + return { + status: 'SUCCEEDED', + amountCents: input.expectedAmountCents, + externalProductId: 'mock-product', + response: { adapter: 'mock', accepted: true } + }; + } + const endpoint = input.settings.redeemEndpoint; + if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) { + throw new ThirdPartyError('THIRD_PARTY_ENDPOINT_INVALID'); + } + if (!input.credential?.apiToken) { + throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_NOT_CONFIGURED'); + } + const response = await this.transport.request({ + url: endpoint, + method: 'POST', + headers: { + Authorization: `Bearer ${input.credential.apiToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'qipai-backend/0.1' + }, + body: JSON.stringify({ + voucherCode: input.voucherCode, + orderNo: input.orderNo + }) + }); + if (response.status < 200 || response.status >= 300) { + return { + status: 'PENDING', + amountCents: 0, + failureCode: `THIRD_PARTY_HTTP_${response.status}`, + response: { httpStatus: response.status } + }; + } + const payload = parseObject(response.body); + const accepted = payload.accepted === true; + const amountCents = Number(payload.amountCents); + return { + status: accepted ? 'SUCCEEDED' : 'FAILED', + amountCents: Number.isSafeInteger(amountCents) && amountCents >= 0 + ? amountCents : 0, + externalProductId: typeof payload.productId === 'string' ? payload.productId : '', + failureCode: accepted ? '' : stringValue(payload.failureCode, 'VOUCHER_REJECTED'), + response: sanitizeResponse(payload) + }; + } +} + +export function parseThirdPartyCredentials(value: string) { + const raw = parseObject(value); + const result = new Map(); + for (const [key, item] of Object.entries(raw)) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_INVALID'); + } + const credential = item as Record; + result.set(key, { + webhookSecret: optionalString(credential.webhookSecret), + apiToken: optionalString(credential.apiToken) + }); + } + return result; +} + +function parseObject(value: string) { + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('object required'); + } + return parsed as Record; + } catch { + throw new ThirdPartyError('THIRD_PARTY_JSON_INVALID'); + } +} + +function optionalString(value: unknown) { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function stringValue(value: unknown, fallback: string) { + return typeof value === 'string' && value.length > 0 ? value : fallback; +} + +function sanitizeResponse(value: Record) { + const copy = { ...value }; + delete copy.voucherCode; + delete copy.token; + delete copy.secret; + return copy; +} diff --git a/backend/src/third-party/third-party-service.ts b/backend/src/third-party/third-party-service.ts new file mode 100644 index 0000000..58e5816 --- /dev/null +++ b/backend/src/third-party/third-party-service.ts @@ -0,0 +1,676 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import type { MySqlPool } from '../db/mysql.js'; +import type { PricingRepository } from '../orders/pricing-repository.js'; +import { + ThirdPartyClient, ThirdPartyError, type ThirdPartyCredential, + type ThirdPartyMode, type ThirdPartyProvider +} from './third-party-client.js'; + +interface OrderRow extends RowDataPacket { + id: string; + orderNo: string; + storeId: string; + status: string; + totalAmountCents: number; + paidAmountCents: number; +} +interface ConfigRow extends RowDataPacket { + id: string; + mode: ThirdPartyMode; + credentialRef: string; + settings: string | Record; +} +interface RedemptionRow extends RowDataPacket { + id: string; + orderId: string; + status: string; + voucherMasked: string; +} +interface BookingRow extends RowDataPacket { + id: string; + tenantId: string; + provider: ThirdPartyProvider; + storeId: string | null; + roomId: string | null; + startsAt: Date; + endsAt: Date; + amountCents: number; + status: string; + orderId: string | null; +} + +export class ThirdPartyService { + constructor( + private readonly pool: MySqlPool, + private readonly pricing: PricingRepository, + private readonly client: ThirdPartyClient, + private readonly credentials: ReadonlyMap + ) {} + + async redeemVoucher(input: { + tenantId: string; + userId: string; + provider: ThirdPartyProvider; + voucherCode: string; + orderId: string; + clientRequestId: string; + }) { + const existing = await this.findRedemptionByRequest( + input.tenantId, input.clientRequestId, input.orderId + ); + if (existing) return { ...existing, idempotent: true }; + const order = await this.loadOwnedOrder(input.tenantId, input.userId, input.orderId); + const config = await this.resolveConfig(input.tenantId, order.storeId, input.provider); + const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents); + if (expectedAmountCents <= 0) throw new ThirdPartyError('PAYMENT_NOT_REQUIRED'); + const result = await this.client.redeemVoucher({ + mode: config.mode, + provider: input.provider, + voucherCode: input.voucherCode, + orderNo: order.orderNo, + expectedAmountCents, + settings: config.settings, + credential: this.resolveCredential(config.credentialRef) + }); + return this.recordRedemption({ + ...input, + actorId: input.userId, + mode: config.mode, + amountCents: result.amountCents, + expectedAmountCents, + externalProductId: result.externalProductId ?? '', + status: result.status, + failureCode: result.failureCode ?? '', + response: result.response ?? {} + }); + } + + async redeemVoucherManually(input: { + tenantId: string; + actorId: string; + access: AccessProfile; + provider: ThirdPartyProvider; + voucherCode: string; + orderId: string; + amountCents: number; + clientRequestId: string; + note: string; + }) { + const existing = await this.findRedemptionByRequest( + input.tenantId, input.clientRequestId, input.orderId + ); + if (existing) return { ...existing, idempotent: true }; + const order = await this.loadOrder(input.tenantId, input.orderId); + assertStoreAccess(input.access, order.storeId); + const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents); + if (input.amountCents !== expectedAmountCents) { + throw new ThirdPartyError('VOUCHER_AMOUNT_INVALID'); + } + return this.recordRedemption({ + ...input, + mode: 'MANUAL', + expectedAmountCents, + externalProductId: '', + status: 'SUCCEEDED', + failureCode: '', + response: { manual: true, note: input.note.slice(0, 200) } + }); + } + + async receiveDirectBooking(input: { + tenantId: string; + provider: ThirdPartyProvider; + signature: string; + rawBody: string; + eventId: string; + externalBookingNo: string; + externalStoreRef: string; + externalRoomRef: string; + customerRef: string; + startsAt: Date; + endsAt: Date; + amountCents: number; + payload: Record; + }) { + const config = await this.resolveConfig(input.tenantId, null, input.provider); + const credential = this.resolveCredential(config.credentialRef); + if (!credential?.webhookSecret) { + throw new ThirdPartyError('THIRD_PARTY_WEBHOOK_NOT_CONFIGURED'); + } + this.client.verifyWebhook(credential.webhookSecret, input.rawBody, input.signature); + const [existing] = await this.pool.execute( + `SELECT id, tenant_id AS tenantId, provider, store_id AS storeId, + room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt, + amount_cents AS amountCents, status, order_id AS orderId + FROM qipai_direct_bookings + WHERE tenant_id = ? AND provider = ? AND event_id = ? LIMIT 1`, + [input.tenantId, input.provider, input.eventId] + ); + if (existing[0]) return { ...normalizeBooking(existing[0]), idempotent: true }; + const mapping = await this.resolveMappings( + input.tenantId, input.provider, input.externalStoreRef, input.externalRoomRef + ); + const status = mapping.storeId && mapping.roomId ? 'READY_TO_CLAIM' : 'PENDING_MAPPING'; + const [result] = await this.pool.execute( + `INSERT INTO qipai_direct_bookings + (tenant_id, provider, event_id, external_booking_no, + external_store_ref, external_room_ref, store_id, room_id, + customer_ref_hash, starts_at, ends_at, amount_cents, status, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`, + [input.tenantId, input.provider, input.eventId, input.externalBookingNo, + input.externalStoreRef, input.externalRoomRef, mapping.storeId, mapping.roomId, + hashValue(input.customerRef), input.startsAt, input.endsAt, input.amountCents, + status, JSON.stringify(sanitizePayload(input.payload))] + ); + return { + bookingId: String(result.insertId), + status, + mapped: status === 'READY_TO_CLAIM', + idempotent: false + }; + } + + async claimDirectBooking(input: { + tenantId: string; + userId: string; + bookingId: string; + }) { + const [rows] = await this.pool.execute( + `SELECT id, tenant_id AS tenantId, provider, store_id AS storeId, + room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt, + amount_cents AS amountCents, status, order_id AS orderId + FROM qipai_direct_bookings + WHERE tenant_id = ? AND id = ? LIMIT 1`, + [input.tenantId, input.bookingId] + ); + const booking = rows[0]; + if (!booking) throw new ThirdPartyError('DIRECT_BOOKING_NOT_FOUND'); + if (booking.status === 'CLAIMED') { + return { bookingId: input.bookingId, orderId: String(booking.orderId), idempotent: true }; + } + if (booking.status !== 'READY_TO_CLAIM' || !booking.roomId) { + throw new ThirdPartyError('DIRECT_BOOKING_NOT_READY'); + } + const quote = await this.pricing.quote({ + tenantId: input.tenantId, + roomId: String(booking.roomId), + startAt: booking.startsAt, + endAt: booking.endsAt, + pricingMode: 'HOURLY' + }); + if (Number(booking.amountCents) > quote.totalCents) { + throw new ThirdPartyError('DIRECT_BOOKING_AMOUNT_MISMATCH'); + } + const order = await this.pricing.reserve({ + tenantId: input.tenantId, + userId: input.userId, + roomId: String(booking.roomId), + startAt: booking.startsAt, + endAt: booking.endsAt, + pricingMode: 'HOURLY', + adjustment: { discountCents: quote.totalCents - Number(booking.amountCents) } + }); + try { + await this.transaction(async (connection) => { + const [claim] = await connection.execute( + `UPDATE qipai_direct_bookings + SET status = 'CLAIMED', order_id = ?, claimed_user_id = ?, + claimed_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ? AND status = 'READY_TO_CLAIM'`, + [order.orderId, input.userId, input.tenantId, input.bookingId] + ); + if (claim.affectedRows !== 1) throw new ThirdPartyError('DIRECT_BOOKING_CLAIM_CONFLICT'); + await this.insertSuccessfulPayment(connection, { + tenantId: input.tenantId, + orderId: order.orderId, + storeId: order.storeId, + amountCents: order.quote.totalCents, + providerReference: `BOOKING-${input.bookingId}` + }); + }); + } catch (error) { + await this.releaseClaimOrder(input.tenantId, order.orderId); + throw error; + } + return { bookingId: input.bookingId, orderId: order.orderId, idempotent: false }; + } + + async listRecords(input: { + tenantId: string; + access: AccessProfile; + provider?: ThirdPartyProvider; + status?: string; + }) { + const storeFilter = input.access.capabilities.includes('tenant.manage') + || input.access.roles.includes('PLATFORM_ADMIN') + ? null : input.access.storeIds; + if (storeFilter && storeFilter.length === 0) { + throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN'); + } + const params: string[] = [input.tenantId]; + const filters = ['tenant_id = ?']; + if (input.provider) { + filters.push('provider = ?'); + params.push(input.provider); + } + if (input.status) { + filters.push('status = ?'); + params.push(input.status); + } + if (storeFilter) { + filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`); + params.push(...storeFilter); + } + const [bookings] = await this.pool.execute( + `SELECT id, provider, external_booking_no AS externalBookingNo, + store_id AS storeId, room_id AS roomId, starts_at AS startsAt, + ends_at AS endsAt, amount_cents AS amountCents, status, + order_id AS orderId, failure_code AS failureCode, created_at AS createdAt + FROM qipai_direct_bookings + WHERE ${filters.join(' AND ')} ORDER BY id DESC LIMIT 100`, + params + ); + const [redemptions] = await this.pool.execute( + `SELECT r.id, v.provider, v.voucher_masked AS voucherMasked, + r.order_id AS orderId, r.store_id AS storeId, r.redemption_mode AS mode, + r.status, r.failure_code AS failureCode, r.created_at AS createdAt + FROM qipai_group_redemptions r + INNER JOIN qipai_group_vouchers v + ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id + WHERE r.tenant_id = ? + ${storeFilter ? `AND r.store_id IN (${storeFilter.map(() => '?').join(',')})` : ''} + ORDER BY r.id DESC LIMIT 100`, + storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId] + ); + return { bookings, redemptions }; + } + + async saveConfig(input: { + tenantId: string; + actorId: string; + access: AccessProfile; + provider: ThirdPartyProvider; + storeId: string | null; + mode: ThirdPartyMode; + enabled: boolean; + credentialRef: string; + settings: Record; + }) { + if (!input.access.capabilities.includes('tenant.manage') + && !input.access.roles.includes('PLATFORM_ADMIN')) { + throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN'); + } + if (input.credentialRef && !/^env:[A-Z0-9_:-]+$/.test(input.credentialRef)) { + throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_REF_INVALID'); + } + if (input.storeId) { + const [stores] = await this.pool.execute( + `SELECT 1 FROM qipai_stores + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, + [input.tenantId, input.storeId] + ); + if (!stores[0]) throw new ThirdPartyError('STORE_NOT_FOUND'); + } + const [existing] = await this.pool.execute( + `SELECT id FROM qipai_third_party_configs + WHERE tenant_id = ? AND provider = ? AND store_id <=> ? LIMIT 1`, + [input.tenantId, input.provider, input.storeId] + ); + if (existing[0]) { + await this.pool.execute( + `UPDATE qipai_third_party_configs + SET mode = ?, enabled = ?, credential_ref = ?, settings = CAST(? AS JSON) + WHERE tenant_id = ? AND id = ?`, + [input.mode, input.enabled, input.credentialRef, JSON.stringify(input.settings), + input.tenantId, existing[0].id] + ); + return { configId: String(existing[0].id), created: false }; + } + const [result] = await this.pool.execute( + `INSERT INTO qipai_third_party_configs + (tenant_id, store_id, provider, mode, enabled, credential_ref, settings) + VALUES (?, ?, ?, ?, ?, ?, CAST(? AS JSON))`, + [input.tenantId, input.storeId, input.provider, input.mode, input.enabled, + input.credentialRef, JSON.stringify(input.settings)] + ); + return { configId: String(result.insertId), created: true }; + } + + async saveMapping(input: { + tenantId: string; + access: AccessProfile; + provider: ThirdPartyProvider; + resourceType: 'STORE' | 'ROOM'; + externalRef: string; + localResourceId: string; + }) { + if (!input.access.capabilities.includes('tenant.manage') + && !input.access.roles.includes('PLATFORM_ADMIN')) { + throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN'); + } + const table = input.resourceType === 'STORE' ? 'qipai_stores' : 'qipai_rooms'; + const [resources] = await this.pool.execute( + `SELECT 1 FROM ${table} + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, + [input.tenantId, input.localResourceId] + ); + if (!resources[0]) throw new ThirdPartyError(`${input.resourceType}_NOT_FOUND`); + await this.pool.execute( + `INSERT INTO qipai_third_party_mappings + (tenant_id, provider, resource_type, external_ref, local_resource_id) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE local_resource_id = VALUES(local_resource_id)`, + [input.tenantId, input.provider, input.resourceType, + input.externalRef, input.localResourceId] + ); + return { mapped: true }; + } + + private async recordRedemption(input: { + tenantId: string; + actorId: string; + provider: ThirdPartyProvider; + voucherCode: string; + orderId: string; + clientRequestId: string; + mode: ThirdPartyMode; + amountCents: number; + expectedAmountCents: number; + externalProductId: string; + status: 'SUCCEEDED' | 'FAILED' | 'PENDING'; + failureCode: string; + response: Record; + }) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(input.tenantId, input.orderId, connection, true); + const [existing] = await connection.execute( + `SELECT r.id, r.order_id AS orderId, r.status, + v.voucher_masked AS voucherMasked + FROM qipai_group_redemptions r + INNER JOIN qipai_group_vouchers v + ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id + WHERE r.tenant_id = ? AND r.client_request_id = ? LIMIT 1`, + [input.tenantId, input.clientRequestId] + ); + if (existing[0]) { + if (String(existing[0].orderId) !== input.orderId) { + throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT'); + } + return { ...existing[0], idempotent: true }; + } + const voucherHash = hashValue(input.voucherCode); + const [voucherResult] = await connection.execute( + `INSERT INTO qipai_group_vouchers + (tenant_id, provider, voucher_hash, voucher_masked, + external_product_id, status, amount_cents, redeemed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL)) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`, + [input.tenantId, input.provider, voucherHash, maskVoucher(input.voucherCode), + input.externalProductId, input.status === 'SUCCEEDED' ? 'REDEEMED' : input.status, + input.amountCents, input.status] + ); + const voucherId = String(voucherResult.insertId); + const [used] = await connection.execute( + `SELECT order_id AS orderId FROM qipai_group_redemptions + WHERE tenant_id = ? AND voucher_id = ? LIMIT 1`, + [input.tenantId, voucherId] + ); + if (used[0]) throw new ThirdPartyError('VOUCHER_ALREADY_REDEEMED'); + const [redemption] = await connection.execute( + `INSERT INTO qipai_group_redemptions + (tenant_id, voucher_id, order_id, store_id, actor_id, + redemption_mode, client_request_id, status, failure_code, + provider_response, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), + IF(? IN ('SUCCEEDED', 'FAILED'), UTC_TIMESTAMP(3), NULL))`, + [input.tenantId, voucherId, input.orderId, order.storeId, input.actorId, + input.mode, input.clientRequestId, input.status, input.failureCode, + JSON.stringify(input.response), input.status] + ); + if (input.status === 'SUCCEEDED') { + if (input.amountCents !== input.expectedAmountCents) { + throw new ThirdPartyError('VOUCHER_AMOUNT_MISMATCH'); + } + await this.insertSuccessfulPayment(connection, { + tenantId: input.tenantId, + orderId: input.orderId, + storeId: order.storeId, + amountCents: input.amountCents, + providerReference: `VOUCHER-${voucherId}` + }); + } + return { + redemptionId: String(redemption.insertId), + voucherMasked: maskVoucher(input.voucherCode), + status: input.status, + failureCode: input.failureCode, + idempotent: false + }; + }); + } + + private async findRedemptionByRequest( + tenantId: string, + clientRequestId: string, + orderId: string + ) { + const [rows] = await this.pool.execute( + `SELECT r.id, r.order_id AS orderId, r.status, + v.voucher_masked AS voucherMasked + FROM qipai_group_redemptions r + INNER JOIN qipai_group_vouchers v + ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id + WHERE r.tenant_id = ? AND r.client_request_id = ? LIMIT 1`, + [tenantId, clientRequestId] + ); + if (!rows[0]) return null; + if (String(rows[0].orderId) !== orderId) { + throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT'); + } + return rows[0]; + } + + private async insertSuccessfulPayment( + connection: PoolConnection, + input: { + tenantId: string; + orderId: string; + storeId: string; + amountCents: number; + providerReference: string; + } + ) { + const paymentNo = `GRP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`; + const [payment] = await connection.execute( + `INSERT INTO qipai_payments + (tenant_id, order_id, store_id, payment_no, channel, provider, + client_request_id, status, amount_cents, provider_payment_id, paid_at) + VALUES (?, ?, ?, ?, 'GROUP_BUY', 'GROUP_BUY', ?, 'SUCCEEDED', ?, ?, + UTC_TIMESTAMP(3))`, + [input.tenantId, input.orderId, input.storeId, paymentNo, + input.providerReference, input.amountCents, input.providerReference] + ); + await connection.execute( + `UPDATE qipai_orders + SET paid_amount_cents = paid_amount_cents + ?, + status = IF(paid_amount_cents + ? >= total_amount_cents, 'PAID', status), + status_version = IF(paid_amount_cents + ? >= total_amount_cents, + status_version + 1, status_version), + status_updated_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ?`, + [input.amountCents, input.amountCents, input.amountCents, + input.tenantId, input.orderId] + ); + await connection.execute( + `UPDATE qipai_room_reservations + SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at) + WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`, + [input.tenantId, input.orderId] + ); + await connection.execute( + `INSERT INTO qipai_order_status_history + (tenant_id, order_id, from_status, to_status, action, actor_type, + actor_id, source, reason, trace_id, metadata) + VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM', + NULL, 'PAYMENT', 'Verified group-buy payment', ?, + JSON_OBJECT('paymentId', ?, 'providerReference', ?))`, + [input.tenantId, input.orderId, `group-payment-${payment.insertId}`, + payment.insertId, input.providerReference] + ); + } + + private async resolveConfig( + tenantId: string, storeId: string | null, provider: ThirdPartyProvider + ) { + const [rows] = await this.pool.execute( + `SELECT id, mode, credential_ref AS credentialRef, settings + FROM qipai_third_party_configs + WHERE tenant_id = ? AND provider = ? AND enabled = 1 + AND (store_id IS NULL OR store_id = ?) + ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`, + [tenantId, provider, storeId] + ); + if (!rows[0]) throw new ThirdPartyError('THIRD_PARTY_CONFIG_NOT_FOUND'); + return { + id: String(rows[0].id), + mode: rows[0].mode, + credentialRef: rows[0].credentialRef, + settings: typeof rows[0].settings === 'string' + ? JSON.parse(rows[0].settings) : rows[0].settings + }; + } + + private resolveCredential(reference: string) { + if (!reference) return undefined; + return this.credentials.get(reference) + ?? this.credentials.get(reference.replace(/^env:/, '')); + } + + private async resolveMappings( + tenantId: string, + provider: ThirdPartyProvider, + externalStoreRef: string, + externalRoomRef: string + ) { + const [rows] = await this.pool.execute( + `SELECT resource_type AS resourceType, local_resource_id AS localResourceId + FROM qipai_third_party_mappings + WHERE tenant_id = ? AND provider = ? + AND ((resource_type = 'STORE' AND external_ref = ?) + OR (resource_type = 'ROOM' AND external_ref = ?))`, + [tenantId, provider, externalStoreRef, externalRoomRef] + ); + const storeId = rows.find((row) => row.resourceType === 'STORE')?.localResourceId; + const roomId = rows.find((row) => row.resourceType === 'ROOM')?.localResourceId; + if (storeId && roomId) { + const [valid] = await this.pool.execute( + `SELECT 1 FROM qipai_rooms + WHERE tenant_id = ? AND id = ? AND store_id = ? AND deleted_at IS NULL`, + [tenantId, roomId, storeId] + ); + if (!valid[0]) return { storeId: null, roomId: null }; + } + return { + storeId: storeId ? String(storeId) : null, + roomId: roomId ? String(roomId) : null + }; + } + + private async loadOwnedOrder(tenantId: string, userId: string, orderId: string) { + const [access] = await this.pool.execute( + `SELECT 1 FROM qipai_order_user_access + WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`, + [tenantId, orderId, userId] + ); + if (!access[0]) throw new ThirdPartyError('ORDER_ACCESS_FORBIDDEN'); + return this.loadOrder(tenantId, orderId); + } + + private async loadOrder( + tenantId: string, + orderId: string, + connection: Pick | PoolConnection = this.pool, + lock = false + ) { + const [rows] = await connection.execute( + `SELECT id, order_no AS orderNo, store_id AS storeId, status, + total_amount_cents AS totalAmountCents, + paid_amount_cents AS paidAmountCents + FROM qipai_orders + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL + ${lock ? 'FOR UPDATE' : ''}`, + [tenantId, orderId] + ); + if (!rows[0]) throw new ThirdPartyError('ORDER_NOT_FOUND'); + if (rows[0].status !== 'PENDING_PAYMENT') { + throw new ThirdPartyError('ORDER_NOT_PAYABLE'); + } + return rows[0]; + } + + private async releaseClaimOrder(tenantId: string, orderId: string) { + await this.pool.execute( + `UPDATE qipai_room_reservations + SET status = 'RELEASED', released_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND order_id = ?`, + [tenantId, orderId] + ); + await this.pool.execute( + `UPDATE qipai_orders SET status = 'CLOSED' + WHERE tenant_id = ? AND id = ? AND status = 'PENDING_PAYMENT'`, + [tenantId, orderId] + ); + } + + private async transaction(work: (connection: PoolConnection) => Promise) { + const connection = await this.pool.getConnection(); + try { + await connection.beginTransaction(); + const result = await work(connection); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } +} + +function assertStoreAccess(access: AccessProfile, storeId: string) { + if (access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN') + || (access.capabilities.includes('store.operation.write') + && access.storeIds.includes(String(storeId)))) return; + throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN'); +} + +function hashValue(value: string) { + return createHash('sha256').update(value).digest('hex'); +} + +function maskVoucher(value: string) { + if (value.length <= 4) return '*'.repeat(value.length); + return `${value.slice(0, 2)}${'*'.repeat(Math.min(8, value.length - 4))}${value.slice(-2)}`; +} + +function sanitizePayload(value: Record) { + const copy = { ...value }; + delete copy.voucherCode; + delete copy.phone; + delete copy.openid; + delete copy.token; + return copy; +} + +function normalizeBooking(row: BookingRow) { + return { + bookingId: String(row.id), + status: row.status, + orderId: row.orderId ? String(row.orderId) : null, + mapped: Boolean(row.storeId && row.roomId) + }; +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 3458b02..fe0c0ff 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -57,6 +57,9 @@ const paymentVerifySql = read('database/migrations/2026062015_m05a_payment_domai const wechatRefundUpSql = read('database/migrations/2026062216_m05b_wechat_refunds.up.sql'); const wechatRefundDownSql = read('database/migrations/2026062216_m05b_wechat_refunds.down.sql'); const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_refunds.verify.sql'); +const thirdPartyUpSql = read('database/migrations/2026062217_m05c_third_party.up.sql'); +const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql'); +const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_party.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -252,5 +255,17 @@ assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_client_request/); assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_callback/); assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_reconciliation_request/); assert.doesNotMatch(wechatRefundUpSql, /private_key|api_v3_key|certificate_pem/i); +for (const table of [ + 'qipai_third_party_configs', 'qipai_third_party_mappings', + 'qipai_group_vouchers', 'qipai_group_redemptions', 'qipai_direct_bookings' +]) { + assert.match(thirdPartyUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); + assert.match(thirdPartyDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`)); + assert.match(thirdPartyVerifySql, new RegExp(`'${table}'`)); +} +assert.match(thirdPartyUpSql, /voucher_hash CHAR\(64\)/); +assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_group_redemption_voucher/); +assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/); +assert.doesNotMatch(thirdPartyUpSql, /voucher_code|api_token|webhook_secret/i); -console.log('PASS: M01-B through M05-B migration contracts are present.'); +console.log('PASS: M01-B through M05-C migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index cc035e7..9542704 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -27,7 +27,8 @@ 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, /2026062015_m05a_payment_domain\.up\.sql/); -assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql$/); +assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/); +assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 9749d9a..c1714cb 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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 { diff --git a/backend/tests/third-party.test.mjs b/backend/tests/third-party.test.mjs new file mode 100644 index 0000000..3e9a5c5 --- /dev/null +++ b/backend/tests/third-party.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; +import { + ThirdPartyClient, ThirdPartyError +} from '../dist/third-party/third-party-client.js'; + +const client = new ThirdPartyClient({ + async request() { + return { + status: 200, + body: JSON.stringify({ + accepted: true, + amountCents: 3600, + productId: 'product-sanitized', + voucherCode: 'must-be-redacted' + }) + }; + } +}); +const body = JSON.stringify({ eventId: 'event-001', amountCents: 3600 }); +const signature = createHmac('sha256', 'test-webhook-secret') + .update(body).digest('hex'); +client.verifyWebhook('test-webhook-secret', body, signature); +assert.throws( + () => client.verifyWebhook('test-webhook-secret', `${body} `, signature), + (error) => error instanceof ThirdPartyError + && error.code === 'THIRD_PARTY_SIGNATURE_INVALID' +); +assert.equal((await client.redeemVoucher({ + mode: 'MOCK', + provider: 'MEITUAN', + voucherCode: 'MOCK-ACCEPT', + orderNo: 'QP001', + expectedAmountCents: 3600, + settings: {} +})).status, 'SUCCEEDED'); +assert.equal((await client.redeemVoucher({ + mode: 'MOCK', + provider: 'DOUYIN', + voucherCode: 'FAIL-REJECT', + orderNo: 'QP002', + expectedAmountCents: 3600, + settings: {} +})).status, 'FAILED'); +const apiResult = await client.redeemVoucher({ + mode: 'API', + provider: 'MEITUAN', + voucherCode: 'API-ACCEPT', + orderNo: 'QP003', + expectedAmountCents: 3600, + settings: { redeemEndpoint: 'https://partner.example.test/redeem' }, + credential: { apiToken: 'test-token' } +}); +assert.equal(apiResult.amountCents, 3600); +assert.equal('voucherCode' in apiResult.response, false); + +const secret = 'third-party-route-secret-with-32-characters'; +const token = signAccessToken({ + sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', aid: '9', rv: 1 +}, secret, 900); +let redeemInput; +let notifyInput; +const routeApp = await buildApp({ + thirdParty: { + jwtSecret: secret, + 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: '' + } + }; + } + }, + accessControl: { + async getAccessProfile() { + return { + roles: ['TENANT_ADMIN'], + capabilities: ['tenant.manage'], + storeIds: [] + }; + } + }, + service: { + async redeemVoucher(input) { + redeemInput = input; + return { redemptionId: '51', status: 'SUCCEEDED' }; + }, + async redeemVoucherManually() { + return { redemptionId: '52', status: 'SUCCEEDED' }; + }, + async receiveDirectBooking(input) { + notifyInput = input; + return { bookingId: '61', status: 'PENDING_MAPPING' }; + }, + async claimDirectBooking() { + return { bookingId: '61', orderId: '71' }; + }, + async listRecords() { + return { bookings: [], redemptions: [] }; + }, + async saveConfig() { + return { configId: '81', created: true }; + }, + async saveMapping() { + return { mapped: true }; + } + } + } +}); + +const redeemed = await routeApp.inject({ + method: 'POST', + url: '/app-api/group-vouchers/redeem', + headers: { authorization: `Bearer ${token}` }, + payload: { + provider: 'MEITUAN', + voucherCode: '1234567890', + orderId: '31', + clientRequestId: 'redeem-request-001' + } +}); +assert.equal(redeemed.statusCode, 200); +assert.equal(redeemInput.userId, '21'); + +const bookingPayload = { + eventId: 'event-001', + externalBookingNo: 'booking-001', + externalStoreRef: 'store-external', + externalRoomRef: 'room-external', + customerRef: 'customer-private', + startsAt: new Date(Date.now() + 86400000).toISOString(), + endsAt: new Date(Date.now() + 90000000).toISOString(), + amountCents: 3600 +}; +const notified = await routeApp.inject({ + method: 'POST', + url: '/app-api/third-party/MEITUAN/tenants/7/bookings/notify', + headers: { 'x-third-party-signature': 'signature-sanitized' }, + payload: bookingPayload +}); +assert.equal(notified.statusCode, 200); +assert.equal(notifyInput.tenantId, '7'); +assert.equal(notifyInput.provider, 'MEITUAN'); +assert.equal(notifyInput.rawBody, JSON.stringify(bookingPayload)); + +await routeApp.close(); +console.log('PASS: M05-C adapters, webhook authentication and third-party routes.'); diff --git a/database/migrations/2026062217_m05c_third_party.down.sql b/database/migrations/2026062217_m05c_third_party.down.sql new file mode 100644 index 0000000..a8ad77a --- /dev/null +++ b/database/migrations/2026062217_m05c_third_party.down.sql @@ -0,0 +1,6 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026062217'; +DROP TABLE IF EXISTS qipai_direct_bookings; +DROP TABLE IF EXISTS qipai_group_redemptions; +DROP TABLE IF EXISTS qipai_group_vouchers; +DROP TABLE IF EXISTS qipai_third_party_mappings; +DROP TABLE IF EXISTS qipai_third_party_configs; diff --git a/database/migrations/2026062217_m05c_third_party.up.sql b/database/migrations/2026062217_m05c_third_party.up.sql new file mode 100644 index 0000000..c93b2b7 --- /dev/null +++ b/database/migrations/2026062217_m05c_third_party.up.sql @@ -0,0 +1,118 @@ +CREATE TABLE IF NOT EXISTS qipai_third_party_configs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NULL, + provider VARCHAR(32) NOT NULL, + mode VARCHAR(16) NOT NULL DEFAULT 'MANUAL', + enabled TINYINT(1) NOT NULL DEFAULT 1, + credential_ref VARCHAR(255) NOT NULL DEFAULT '', + settings JSON NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_third_party_config_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_third_party_config_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_third_party_config_scope (tenant_id, provider, store_id), + KEY idx_qipai_third_party_config_resolution (tenant_id, provider, enabled, store_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_third_party_mappings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(32) NOT NULL, + resource_type VARCHAR(16) NOT NULL, + external_ref VARCHAR(128) NOT NULL, + local_resource_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_third_party_mapping_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + UNIQUE KEY uq_qipai_third_party_mapping_external + (tenant_id, provider, resource_type, external_ref), + KEY idx_qipai_third_party_mapping_local + (tenant_id, provider, resource_type, local_resource_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_group_vouchers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(32) NOT NULL, + voucher_hash CHAR(64) NOT NULL, + voucher_masked VARCHAR(64) NOT NULL, + external_product_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED', + amount_cents INT UNSIGNED NOT NULL DEFAULT 0, + expires_at DATETIME(3) NULL, + redeemed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_group_voucher_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + UNIQUE KEY uq_qipai_group_voucher_hash (tenant_id, provider, voucher_hash), + KEY idx_qipai_group_voucher_status (tenant_id, provider, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_group_redemptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + voucher_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NOT NULL, + actor_id BIGINT UNSIGNED NULL, + redemption_mode VARCHAR(16) NOT NULL, + client_request_id VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + failure_code VARCHAR(64) NOT NULL DEFAULT '', + provider_response JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_group_redemption_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_group_redemption_voucher + FOREIGN KEY (voucher_id) REFERENCES qipai_group_vouchers(id), + CONSTRAINT fk_qipai_group_redemption_order + FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + CONSTRAINT fk_qipai_group_redemption_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_group_redemption_request (tenant_id, client_request_id), + UNIQUE KEY uq_qipai_group_redemption_voucher (tenant_id, voucher_id), + KEY idx_qipai_group_redemption_status (tenant_id, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_direct_bookings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(32) NOT NULL, + event_id VARCHAR(128) NOT NULL, + external_booking_no VARCHAR(128) NOT NULL, + external_store_ref VARCHAR(128) NOT NULL, + external_room_ref VARCHAR(128) NOT NULL, + store_id BIGINT UNSIGNED NULL, + room_id BIGINT UNSIGNED NULL, + customer_ref_hash CHAR(64) NOT NULL DEFAULT '', + starts_at DATETIME(3) NOT NULL, + ends_at DATETIME(3) NOT NULL, + amount_cents INT UNSIGNED NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_MAPPING', + order_id BIGINT UNSIGNED NULL, + claimed_user_id BIGINT UNSIGNED NULL, + payload JSON NOT NULL, + failure_code VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + claimed_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_direct_booking_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_direct_booking_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_direct_booking_room + FOREIGN KEY (room_id) REFERENCES qipai_rooms(id), + CONSTRAINT fk_qipai_direct_booking_order + FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + UNIQUE KEY uq_qipai_direct_booking_event (tenant_id, provider, event_id), + UNIQUE KEY uq_qipai_direct_booking_no + (tenant_id, provider, external_booking_no), + KEY idx_qipai_direct_booking_status (tenant_id, provider, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062217', 'm05c_third_party'); diff --git a/database/migrations/2026062217_m05c_third_party.verify.sql b/database/migrations/2026062217_m05c_third_party.verify.sql new file mode 100644 index 0000000..dca4233 --- /dev/null +++ b/database/migrations/2026062217_m05c_third_party.verify.sql @@ -0,0 +1,23 @@ +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name IN ( + 'qipai_third_party_configs', 'qipai_third_party_mappings', + 'qipai_group_vouchers', 'qipai_group_redemptions', 'qipai_direct_bookings' +) ORDER BY table_name; + +SELECT table_name, index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ((table_name = 'qipai_group_vouchers' + AND index_name = 'uq_qipai_group_voucher_hash') + OR (table_name = 'qipai_group_redemptions' + AND index_name IN ( + 'uq_qipai_group_redemption_request', 'uq_qipai_group_redemption_voucher' + )) + OR (table_name = 'qipai_direct_bookings' + AND index_name IN ( + 'uq_qipai_direct_booking_event', 'uq_qipai_direct_booking_no' + )) + OR (table_name = 'qipai_third_party_mappings' + AND index_name = 'uq_qipai_third_party_mapping_external')) +GROUP BY table_name, index_name ORDER BY table_name, index_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062217'; diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 15509ed..f1cadff 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_CONNECTION_LIMIT=2 -echo "INFO: MySQL ${mysql_version}; running M01-B through M05-B migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M05-C migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M05-B live MySQL migration roundtrip completed." +echo "PASS: M01-B through M05-C live MySQL migration roundtrip completed."