文件
hyb-oa/server/src/routes/users.js
T
wangsli 1855a82011 feat(server): 敏感字段加密与脱敏(F25)
- 新增敏感字段加密服务(AES-256-GCM,FIELD_ENCRYPTION_KEY)
- users 详情返回银行卡/身份证脱敏字段;新增 /users/:id/sensitive 特权明文接口并写审计
- 权限新增 system.users_sensitive(hr/admin)
- 更新 env/README 与 v3 CSV 状态推进
2026-02-09 20:51:34 +08:00

1283 行
37 KiB
JavaScript

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