feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
+472
-10
@@ -1,3 +1,5 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
@@ -32,11 +34,22 @@ const createBodySchema = z.object({
|
||||
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(),
|
||||
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),
|
||||
});
|
||||
@@ -104,6 +117,29 @@ function parseCreateBody(body) {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -120,6 +156,15 @@ function mapEmployeeSummary(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmployeeDetail(row) {
|
||||
return {
|
||||
...mapEmployeeSummary(row),
|
||||
directManagerId: row.direct_manager_id ?? null,
|
||||
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
archivedAt: row.archived_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUniqueErrorDetails(error) {
|
||||
const meta = error?.meta ?? {};
|
||||
const target = meta.target ?? meta?.cause?.constraint?.fields ?? null;
|
||||
@@ -135,9 +180,14 @@ function normalizeUniqueErrorDetails(error) {
|
||||
return Object.keys(details).length ? details : { unique: "duplicate" };
|
||||
}
|
||||
|
||||
async function usersRoutes(fastify) {
|
||||
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(
|
||||
"/users",
|
||||
routeBasePath,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -192,8 +242,61 @@ async function usersRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
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));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users",
|
||||
routeBasePath,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -204,7 +307,7 @@ async function usersRoutes(fastify) {
|
||||
|
||||
const department = await fastify.prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: { id: true },
|
||||
select: { id: true, archived_at: true },
|
||||
});
|
||||
if (!department) {
|
||||
throw new AppError({
|
||||
@@ -215,6 +318,38 @@ async function usersRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -236,6 +371,7 @@ async function usersRoutes(fastify) {
|
||||
bank_account_encrypted: "ENC_TBD",
|
||||
bank_name: "TBD",
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
created_at: now,
|
||||
created_by: request.employee.id,
|
||||
updated_at: now,
|
||||
@@ -305,8 +441,209 @@ async function usersRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
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,
|
||||
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));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/disable",
|
||||
`${routeBasePath}/:id/archive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -364,7 +701,65 @@ async function usersRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/enable",
|
||||
`${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"),
|
||||
},
|
||||
@@ -414,7 +809,7 @@ async function usersRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/reset-password",
|
||||
`${routeBasePath}/:id/reset-password`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
},
|
||||
@@ -454,6 +849,10 @@ async function usersRoutes(fastify) {
|
||||
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,
|
||||
},
|
||||
@@ -483,7 +882,71 @@ async function usersRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/users/:id/roles",
|
||||
`${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"),
|
||||
},
|
||||
@@ -559,4 +1022,3 @@ async function usersRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = usersRoutes;
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户