feat(M05-C): 完成团购验券与第三方直订
This commit is contained in:
@@ -14,6 +14,7 @@ QIPAI_SESSION_TTL_SECONDS=604800
|
|||||||
QIPAI_WECHAT_APP_SECRETS={}
|
QIPAI_WECHAT_APP_SECRETS={}
|
||||||
QIPAI_TEST_PAYMENT_ENABLED=false
|
QIPAI_TEST_PAYMENT_ENABLED=false
|
||||||
QIPAI_WECHAT_PAY_CREDENTIALS={}
|
QIPAI_WECHAT_PAY_CREDENTIALS={}
|
||||||
|
QIPAI_THIRD_PARTY_CREDENTIALS={}
|
||||||
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
||||||
QIPAI_MQTT_USERNAME=
|
QIPAI_MQTT_USERNAME=
|
||||||
QIPAI_MQTT_PASSWORD=
|
QIPAI_MQTT_PASSWORD=
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"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",
|
"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: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": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import {
|
|||||||
} from './routes/order-management.js';
|
} from './routes/order-management.js';
|
||||||
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
|
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
|
||||||
import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js';
|
import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js';
|
||||||
|
import {
|
||||||
|
registerThirdPartyRoutes, type ThirdPartyRouteOptions
|
||||||
|
} from './routes/third-party.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -53,6 +56,7 @@ export interface BuildAppOptions {
|
|||||||
orderManagement?: OrderManagementRouteOptions;
|
orderManagement?: OrderManagementRouteOptions;
|
||||||
orderShare?: OrderShareRouteOptions;
|
orderShare?: OrderShareRouteOptions;
|
||||||
payment?: PaymentRouteOptions;
|
payment?: PaymentRouteOptions;
|
||||||
|
thirdParty?: ThirdPartyRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -133,6 +137,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.payment) {
|
if (options.payment) {
|
||||||
await registerPaymentRoutes(app, options.payment);
|
await registerPaymentRoutes(app, options.payment);
|
||||||
}
|
}
|
||||||
|
if (options.thirdParty) {
|
||||||
|
await registerThirdPartyRoutes(app, options.thirdParty);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const configSchema = z.object({
|
|||||||
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
|
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
|
||||||
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
|
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||||
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
|
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
|
||||||
|
QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
|
||||||
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
|
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
|
||||||
QIPAI_MQTT_USERNAME: z.string().default(''),
|
QIPAI_MQTT_USERNAME: z.string().default(''),
|
||||||
QIPAI_MQTT_PASSWORD: z.string().default('')
|
QIPAI_MQTT_PASSWORD: z.string().default('')
|
||||||
@@ -60,6 +61,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
|||||||
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
||||||
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS
|
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS
|
||||||
},
|
},
|
||||||
|
thirdParty: {
|
||||||
|
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
|
||||||
|
},
|
||||||
mqtt: {
|
mqtt: {
|
||||||
url: parsed.QIPAI_MQTT_URL,
|
url: parsed.QIPAI_MQTT_URL,
|
||||||
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0,
|
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
|
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
|
||||||
'database/migrations/2026062014_m04d_order_shares.up.sql',
|
'database/migrations/2026062014_m04d_order_shares.up.sql',
|
||||||
'database/migrations/2026062015_m05a_payment_domain.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: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -54,9 +55,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
|
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
|
||||||
'database/migrations/2026062014_m04d_order_shares.verify.sql',
|
'database/migrations/2026062014_m04d_order_shares.verify.sql',
|
||||||
'database/migrations/2026062015_m05a_payment_domain.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: [
|
down: [
|
||||||
|
'database/migrations/2026062217_m05c_third_party.down.sql',
|
||||||
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
||||||
'database/migrations/2026062015_m05a_payment_domain.down.sql',
|
'database/migrations/2026062015_m05a_payment_domain.down.sql',
|
||||||
'database/migrations/2026062014_m04d_order_shares.down.sql',
|
'database/migrations/2026062014_m04d_order_shares.down.sql',
|
||||||
@@ -204,7 +207,8 @@ export async function executeMigrationPlan(
|
|||||||
2, 2, 1, 2, 1,
|
2, 2, 1, 2, 1,
|
||||||
1, 8, 3, 1,
|
1, 8, 3, 1,
|
||||||
5, 8, 4, 1,
|
5, 8, 4, 1,
|
||||||
1, 5, 3, 1
|
1, 5, 3, 1,
|
||||||
|
5, 6, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -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<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: {
|
||||||
|
getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile>;
|
||||||
|
};
|
||||||
|
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<string, unknown>,
|
||||||
|
...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<unknown>) {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
|
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
|
||||||
} from './payments/wechat-pay-client.js';
|
} from './payments/wechat-pay-client.js';
|
||||||
import { WechatPaymentService } from './payments/wechat-payment-service.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 config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
@@ -29,6 +33,7 @@ const accessControl = new RbacRepository(pool);
|
|||||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||||
const paymentRepository = new PaymentRepository(pool);
|
const paymentRepository = new PaymentRepository(pool);
|
||||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||||
|
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config,
|
config,
|
||||||
platformConfigRepository: new PlatformConfigRepository(pool),
|
platformConfigRepository: new PlatformConfigRepository(pool),
|
||||||
@@ -106,6 +111,17 @@ const app = await buildApp({
|
|||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret,
|
jwtSecret: config.auth.jwtSecret,
|
||||||
testAdapterEnabled: config.payment.testAdapterEnabled
|
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 () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
+174
@@ -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<string, string>;
|
||||||
|
body: string;
|
||||||
|
}): Promise<{ status: number; body: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoucherRedeemResult {
|
||||||
|
status: 'SUCCEEDED' | 'FAILED' | 'PENDING';
|
||||||
|
amountCents: number;
|
||||||
|
externalProductId?: string;
|
||||||
|
failureCode?: string;
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
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<string, unknown>;
|
||||||
|
credential?: ThirdPartyCredential;
|
||||||
|
}): Promise<VoucherRedeemResult> {
|
||||||
|
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<string, ThirdPartyCredential>();
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
} 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<string, unknown>) {
|
||||||
|
const copy = { ...value };
|
||||||
|
delete copy.voucherCode;
|
||||||
|
delete copy.token;
|
||||||
|
delete copy.secret;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
+676
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
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<string, ThirdPartyCredential>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}) {
|
||||||
|
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<BookingRow[]>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<BookingRow[]>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<string, unknown>;
|
||||||
|
}) {
|
||||||
|
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<RowDataPacket[]>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const order = await this.loadOrder(input.tenantId, input.orderId, connection, true);
|
||||||
|
const [existing] = await connection.execute<RedemptionRow[]>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<RedemptionRow[]>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<ConfigRow[]>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<RowDataPacket[]>(
|
||||||
|
`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<MySqlPool, 'execute'> | PoolConnection = this.pool,
|
||||||
|
lock = false
|
||||||
|
) {
|
||||||
|
const [rows] = await connection.execute<OrderRow[]>(
|
||||||
|
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 wechatRefundUpSql = read('database/migrations/2026062216_m05b_wechat_refunds.up.sql');
|
||||||
const wechatRefundDownSql = read('database/migrations/2026062216_m05b_wechat_refunds.down.sql');
|
const wechatRefundDownSql = read('database/migrations/2026062216_m05b_wechat_refunds.down.sql');
|
||||||
const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_refunds.verify.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 = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'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_refund_callback/);
|
||||||
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_reconciliation_request/);
|
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_reconciliation_request/);
|
||||||
assert.doesNotMatch(wechatRefundUpSql, /private_key|api_v3_key|certificate_pem/i);
|
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.');
|
||||||
|
|||||||
@@ -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, /2026062013_m04c_order_adjustments\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
|
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
|
assert.match(plan.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.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
PaymentError, PaymentRepository
|
PaymentError, PaymentRepository
|
||||||
} from '../dist/payments/payment-repository.js';
|
} 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 {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
loadMigrationPlan,
|
loadMigrationPlan,
|
||||||
@@ -42,6 +45,9 @@ const expectedTables = [
|
|||||||
'qipai_audit_logs',
|
'qipai_audit_logs',
|
||||||
'qipai_auth_sessions',
|
'qipai_auth_sessions',
|
||||||
'qipai_devices',
|
'qipai_devices',
|
||||||
|
'qipai_direct_bookings',
|
||||||
|
'qipai_group_redemptions',
|
||||||
|
'qipai_group_vouchers',
|
||||||
'qipai_holiday_calendar',
|
'qipai_holiday_calendar',
|
||||||
'qipai_legacy_table_mappings',
|
'qipai_legacy_table_mappings',
|
||||||
'qipai_media_assets',
|
'qipai_media_assets',
|
||||||
@@ -77,6 +83,8 @@ const expectedTables = [
|
|||||||
'qipai_tenant_apps',
|
'qipai_tenant_apps',
|
||||||
'qipai_tenant_configs',
|
'qipai_tenant_configs',
|
||||||
'qipai_tenants',
|
'qipai_tenants',
|
||||||
|
'qipai_third_party_configs',
|
||||||
|
'qipai_third_party_mappings',
|
||||||
'qipai_user_admin_profiles',
|
'qipai_user_admin_profiles',
|
||||||
'qipai_user_identities',
|
'qipai_user_identities',
|
||||||
'qipai_user_roles',
|
'qipai_user_roles',
|
||||||
@@ -102,12 +110,12 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015', '2026062216']
|
'2026062015', '2026062216', '2026062217']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1160,186 @@ async function assertPaymentDomain(pool, context) {
|
|||||||
assert.equal(configRows.some((row) => /secret|private.key/i.test(row.settings)), false);
|
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) {
|
async function assertContentManagement(pool, context) {
|
||||||
const [adminRows] = await pool.query(
|
const [adminRows] = await pool.query(
|
||||||
`SELECT u.id FROM qipai_users u
|
`SELECT u.id FROM qipai_users u
|
||||||
@@ -1255,7 +1443,8 @@ try {
|
|||||||
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
||||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
{ version: '2026062014', name: 'm04d_order_shares' },
|
||||||
{ version: '2026062015', name: 'm05a_payment_domain' },
|
{ 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);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -1270,13 +1459,14 @@ try {
|
|||||||
await assertOrderAdjustments(pool, loginContext);
|
await assertOrderAdjustments(pool, loginContext);
|
||||||
await assertOrderShares(pool, loginContext);
|
await assertOrderShares(pool, loginContext);
|
||||||
await assertPaymentDomain(pool, loginContext);
|
await assertPaymentDomain(pool, loginContext);
|
||||||
|
await assertThirdPartyDomain(pool, loginContext);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.down);
|
await executeMigrationPlan(pool, plans.down);
|
||||||
assert.deepEqual(await readCoreTables(pool), []);
|
assert.deepEqual(await readCoreTables(pool), []);
|
||||||
await assertLegacyCompatibility(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.up);
|
||||||
await executeMigrationPlan(pool, plans.verify);
|
await executeMigrationPlan(pool, plans.verify);
|
||||||
@@ -1297,7 +1487,8 @@ try {
|
|||||||
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
||||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
{ version: '2026062014', name: 'm04d_order_shares' },
|
||||||
{ version: '2026062015', name: 'm05a_payment_domain' },
|
{ 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);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -1380,6 +1571,11 @@ try {
|
|||||||
'test adapter explicit non-production gate',
|
'test adapter explicit non-production gate',
|
||||||
'Wechat refund idempotency and callback indexes',
|
'Wechat refund idempotency and callback indexes',
|
||||||
'Wechat reconciliation request history'
|
'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));
|
}, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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.');
|
||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
@@ -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';
|
||||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
|||||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
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
|
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."
|
||||||
|
|||||||
Reference in New Issue
Block a user