feat(M05-C): 完成团购验券与第三方直订

This commit is contained in:
Codex
2026-06-22 11:28:50 +08:00
parent 3ec74bb751
commit cda640baa0
17 changed files with 1654 additions and 13 deletions
+244
View File
@@ -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
});
}