const crypto = require("node:crypto"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { hashPassword } = require("../utils/crypto"); const { validatePassword } = require("../utils/passwordPolicy"); const { decryptStringOrThrow, decryptStringSafe, encryptStringOrThrow, maskBankAccount, maskIdNumber, } = require("../services/crypto"); const roleCodeSchema = z.enum([ "employee", "manager", "ceo", "finance", "finance_mgr", "administration", "hr", "admin", ]); const listQuerySchema = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), status: z.enum(["active", "archived"]).optional(), q: z.string().max(100).optional(), }); const createBodySchema = z.object({ employeeNo: z.string().min(1).max(50), name: z.string().min(1).max(100), phone: z.string().min(6).max(30), email: z.string().email().max(200), departmentId: z.string().uuid().optional(), title: z.string().min(1).max(100).optional(), directManagerId: z.string().uuid().nullable().optional(), hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), roles: z.array(roleCodeSchema).min(1).optional(), password: z.string().min(1), }); const updateBodySchema = z.object({ employeeNo: z.string().trim().min(1).max(50).optional(), name: z.string().trim().min(1).max(100).optional(), phone: z.string().trim().min(6).max(30).optional(), email: z.string().trim().email().max(200).optional(), departmentId: z.string().uuid().optional(), title: z.string().trim().min(1).max(100).optional(), directManagerId: z.string().uuid().nullable().optional(), hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), }); const setRolesBodySchema = z.object({ roles: z.array(roleCodeSchema).min(1), }); const resetPasswordBodySchema = z.object({ newPassword: z.string().min(1).optional(), }); const sensitiveBodySchema = z .object({ bankName: z.string().trim().max(100).nullable().optional(), bankAccount: z.string().trim().max(100).nullable().optional(), idNumber: z.string().trim().max(64).nullable().optional(), }) .strict(); function parseUserId(params) { const parsed = z.string().uuid().safeParse(params?.id); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } return parsed.data; } function parseListQuery(query) { const parsed = listQuerySchema.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, status: parsed.data.status, q: parsed.data.q?.trim() ?? "", }; } function parseCreateBody(body) { const parsed = createBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const errors = validatePassword(parsed.data.password); if (errors.length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { password: errors }, }); } return { ...parsed.data, roles: parsed.data.roles ?? ["employee"], }; } function parseUpdateBody(body) { const parsed = updateBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } if (!Object.keys(parsed.data).length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty" }, }); } return parsed.data; } function mapEmployeeSummary(row) { return { id: row.id, employeeNo: row.employee_no, name: row.name, phone: row.phone, email: row.email, status: row.status, departmentId: row.department_id, title: row.title, roleCodes: row.roles.map((item) => item.role_code), createdAt: row.created_at?.toISOString?.() ?? null, updatedAt: row.updated_at?.toISOString?.() ?? null, }; } function mapEmployeeDetail(row, options = {}) { const config = options.config ?? null; const logger = options.logger ?? null; const bankAccountPlain = decryptStringSafe(config, row.bank_account_encrypted, logger); const idNumberPlain = decryptStringSafe(config, row.id_number_encrypted, logger); return { ...mapEmployeeSummary(row), directManagerId: row.direct_manager_id ?? null, hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null, archivedAt: row.archived_at?.toISOString?.() ?? null, bankName: row.bank_name ? String(row.bank_name) : null, bankAccountMasked: bankAccountPlain ? maskBankAccount(bankAccountPlain) : null, idNumberMasked: idNumberPlain ? maskIdNumber(idNumberPlain) : null, }; } function normalizeUniqueErrorDetails(error) { const meta = error?.meta ?? {}; const target = meta.target ?? meta?.cause?.constraint?.fields ?? null; const fields = Array.isArray(target) ? target : typeof target === "string" ? [target] : []; const details = {}; for (const field of fields) { if (typeof field === "string" && field.trim()) { details[field] = "duplicate"; } } return Object.keys(details).length ? details : { unique: "duplicate" }; } function parseSensitiveBody(body) { const parsed = sensitiveBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } if (!Object.keys(parsed.data).length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty" }, }); } return parsed.data; } async function usersRoutes(fastify, opts = {}) { const basePathRaw = typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users"; const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`; const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; fastify.get( routeBasePath, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const query = parseListQuery(request.query); const skip = (query.page - 1) * query.pageSize; const q = query.q; const where = { ...(query.status ? { status: query.status } : {}), ...(q ? { OR: [ { employee_no: { contains: q, mode: "insensitive" } }, { name: { contains: q, mode: "insensitive" } }, { phone: { contains: q, mode: "insensitive" } }, { email: { contains: q, mode: "insensitive" } }, ], } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.employee.findMany({ where, orderBy: { created_at: "desc" }, skip, take: query.pageSize, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, title: true, created_at: true, updated_at: true, roles: { select: { role_code: true } }, }, }), fastify.prisma.employee.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, items: items.map(mapEmployeeSummary), }); }, ); fastify.get( `${routeBasePath}/:id`, { preHandler: fastify.authenticate, }, async (request, reply) => { const userId = parseUserId(request.params); const canManage = fastify.hasPermission(request.employee, "system.users"); if (!canManage) { const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own"); const isSelf = request.employee.id === userId; if (!canViewOwn || !isSelf) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } } const employee = await fastify.prisma.employee.findUnique({ where: { id: userId }, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, title: true, direct_manager_id: true, hire_date: true, bank_account_encrypted: true, bank_name: true, id_number_encrypted: true, archived_at: true, created_at: true, updated_at: true, roles: { select: { role_code: true } }, }, }); if (!employee) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } return sendData( reply, request, mapEmployeeDetail(employee, { config: fastify.config, logger: request.log ?? fastify.log }), ); }, ); fastify.post( routeBasePath, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const body = parseCreateBody(request.body); const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date(); const departmentId = body.departmentId ?? request.employee.department_id; const department = await fastify.prisma.department.findUnique({ where: { id: departmentId }, select: { id: true, archived_at: true }, }); if (!department) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { departmentId: "not_found" }, }); } if (department.archived_at) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { departmentId: "archived" }, }); } if (typeof body.directManagerId === "string") { const manager = await fastify.prisma.employee.findUnique({ where: { id: body.directManagerId }, select: { id: true, status: true }, }); if (!manager) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { directManagerId: "not_found" }, }); } if (manager.status !== "active") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { directManagerId: "inactive" }, }); } } const passwordHash = await hashPassword(body.password); const now = new Date(); let created = null; try { created = await fastify.prisma.$transaction(async (tx) => { const employee = await tx.employee.create({ data: { employee_no: body.employeeNo, name: body.name, phone: body.phone, email: body.email, department_id: departmentId, title: body.title ?? "员工", direct_manager_id: body.directManagerId ?? null, hire_date: hireDate, status: "active", bank_account_encrypted: "", bank_name: "", password_hash: passwordHash, must_change_password: true, created_at: now, created_by: request.employee.id, updated_at: now, updated_by: request.employee.id, }, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, title: true, created_at: true, updated_at: true, }, }); await tx.employeeRole.createMany({ data: body.roles.map((roleCode) => ({ employee_id: employee.id, role_code: roleCode, created_at: now, created_by: request.employee.id, })), skipDuplicates: true, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_CREATED", targetType: "employee", targetId: employee.id, extra: { employeeNo: employee.employee_no, email: employee.email, phone: employee.phone, roles: body.roles, }, }), }); return employee; }); } catch (error) { if (error?.code === "P2002") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: normalizeUniqueErrorDetails(error), }); } throw error; } const roles = await fastify.prisma.employeeRole.findMany({ where: { employee_id: created.id }, select: { role_code: true }, }); return sendData(reply, request, { ...mapEmployeeSummary({ ...created, roles }), }); }, ); fastify.patch( `${routeBasePath}/:id`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const userId = parseUserId(request.params); const body = parseUpdateBody(request.body); const now = new Date(); const hireDate = typeof body.hireDate === "string" ? new Date(`${body.hireDate}T00:00:00.000Z`) : null; if (hireDate && Number.isNaN(hireDate.getTime())) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { hireDate: "invalid_date" }, }); } if (typeof body.departmentId === "string") { const department = await fastify.prisma.department.findUnique({ where: { id: body.departmentId }, select: { id: true, archived_at: true }, }); if (!department) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { departmentId: "not_found" }, }); } if (department.archived_at) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { departmentId: "archived" }, }); } } if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) { if (body.directManagerId === userId) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { directManagerId: "self_not_allowed" }, }); } if (typeof body.directManagerId === "string") { const manager = await fastify.prisma.employee.findUnique({ where: { id: body.directManagerId }, select: { id: true, status: true }, }); if (!manager) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { directManagerId: "not_found" }, }); } if (manager.status !== "active") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { directManagerId: "inactive" }, }); } } } let updated = null; try { updated = await fastify.prisma.$transaction(async (tx) => { const current = await tx.employee.findUnique({ where: { id: userId }, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, title: true, direct_manager_id: true, hire_date: true, bank_account_encrypted: true, bank_name: true, id_number_encrypted: true, archived_at: true, created_at: true, updated_at: true, roles: { select: { role_code: true } }, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const changes = {}; if (typeof body.employeeNo === "string" && body.employeeNo !== current.employee_no) { changes.employeeNo = { before: current.employee_no, after: body.employeeNo }; } if (typeof body.name === "string" && body.name !== current.name) { changes.name = { before: current.name, after: body.name }; } if (typeof body.phone === "string" && body.phone !== current.phone) { changes.phone = { before: current.phone, after: body.phone }; } if (typeof body.email === "string" && body.email !== current.email) { changes.email = { before: current.email, after: body.email }; } if (typeof body.departmentId === "string" && body.departmentId !== current.department_id) { changes.departmentId = { before: current.department_id, after: body.departmentId }; } if (typeof body.title === "string" && body.title !== current.title) { changes.title = { before: current.title, after: body.title }; } if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) { changes.directManagerId = { before: current.direct_manager_id, after: body.directManagerId ?? null }; } if (typeof body.hireDate === "string") { const currentHireDate = current.hire_date?.toISOString?.()?.slice(0, 10) ?? null; if (body.hireDate !== currentHireDate) { changes.hireDate = { before: currentHireDate, after: body.hireDate }; } } const employee = await tx.employee.update({ where: { id: userId }, data: { ...(typeof body.employeeNo === "string" ? { employee_no: body.employeeNo } : {}), ...(typeof body.name === "string" ? { name: body.name } : {}), ...(typeof body.phone === "string" ? { phone: body.phone } : {}), ...(typeof body.email === "string" ? { email: body.email } : {}), ...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}), ...(typeof body.title === "string" ? { title: body.title } : {}), ...(Object.prototype.hasOwnProperty.call(body, "directManagerId") ? { direct_manager_id: body.directManagerId ?? null } : {}), ...(typeof body.hireDate === "string" ? { hire_date: hireDate } : {}), updated_at: now, updated_by: request.employee.id, }, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, title: true, direct_manager_id: true, hire_date: true, bank_account_encrypted: true, bank_name: true, id_number_encrypted: true, archived_at: true, created_at: true, updated_at: true, roles: { select: { role_code: true } }, }, }); if (Object.keys(changes).length) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_UPDATED", targetType: "employee", targetId: userId, extra: { changes }, }), }); } return employee; }); } catch (error) { if (error?.code === "P2002") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: normalizeUniqueErrorDetails(error), }); } throw error; } return sendData( reply, request, mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }), ); }, ); fastify.get( `${routeBasePath}/:id/sensitive`, { preHandler: fastify.requirePermission("system.users_sensitive"), }, async (request, reply) => { const userId = parseUserId(request.params); const employee = await fastify.prisma.employee.findUnique({ where: { id: userId }, select: { id: true, bank_name: true, bank_account_encrypted: true, id_number_encrypted: true, }, }); if (!employee) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } let bankAccount = null; let idNumber = null; try { bankAccount = decryptStringOrThrow(fastify.config, employee.bank_account_encrypted); idNumber = decryptStringOrThrow(fastify.config, employee.id_number_encrypted); } catch (error) { fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured"); throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500, details: {}, }); } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_SENSITIVE_VIEWED", targetType: "employee", targetId: userId, extra: {}, }), }); return sendData(reply, request, { bankName: employee.bank_name ? String(employee.bank_name) : null, bankAccount, idNumber, }); }, ); fastify.patch( `${routeBasePath}/:id/sensitive`, { preHandler: fastify.requirePermission("system.users_sensitive"), }, async (request, reply) => { const userId = parseUserId(request.params); const body = parseSensitiveBody(request.body); const now = new Date(); let updated = null; try { updated = await fastify.prisma.$transaction(async (tx) => { const current = await tx.employee.findUnique({ where: { id: userId }, select: { id: true, bank_name: true, bank_account_encrypted: true, id_number_encrypted: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, title: true, direct_manager_id: true, hire_date: true, archived_at: true, created_at: true, updated_at: true, roles: { select: { role_code: true } }, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const changes = {}; const data = { updated_at: now, updated_by: request.employee.id, }; if (Object.prototype.hasOwnProperty.call(body, "bankName")) { const nextBankName = typeof body.bankName === "string" ? body.bankName.trim() : body.bankName; data.bank_name = nextBankName ? String(nextBankName) : ""; changes.bankName = { before: current.bank_name ? String(current.bank_name) : null, after: data.bank_name || null, }; } if (Object.prototype.hasOwnProperty.call(body, "bankAccount")) { const nextBankAccount = typeof body.bankAccount === "string" ? body.bankAccount.trim() : body.bankAccount; data.bank_account_encrypted = nextBankAccount ? encryptStringOrThrow(fastify.config, nextBankAccount) : ""; changes.bankAccountMasked = nextBankAccount ? maskBankAccount(nextBankAccount) : null; } if (Object.prototype.hasOwnProperty.call(body, "idNumber")) { const nextIdNumber = typeof body.idNumber === "string" ? body.idNumber.trim() : body.idNumber; data.id_number_encrypted = nextIdNumber ? encryptStringOrThrow(fastify.config, nextIdNumber) : null; changes.idNumberMasked = nextIdNumber ? maskIdNumber(nextIdNumber) : null; } const employee = await tx.employee.update({ where: { id: userId }, data, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, title: true, direct_manager_id: true, hire_date: true, bank_account_encrypted: true, bank_name: true, id_number_encrypted: true, archived_at: true, created_at: true, updated_at: true, roles: { select: { role_code: true } }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_SENSITIVE_UPDATED", targetType: "employee", targetId: userId, extra: { changes }, }), }); return employee; }); } catch (error) { if (String(error?.message ?? "").includes("FIELD_ENCRYPTION_KEY")) { fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured"); throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500, details: {}, }); } throw error; } return sendData( reply, request, mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }), ); }, ); fastify.post( `${routeBasePath}/:id/archive`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const userId = parseUserId(request.params); if (userId === request.employee.id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { user: "self_disable_not_allowed" }, }); } const now = new Date(); const employee = await fastify.prisma.$transaction(async (tx) => { const updated = await tx.employee.update({ where: { id: userId }, data: { status: "archived", archived_at: now, token_version: { increment: 1 }, updated_by: request.employee.id, }, select: { id: true, status: true, token_version: true, }, }); await tx.authSession.updateMany({ where: { employee_id: userId, revoked_at: null }, data: { revoked_at: now }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_DISABLED", targetType: "employee", targetId: userId, extra: {}, }), }); return updated; }); return sendData(reply, request, { id: employee.id, status: employee.status, }); }, ); fastify.post( `${routeBasePath}/:id/disable`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const userId = parseUserId(request.params); if (userId === request.employee.id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { user: "self_disable_not_allowed" }, }); } const now = new Date(); const employee = await fastify.prisma.$transaction(async (tx) => { const updated = await tx.employee.update({ where: { id: userId }, data: { status: "archived", archived_at: now, token_version: { increment: 1 }, updated_by: request.employee.id, }, select: { id: true, status: true, token_version: true, }, }); await tx.authSession.updateMany({ where: { employee_id: userId, revoked_at: null }, data: { revoked_at: now }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_DISABLED", targetType: "employee", targetId: userId, extra: {}, }), }); return updated; }); return sendData(reply, request, { id: employee.id, status: employee.status, }); }, ); fastify.post( `${routeBasePath}/:id/enable`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const userId = parseUserId(request.params); const now = new Date(); const employee = await fastify.prisma.$transaction(async (tx) => { const updated = await tx.employee.update({ where: { id: userId }, data: { status: "active", archived_at: null, token_version: { increment: 1 }, updated_by: request.employee.id, }, select: { id: true, status: true, token_version: true, }, }); await tx.authSession.updateMany({ where: { employee_id: userId, revoked_at: null }, data: { revoked_at: now }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_ENABLED", targetType: "employee", targetId: userId, extra: {}, }), }); return updated; }); return sendData(reply, request, { id: employee.id, status: employee.status, }); }, ); fastify.post( `${routeBasePath}/:id/reset-password`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const userId = parseUserId(request.params); const parsed = resetPasswordBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const newPassword = typeof parsed.data.newPassword === "string" && parsed.data.newPassword.trim() ? parsed.data.newPassword.trim() : null; const oneTimePassword = newPassword ?? `ResetA1${crypto.randomBytes(6).toString("hex")}`; const errors = validatePassword(oneTimePassword); if (errors.length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { newPassword: errors }, }); } const passwordHash = await hashPassword(oneTimePassword); const now = new Date(); await fastify.prisma.$transaction(async (tx) => { await tx.employee.update({ where: { id: userId }, data: { password_hash: passwordHash, must_change_password: true, login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, token_version: { increment: 1 }, updated_by: request.employee.id, }, }); await tx.authSession.updateMany({ where: { employee_id: userId, revoked_at: null }, data: { revoked_at: now }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_PASSWORD_RESET", targetType: "employee", targetId: userId, extra: {}, }), }); }); return sendData(reply, request, { id: userId, oneTimePassword: oneTimePassword, }); }, ); fastify.post( `${routeBasePath}/:id/unlock`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const userId = parseUserId(request.params); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.employee.findUnique({ where: { id: userId }, select: { id: true, login_locked_until: true, login_failed_count: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const wasLocked = Boolean(current.login_locked_until && current.login_locked_until > now); await tx.employee.update({ where: { id: userId }, data: { login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, updated_by: request.employee.id, updated_at: now, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_UNLOCKED", targetType: "employee", targetId: userId, extra: { previousLockedUntil: current.login_locked_until ? current.login_locked_until.toISOString() : null, previousFailedCount: current.login_failed_count ?? 0, }, }), }); return { wasLocked }; }); return sendData(reply, request, { id: userId, outcome: result.wasLocked ? "unlocked" : "already_unlocked", }); }, ); fastify.post( `${routeBasePath}/:id/roles`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const userId = parseUserId(request.params); if (userId === request.employee.id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { user: "self_role_change_not_allowed" }, }); } const parsed = setRolesBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const roles = parsed.data.roles; const now = new Date(); await fastify.prisma.$transaction(async (tx) => { await tx.employeeRole.deleteMany({ where: { employee_id: userId }, }); await tx.employeeRole.createMany({ data: roles.map((roleCode) => ({ employee_id: userId, role_code: roleCode, created_at: now, created_by: request.employee.id, })), skipDuplicates: true, }); await tx.employee.update({ where: { id: userId }, data: { token_version: { increment: 1 }, updated_by: request.employee.id, }, }); await tx.authSession.updateMany({ where: { employee_id: userId, revoked_at: null }, data: { revoked_at: now }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USER_ROLES_SET", targetType: "employee", targetId: userId, extra: { roles }, }), }); }); return sendData(reply, request, { id: userId, roleCodes: roles, }); }, ); } module.exports = usersRoutes;