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