245 lines
8.5 KiB
TypeScript
245 lines
8.5 KiB
TypeScript
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
|
|
});
|
|
}
|