const crypto = require("node:crypto"); const path = require("node:path"); const { Readable } = require("node:stream"); const ExcelJS = require("exceljs"); const { z } = require("zod"); const { EMPLOYMENT_STATUS_VALUES, buildEmployeeDirectoryWhere, parseEmployeeDirectoryFilters, } = require("../services/employeeDirectoryFilters"); const { applyEmployeeTransfer } = require("../services/employeeLifecycle"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); 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 { currentDateString } = require("../utils/date"); const roleCodeSchema = z.enum([ "employee", "manager", "ceo", "finance", "finance_mgr", "administration", "hr", "admin", ]); const listPaginationSchema = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).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(); const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"]; const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"]; const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]); const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([ "text/csv", "application/csv", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ]); const USER_IMPORT_MAX_ROWS = 500; const BATCH_EMPLOYEE_IDS_SCHEMA = z.array(z.string().uuid()).min(1); const BATCH_TRANSFER_DEPARTMENT_SCHEMA = z.object({ employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA, departmentId: z.string().uuid(), }).strict(); const BATCH_TOGGLE_SCHEMA = z.object({ employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA, }).strict(); const BATCH_SET_MANAGER_SCHEMA = z.object({ employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA, managerId: z.string().uuid(), }).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, options = {}) { const parsed = listPaginationSchema.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, ...parseEmployeeDirectoryFilters(query, options), }; } 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, department: row.department ? { id: row.department.id, name: row.department.name } : null, directManagerId: row.direct_manager_id ?? null, directManager: row.direct_manager ? { id: row.direct_manager.id, employeeNo: row.direct_manager.employee_no, name: row.direct_manager.name, } : null, title: row.title, employmentStatus: row.employment_status ?? null, hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null, avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null, 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; } function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseBatchBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const employeeIds = [...new Set(parsed.data.employeeIds)]; if (employeeIds.length > 50) { throw buildValidationError({ employeeIds: "too_many" }, 409); } return { ...parsed.data, employeeIds, }; } function ensureHasAnyPermission(fastify, request, permissionCodes) { if (permissionCodes.some((code) => fastify.hasPermission(request.employee, code))) { return; } throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } async function ensureLifecycleFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.employeeLifecycle, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); } } async function ensureDepartmentUsable(prisma, departmentId, fieldName = "departmentId") { const department = await prisma.department.findUnique({ where: { id: departmentId }, select: { id: true, archived_at: true }, }); if (!department) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { [fieldName]: "not_found" }, }); } if (department.archived_at) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { [fieldName]: "archived" }, }); } } async function ensureManagerUsable(prisma, managerId, options = {}) { const fieldName = options.fieldName ?? "directManagerId"; const disallowedEmployeeIds = options.disallowedEmployeeIds ?? []; if (disallowedEmployeeIds.includes(managerId)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { [fieldName]: "self_not_allowed" }, }); } const manager = await prisma.employee.findUnique({ where: { id: managerId }, select: { id: true, status: true }, }); if (!manager) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { [fieldName]: "not_found" }, }); } if (manager.status !== "active") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { [fieldName]: "inactive" }, }); } } async function ensureEmployeesExist(prisma, employeeIds) { const employees = await prisma.employee.findMany({ where: { id: { in: employeeIds } }, select: { id: true }, }); if (employees.length !== employeeIds.length) { const existingIds = new Set(employees.map((item) => item.id)); const missingId = employeeIds.find((item) => !existingIds.has(item)); throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, details: { employeeId: missingId ?? null }, }); } } async function setEmployeeAccountStatus(tx, request, options) { const updated = await tx.employee.update({ where: { id: options.employeeId }, data: { status: options.status, archived_at: options.status === "archived" ? options.now : null, token_version: { increment: 1 }, updated_by: options.actorId, }, select: { id: true, status: true, token_version: true, }, }); await tx.authSession.updateMany({ where: { employee_id: options.employeeId, revoked_at: null }, data: { revoked_at: options.now }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: options.actorId, action: options.status === "archived" ? "SYSTEM_USER_DISABLED" : "SYSTEM_USER_ENABLED", targetType: "employee", targetId: options.employeeId, extra: {}, }), }); return updated; } function generateOneTimePassword() { const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`; const errors = validatePassword(password); if (errors.length) { throw buildValidationError({ password: errors }); } return password; } function normalizeImportHeader(value) { return String(value ?? "").replace(/^\ufeff/, "").trim(); } function normalizeImportCell(value) { if (value == null) { return ""; } if (value instanceof Date) { return value.toISOString().slice(0, 10); } if (typeof value === "object") { if (Array.isArray(value.richText)) { return value.richText.map((item) => item?.text ?? "").join("").trim(); } if (typeof value.text === "string") { return value.text.trim(); } if (value.result != null) { return normalizeImportCell(value.result); } } return String(value).trim(); } function normalizeImportRoles(rawValue) { const raw = normalizeImportCell(rawValue); if (!raw) { return ["employee"]; } const values = raw .split(",") .map((item) => item.trim()) .filter(Boolean); return values.length ? values : ["employee"]; } function buildImportRowDraft(rawRow, defaultHireDate) { const normalized = { employeeNo: normalizeImportCell(rawRow.data.employeeNo), name: normalizeImportCell(rawRow.data.name), phone: normalizeImportCell(rawRow.data.phone), email: normalizeImportCell(rawRow.data.email), departmentName: normalizeImportCell(rawRow.data.departmentName), title: normalizeImportCell(rawRow.data.title) || "员工", directManagerEmployeeNo: normalizeImportCell(rawRow.data.directManagerEmployeeNo), roles: normalizeImportRoles(rawRow.data.roles), hireDate: normalizeImportCell(rawRow.data.hireDate) || defaultHireDate, }; return { rowNumber: rawRow.rowNumber, employeeNo: normalized.employeeNo, name: normalized.name, normalized, errors: [], resolvedDepartmentId: null, resolvedDirectManagerId: null, resolvedImportedManagerEmployeeNo: null, }; } function pushImportRowError(row, field, message) { const key = `${field}:${message}`; if (row.errors.some((item) => `${item.field}:${item.message}` === key)) { return; } row.errors.push({ field, message }); } function markDuplicateImportValues(rows, key, field) { const byValue = new Map(); for (const row of rows) { const value = row.normalized[key]; if (!value) { continue; } const bucket = byValue.get(value) ?? []; bucket.push(row); byValue.set(value, bucket); } for (const bucket of byValue.values()) { if (bucket.length <= 1) { continue; } for (const row of bucket) { pushImportRowError(row, field, "duplicate_in_file"); } } } function resolveWorksheetRows(worksheet) { if (!worksheet) { return { headers: [], rows: [] }; } const headerRow = worksheet.getRow(1); const headers = headerRow.values.slice(1).map((value) => normalizeImportHeader(value)); const rows = []; worksheet.eachRow((row, rowNumber) => { if (rowNumber === 1) { return; } const data = {}; headers.forEach((header, index) => { if (!header) { return; } data[header] = normalizeImportCell(row.getCell(index + 1).value); }); if (Object.values(data).some((value) => value !== "")) { rows.push({ rowNumber, data }); } }); return { headers, rows }; } async function parseUserImportRowsFromBuffer(buffer, extension) { const workbook = new ExcelJS.Workbook(); if (extension === ".csv") { const worksheet = await workbook.csv.read(Readable.from([buffer])); return resolveWorksheetRows(worksheet); } await workbook.xlsx.load(buffer); return resolveWorksheetRows(workbook.worksheets[0] ?? null); } async function readUserImportPayload(request) { let filePart = null; try { filePart = await request.file(); } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { throw buildValidationError({ file: "file_too_large" }); } throw error; } if (!filePart) { throw buildValidationError({ file: "missing" }); } const extension = path.extname(filePart.filename ?? "").toLowerCase(); if (!USER_IMPORT_ALLOWED_EXTENSIONS.has(extension)) { throw buildValidationError({ file: "unsupported_extension" }); } if (!USER_IMPORT_ALLOWED_MIME_TYPES.has(filePart.mimetype)) { throw buildValidationError({ file: "unsupported_mime_type" }); } const buffer = await filePart.toBuffer(); const parsed = await parseUserImportRowsFromBuffer(buffer, extension); const missingHeaders = USER_IMPORT_REQUIRED_HEADERS.filter((header) => !parsed.headers.includes(header)); if (missingHeaders.length) { throw buildValidationError({ file: "missing_headers", missingHeaders }); } if (!parsed.rows.length) { throw buildValidationError({ file: "empty" }); } if (parsed.rows.length > USER_IMPORT_MAX_ROWS) { throw buildValidationError({ file: "row_limit_exceeded", limit: USER_IMPORT_MAX_ROWS }); } return { filename: filePart.filename ?? "users-import", rows: parsed.rows, }; } function validateImportRowFields(row) { const { normalized } = row; if (!normalized.employeeNo) { pushImportRowError(row, "employeeNo", "required"); } else if (normalized.employeeNo.length > 50) { pushImportRowError(row, "employeeNo", "too_long"); } if (!normalized.name) { pushImportRowError(row, "name", "required"); } else if (normalized.name.length > 100) { pushImportRowError(row, "name", "too_long"); } if (!normalized.phone) { pushImportRowError(row, "phone", "required"); } else if (normalized.phone.length < 6) { pushImportRowError(row, "phone", "too_short"); } else if (normalized.phone.length > 30) { pushImportRowError(row, "phone", "too_long"); } if (!normalized.email) { pushImportRowError(row, "email", "required"); } else { const parsedEmail = z.string().trim().email().max(200).safeParse(normalized.email); if (!parsedEmail.success) { pushImportRowError(row, "email", "invalid"); } } if (!normalized.departmentName) { pushImportRowError(row, "departmentName", "required"); } else if (normalized.departmentName.length > 100) { pushImportRowError(row, "departmentName", "too_long"); } if (!normalized.title) { pushImportRowError(row, "title", "required"); } else if (normalized.title.length > 100) { pushImportRowError(row, "title", "too_long"); } if (normalized.directManagerEmployeeNo && normalized.directManagerEmployeeNo.length > 50) { pushImportRowError(row, "directManagerEmployeeNo", "too_long"); } const parsedHireDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).safeParse(normalized.hireDate); if (!parsedHireDate.success) { pushImportRowError(row, "hireDate", "invalid"); } if (!normalized.roles.length) { pushImportRowError(row, "roles", "required"); } for (const roleCode of normalized.roles) { if (!roleCodeSchema.safeParse(roleCode).success) { pushImportRowError(row, "roles", `invalid_role:${roleCode}`); } } } function createDepartmentResolutionMap(departments) { const map = new Map(); for (const item of departments) { const bucket = map.get(item.name) ?? []; bucket.push(item); map.set(item.name, bucket); } return map; } function createEmployeeLookupMaps(employees) { return { byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])), byPhone: new Map(employees.map((item) => [item.phone, item])), byEmail: new Map(employees.map((item) => [item.email, item])), }; } async function buildUserImportPreview(prisma, rawRows, options = {}) { const defaultHireDate = currentDateString(options.timeZone ?? "Asia/Shanghai"); const previewRows = rawRows.map((row) => buildImportRowDraft(row, defaultHireDate)); for (const row of previewRows) { validateImportRowFields(row); } markDuplicateImportValues(previewRows, "employeeNo", "employeeNo"); markDuplicateImportValues(previewRows, "phone", "phone"); markDuplicateImportValues(previewRows, "email", "email"); const departmentNames = Array.from( new Set(previewRows.map((row) => row.normalized.departmentName).filter(Boolean)), ); const employeeNos = Array.from( new Set( previewRows.flatMap((row) => [row.normalized.employeeNo, row.normalized.directManagerEmployeeNo].filter(Boolean), ), ), ); const phones = Array.from(new Set(previewRows.map((row) => row.normalized.phone).filter(Boolean))); const emails = Array.from(new Set(previewRows.map((row) => row.normalized.email).filter(Boolean))); const [departments, employees] = await Promise.all([ departmentNames.length ? prisma.department.findMany({ where: { name: { in: departmentNames } }, select: { id: true, name: true, archived_at: true }, }) : Promise.resolve([]), employeeNos.length || phones.length || emails.length ? prisma.employee.findMany({ where: { OR: [ ...(employeeNos.length ? [{ employee_no: { in: employeeNos } }] : []), ...(phones.length ? [{ phone: { in: phones } }] : []), ...(emails.length ? [{ email: { in: emails } }] : []), ], }, select: { id: true, employee_no: true, phone: true, email: true, status: true, }, }) : Promise.resolve([]), ]); const departmentsByName = createDepartmentResolutionMap(departments); const existingLookup = createEmployeeLookupMaps(employees); for (const row of previewRows) { const departmentMatches = departmentsByName.get(row.normalized.departmentName) ?? []; const activeDepartmentMatches = departmentMatches.filter((item) => !item.archived_at); if (!departmentMatches.length) { pushImportRowError(row, "departmentName", "not_found"); } else if (!activeDepartmentMatches.length) { pushImportRowError(row, "departmentName", "archived"); } else if (activeDepartmentMatches.length > 1) { pushImportRowError(row, "departmentName", "ambiguous"); } else { row.resolvedDepartmentId = activeDepartmentMatches[0].id; } const employeeNoConflict = existingLookup.byEmployeeNo.get(row.normalized.employeeNo); if (employeeNoConflict) { pushImportRowError(row, "employeeNo", "duplicate_existing"); } const phoneConflict = existingLookup.byPhone.get(row.normalized.phone); if (phoneConflict) { pushImportRowError(row, "phone", "duplicate_existing"); } const emailConflict = existingLookup.byEmail.get(row.normalized.email); if (emailConflict) { pushImportRowError(row, "email", "duplicate_existing"); } if (!row.normalized.directManagerEmployeeNo) { continue; } if (row.normalized.directManagerEmployeeNo === row.normalized.employeeNo) { pushImportRowError(row, "directManagerEmployeeNo", "self_not_allowed"); continue; } const existingManager = existingLookup.byEmployeeNo.get(row.normalized.directManagerEmployeeNo); if (existingManager) { if (existingManager.status !== "active") { pushImportRowError(row, "directManagerEmployeeNo", "inactive"); } else { row.resolvedDirectManagerId = existingManager.id; } } } const importRowsByEmployeeNo = new Map( previewRows .filter((row) => row.normalized.employeeNo && !row.errors.some((item) => item.field === "employeeNo")) .map((row) => [row.normalized.employeeNo, row]), ); const resolvedRows = new Set(); const resolvingRows = new Set(); function resolveImportedManager(row) { if (!row.normalized.directManagerEmployeeNo || row.resolvedDirectManagerId) { resolvedRows.add(row); return; } if (resolvedRows.has(row)) { return; } if (resolvingRows.has(row)) { pushImportRowError(row, "directManagerEmployeeNo", "cycle_not_allowed"); return; } const importedManager = importRowsByEmployeeNo.get(row.normalized.directManagerEmployeeNo); if (!importedManager) { pushImportRowError(row, "directManagerEmployeeNo", "not_found"); resolvedRows.add(row); return; } resolvingRows.add(row); resolveImportedManager(importedManager); resolvingRows.delete(row); if (importedManager.errors.length) { pushImportRowError(row, "directManagerEmployeeNo", "referenced_row_invalid"); } else { row.resolvedImportedManagerEmployeeNo = importedManager.normalized.employeeNo; } resolvedRows.add(row); } for (const row of previewRows) { resolveImportedManager(row); } return { summary: { totalRows: previewRows.length, validRows: previewRows.filter((row) => row.errors.length === 0).length, invalidRows: previewRows.filter((row) => row.errors.length > 0).length, }, rows: previewRows.map((row) => ({ rowNumber: row.rowNumber, employeeNo: row.employeeNo, name: row.name, normalized: row.normalized, errors: row.errors, })), validRows: previewRows.filter((row) => row.errors.length === 0), }; } async function createSystemUser(tx, options) { const now = options.now ?? new Date(); const passwordHash = await hashPassword(options.password); const employee = await tx.employee.create({ data: { employee_no: options.employeeNo, name: options.name, phone: options.phone, email: options.email, department_id: options.departmentId, title: options.title ?? "员工", direct_manager_id: options.directManagerId ?? null, hire_date: options.hireDate, status: "active", bank_account_encrypted: "", bank_name: "", password_hash: passwordHash, must_change_password: true, created_at: now, created_by: options.actorId, updated_at: now, updated_by: options.actorId, }, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, department_id: true, direct_manager_id: true, title: true, employment_status: true, hire_date: true, created_at: true, updated_at: true, }, }); await tx.employeeRole.createMany({ data: options.roles.map((roleCode) => ({ employee_id: employee.id, role_code: roleCode, created_at: now, created_by: options.actorId, })), skipDuplicates: true, }); return employee; } 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; const allowDepartmentFilter = opts.allowDepartmentFilter === true; const listPermissionCodes = Array.isArray(opts.listPermissionCodes) && opts.listPermissionCodes.length ? [...new Set(opts.listPermissionCodes)] : ["system.users"]; const enableBatchActions = routeBasePath === "/users" && opts.enableBatchActions !== false; fastify.get( routeBasePath, { preHandler: fastify.authenticate, }, async (request, reply) => { ensureHasAnyPermission(fastify, request, listPermissionCodes); const query = parseListQuery(request.query, { allowDepartmentFilter }); const skip = (query.page - 1) * query.pageSize; const where = buildEmployeeDirectoryWhere(query); 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, direct_manager_id: true, title: true, employment_status: true, hire_date: true, avatar_path: true, created_at: true, updated_at: true, department: { select: { id: true, name: true } }, direct_manager: { select: { id: true, employee_no: true, name: 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; await ensureDepartmentUsable(fastify.prisma, departmentId); if (typeof body.directManagerId === "string") { await ensureManagerUsable(fastify.prisma, body.directManagerId); } const now = new Date(); let created = null; try { created = await fastify.prisma.$transaction(async (tx) => { const employee = await createSystemUser(tx, { employeeNo: body.employeeNo, name: body.name, phone: body.phone, email: body.email, departmentId, title: body.title ?? "员工", directManagerId: body.directManagerId ?? null, hireDate, password: body.password, roles: body.roles, actorId: request.employee.id, now, }); 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 }), }); }, ); if (enableBatchActions) { fastify.post( `${routeBasePath}/batch/transfer-department`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { await ensureLifecycleFeatureEnabled(fastify, request); const body = parseBatchBody(BATCH_TRANSFER_DEPARTMENT_SCHEMA, request.body); await ensureDepartmentUsable(fastify.prisma, body.departmentId); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeesExist(tx, body.employeeIds); for (const employeeId of body.employeeIds) { await applyEmployeeTransfer(tx, { employeeId, actorId: request.employee.id, departmentId: body.departmentId, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USERS_BATCH_TRANSFER_DEPARTMENT", targetType: "employee_batch", extra: { employeeIds: body.employeeIds, departmentId: body.departmentId, summary: { total: body.employeeIds.length, updated: body.employeeIds.length, }, }, }), }); }); return sendData(reply, request, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, employeeIds: body.employeeIds, }); }, ); fastify.post( `${routeBasePath}/batch/set-manager`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { await ensureLifecycleFeatureEnabled(fastify, request); const body = parseBatchBody(BATCH_SET_MANAGER_SCHEMA, request.body); await ensureManagerUsable(fastify.prisma, body.managerId, { fieldName: "managerId", disallowedEmployeeIds: body.employeeIds, }); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeesExist(tx, body.employeeIds); for (const employeeId of body.employeeIds) { await applyEmployeeTransfer(tx, { employeeId, actorId: request.employee.id, directManagerId: body.managerId, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USERS_BATCH_SET_MANAGER", targetType: "employee_batch", extra: { employeeIds: body.employeeIds, managerId: body.managerId, summary: { total: body.employeeIds.length, updated: body.employeeIds.length, }, }, }), }); }); return sendData(reply, request, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, employeeIds: body.employeeIds, }); }, ); fastify.post( `${routeBasePath}/batch/disable`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body); if (body.employeeIds.includes(request.employee.id)) { throw buildValidationError({ user: "self_disable_not_allowed" }, 409); } const now = new Date(); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeesExist(tx, body.employeeIds); for (const employeeId of body.employeeIds) { await setEmployeeAccountStatus(tx, request, { employeeId, actorId: request.employee.id, status: "archived", now, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USERS_BATCH_DISABLED", targetType: "employee_batch", extra: { employeeIds: body.employeeIds, summary: { total: body.employeeIds.length, updated: body.employeeIds.length, }, }, }), }); }); return sendData(reply, request, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, employeeIds: body.employeeIds, }); }, ); fastify.post( `${routeBasePath}/batch/enable`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body); const now = new Date(); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeesExist(tx, body.employeeIds); for (const employeeId of body.employeeIds) { await setEmployeeAccountStatus(tx, request, { employeeId, actorId: request.employee.id, status: "active", now, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USERS_BATCH_ENABLED", targetType: "employee_batch", extra: { employeeIds: body.employeeIds, summary: { total: body.employeeIds.length, updated: body.employeeIds.length, }, }, }), }); }); return sendData(reply, request, { summary: { total: body.employeeIds.length, updated: body.employeeIds.length }, employeeIds: body.employeeIds, }); }, ); } if (routeBasePath === "/users") { fastify.post( `${routeBasePath}/import/dry-run`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const { filename, rows } = await readUserImportPayload(request); const preview = await buildUserImportPreview(fastify.prisma, rows, { timeZone: fastify.config.appTimezone, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USERS_IMPORT_DRY_RUN", targetType: "employee_import", extra: { sourceFilename: filename, ...preview.summary, }, }), }); return sendData(reply, request, { sourceFilename: filename, summary: preview.summary, rows: preview.rows, }); }, ); fastify.post( `${routeBasePath}/import/apply`, { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const { filename, rows } = await readUserImportPayload(request); let result = null; try { result = await fastify.prisma.$transaction(async (tx) => { const preview = await buildUserImportPreview(tx, rows, { timeZone: fastify.config.appTimezone, }); if (preview.summary.invalidRows > 0) { throw buildValidationError( { summary: preview.summary, rows: preview.rows, }, 409, ); } const now = new Date(); const createdRows = []; const createdByEmployeeNo = new Map(); for (const row of preview.validRows) { const oneTimePassword = generateOneTimePassword(); const employee = await createSystemUser(tx, { employeeNo: row.normalized.employeeNo, name: row.normalized.name, phone: row.normalized.phone, email: row.normalized.email, departmentId: row.resolvedDepartmentId, title: row.normalized.title, directManagerId: row.resolvedImportedManagerEmployeeNo ? null : row.resolvedDirectManagerId, hireDate: new Date(`${row.normalized.hireDate}T00:00:00.000Z`), password: oneTimePassword, roles: row.normalized.roles, actorId: request.employee.id, now, }); const item = { employee, row, oneTimePassword, }; createdRows.push(item); createdByEmployeeNo.set(row.normalized.employeeNo, item); } for (const item of createdRows) { if (!item.row.resolvedImportedManagerEmployeeNo) { continue; } const manager = createdByEmployeeNo.get(item.row.resolvedImportedManagerEmployeeNo); await tx.employee.update({ where: { id: item.employee.id }, data: { direct_manager_id: manager.employee.id, updated_at: now, updated_by: request.employee.id, }, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SYSTEM_USERS_IMPORTED", targetType: "employee_import", extra: { sourceFilename: filename, importedCount: createdRows.length, employeeNos: createdRows.map((item) => item.employee.employee_no), }, }), }); return { sourceFilename: filename, summary: preview.summary, imported: createdRows.map((item) => ({ employeeId: item.employee.id, employeeNo: item.employee.employee_no, name: item.employee.name, email: item.employee.email, oneTimePassword: item.oneTimePassword, })), }; }); } catch (error) { if (error?.code === "P2002") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: normalizeUniqueErrorDetails(error), }); } throw error; } return sendData(reply, request, result); }, ); } 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") { await ensureDepartmentUsable(fastify.prisma, body.departmentId); } if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) { if (typeof body.directManagerId === "string") { await ensureManagerUsable(fastify.prisma, body.directManagerId, { disallowedEmployeeIds: [userId], }); } } 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) => { return setEmployeeAccountStatus(tx, request, { employeeId: userId, actorId: request.employee.id, status: "archived", now, }); }); 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) => { return setEmployeeAccountStatus(tx, request, { employeeId: userId, actorId: request.employee.id, status: "active", now, }); }); 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;