Files
qipai/backend/src/routes/members.ts
T
2026-06-24 16:02:26 +08:00

108 lines
3.5 KiB
TypeScript

import type { FastifyInstance, FastifyReply, FastifyRequest } 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 {
MemberProfileError,
type MemberActor,
type MemberProfileService
} from '../wallets/member-profile-service.js';
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
search: z.string().trim().max(128).optional()
});
export interface MemberRouteOptions {
service: Pick<MemberProfileService, 'listMembers' | 'getMember'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerMemberRoutes(
app: FastifyInstance,
options: MemberRouteOptions
): Promise<void> {
app.get('/admin-api/members', async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listMembers({ actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/members/:id', async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
const params = idSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMember({ actor, memberId: params.data.id }),
traceId: request.traceId
}));
});
}
async function requireReader(
request: FastifyRequest,
reply: FastifyReply,
options: MemberRouteOptions
): Promise<MemberActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
if (!access.capabilities.includes('user.read')
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'MEMBER_READ_FORBIDDEN', message: 'Member read permission is required.',
traceId: request.traceId
});
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof MemberProfileError)) throw error;
return reply.status(404).send({
code: error.code,
message: 'The requested member is not available.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_MEMBER_REQUEST',
message: 'The member request is invalid.',
traceId
});
}