feat(M02-D): 完成用户与员工权限管理
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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 {
|
||||
UserManagementError,
|
||||
type AssignableRole,
|
||||
type ManagementActor,
|
||||
type UserManagementRepository
|
||||
} from '../auth/user-management-repository.js';
|
||||
|
||||
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const rolesSchema = z.array(z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN'])).max(4);
|
||||
const storesSchema = z.array(z.string().regex(/^[1-9]\d{0,19}$/)).max(100);
|
||||
const createSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(128),
|
||||
phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/),
|
||||
note: z.string().trim().max(1000).default(''),
|
||||
roles: rolesSchema.default(['STAFF']),
|
||||
storeIds: storesSchema.default([])
|
||||
});
|
||||
const updateSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(128).optional(),
|
||||
phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/).optional(),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
|
||||
roles: rolesSchema.optional(),
|
||||
storeIds: storesSchema.optional()
|
||||
}).refine((value) => Object.keys(value).length > 0);
|
||||
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 UserManagementRouteOptions {
|
||||
repository: Pick<UserManagementRepository, 'listUsers' | 'createStaff' | 'updateUser' | 'resetSessions'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerUserManagementRoutes(
|
||||
app: FastifyInstance,
|
||||
options: UserManagementRouteOptions
|
||||
): Promise<void> {
|
||||
app.get('/admin-api/users', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
const data = await options.repository.listUsers({ actor, ...query.data });
|
||||
return { code: 0, data, traceId: request.traceId };
|
||||
});
|
||||
|
||||
app.post('/admin-api/staff', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const body = createSchema.safeParse(request.body);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handleMutation(reply, request.traceId, async () => {
|
||||
const data = await options.repository.createStaff(actor, {
|
||||
...body.data,
|
||||
roles: body.data.roles as AssignableRole[]
|
||||
});
|
||||
return reply.status(201).send({ code: 0, data, traceId: request.traceId });
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/admin-api/users/:id', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const params = idSchema.safeParse(request.params);
|
||||
const body = updateSchema.safeParse(request.body);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handleMutation(reply, request.traceId, async () => {
|
||||
const data = await options.repository.updateUser(actor, params.data.id, {
|
||||
...body.data,
|
||||
roles: body.data.roles as AssignableRole[] | undefined
|
||||
});
|
||||
return { code: 0, data, traceId: request.traceId };
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin-api/users/:id/reset-sessions', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const params = idSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handleMutation(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.resetSessions(actor, params.data.id),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireManager(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: UserManagementRouteOptions
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization,
|
||||
options.authRepository,
|
||||
options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'The access token or session is invalid.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId,
|
||||
auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.some((code) => code === 'staff.manage' || code === 'tenant.manage')
|
||||
&& !access.roles.includes('PLATFORM_ADMIN')) {
|
||||
reply.status(403).send({
|
||||
code: 'STAFF_MANAGEMENT_FORBIDDEN', message: 'Staff management permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMutation(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserManagementError)) throw error;
|
||||
const forbidden = error.code.endsWith('_FORBIDDEN') || error.code === 'USER_NOT_MANAGEABLE';
|
||||
return reply.status(forbidden ? 403 : 404).send({
|
||||
code: error.code,
|
||||
message: 'The requested user, role or store assignment is not allowed.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_USER_MANAGEMENT_REQUEST',
|
||||
message: 'The user management request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user