feat(M02-D): 完成用户与员工权限管理

This commit is contained in:
Codex
2026-06-18 14:45:01 +08:00
parent 46acb8422a
commit 5a300a82f6
21 changed files with 894 additions and 40 deletions
+10 -19
View File
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { signAccessToken, verifyAccessToken } from '../auth/jwt.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import { signAccessToken } from '../auth/jwt.js';
import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
@@ -100,40 +101,30 @@ export async function registerAuthRoutes(app: FastifyInstance, options: AuthRout
});
app.get('/app-api/auth/me', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const auth = await authenticateAccessToken(
request.headers.authorization, options.repository, options.jwtSecret
);
if (!auth) return unauthorized(reply, request.traceId);
const access = options.accessControl
? await options.accessControl.getAccessProfile(auth.user.tenantId, auth.user.id)
? await options.accessControl.getAccessProfile(auth.session.user.tenantId, auth.session.user.id)
: { roles: [], capabilities: [], storeIds: [] };
return {
code: 0,
data: { user: publicUser(auth.user), access },
data: { user: publicUser(auth.session.user), access },
traceId: request.traceId
};
});
app.post('/app-api/auth/logout', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const auth = await authenticateAccessToken(
request.headers.authorization, options.repository, options.jwtSecret
);
if (!auth) return unauthorized(reply, request.traceId);
await options.repository.revokeSession(auth.sessionId);
return { code: 0, data: { revoked: true }, traceId: request.traceId };
});
}
async function authenticate(authorization: string | undefined, options: AuthRouteOptions) {
if (!authorization?.startsWith('Bearer ')) return null;
try {
const claims = verifyAccessToken(authorization.slice(7), options.jwtSecret);
const session = await options.repository.validateSession(claims.sid, claims.tid, claims.sub);
if (!session || session.platformAppId !== claims.aid || session.user.roleVersion !== claims.rv) {
return null;
}
return { sessionId: claims.sid, user: session.user };
} catch {
return null;
}
}
function unauthorized(reply: { status(code: number): { send(payload: unknown): unknown } }, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
+159
View File
@@ -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
});
}