文件
hyb-oa/server/src/routes/users.js
T

2141 行
64 KiB
JavaScript

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;