完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)

这个提交包含在:
2026-07-18 15:46:14 +08:00
父节点 58c8decef9
当前提交 5d0a5f9a38
共修改 134 个文件,包含 10790 行新增和 1413 行删除
+1 -1
查看文件
@@ -10,7 +10,7 @@ const {
listFinanceLedger,
} = require("../services/financeLedger");
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "payment.batch.review", "risk.review.manage", "expense.finance.pay"];
const FINANCE_LEDGER_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
+7 -4
查看文件
@@ -4,18 +4,21 @@ const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { ROLE_PERMISSIONS } = require("../constants/permissions");
const { BUSINESS_APPROVAL_ROLE_CODES } = require("../utils/workflow");
const ROLE_CODES = Object.keys(ROLE_PERMISSIONS);
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/);
const OWNER_SELF_FALLBACK_ROLE_CODES = ["finance_mgr"];
const REIMBURSEMENT_APPROVAL_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter(
(roleCode) => (ROLE_PERMISSIONS[roleCode] ?? []).includes("expense.approve"),
);
const CREATE_BODY_SCHEMA = z.object({
name: z.string().trim().min(1).max(100),
minAmount: MONEY_STRING_SCHEMA,
maxAmount: MONEY_STRING_SCHEMA.nullable().optional(),
level1Role: z.enum(ROLE_CODES),
level2Role: z.enum(ROLE_CODES).nullable().optional(),
level3Role: z.enum(ROLE_CODES).nullable().optional(),
level1Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES),
level2Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES).nullable().optional(),
level3Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES).nullable().optional(),
ownerSelfFallbackRole: z.enum(OWNER_SELF_FALLBACK_ROLE_CODES).nullable().optional(),
enabled: z.boolean().optional(),
priority: z.number().int().min(0).max(10000).optional(),
+418 -125
查看文件
@@ -1,4 +1,5 @@
const fs = require("node:fs/promises");
const crypto = require("node:crypto");
const path = require("node:path");
const sharp = require("sharp");
@@ -25,7 +26,18 @@ const {
runWecomHrMasterSync,
} = require("../services/integrations/wecomHrMaster");
const { listOrganizationDepartments } = require("../services/organization");
const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage");
const {
deleteObject,
StorageNotFoundError,
openReadStream,
parseStoragePath,
writeBuffer,
} = require("../services/storage");
const {
buildAttachmentContentDisposition,
PurchaseAttachmentPolicyError,
validateAndNormalizePurchaseAttachment,
} = require("../services/purchaseAttachmentPolicy");
const UUID_SCHEMA = z.string().uuid();
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
@@ -103,13 +115,16 @@ const CONTRACT_CREATE_SCHEMA = z.object({
startDate: DATE_SCHEMA,
endDate: DATE_SCHEMA,
terminatedDate: DATE_SCHEMA.nullable().optional(),
attachmentPath: z.string().trim().max(500).nullable().optional(),
}).strict();
const CONTRACT_PATCH_SCHEMA = CONTRACT_CREATE_SCHEMA.partial()
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const CONTRACT_DELETE_SCHEMA = z.object({
reason: z.string().trim().min(2).max(200),
}).strict();
const TRAINING_CREATE_SCHEMA = z.object({
name: z.string().trim().min(1).max(200),
trainingType: TRAINING_TYPE_SCHEMA,
@@ -170,7 +185,7 @@ async function ensureFeatureEnabled(fastify, request) {
}
function canManageProfile(fastify, request, employeeId) {
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
}
function canViewPeopleDirectory(fastify, request) {
@@ -178,7 +193,7 @@ function canViewPeopleDirectory(fastify, request) {
}
function canManageLifecycle(fastify, request) {
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "*");
}
function canUseWecomDirectorySync(fastify) {
@@ -311,7 +326,7 @@ function mapEmployeeContract(contract, timeZone) {
startDate: contract.start_date?.toISOString?.()?.slice(0, 10) ?? null,
endDate: contract.end_date?.toISOString?.()?.slice(0, 10) ?? null,
terminatedDate: contract.terminated_date?.toISOString?.()?.slice(0, 10) ?? null,
attachmentPath: contract.attachment_path ?? null,
hasAttachment: Boolean(contract.attachment_path),
attachmentUrl: buildContractAttachmentUrl(contract.employee_id, contract.id, contract.attachment_path),
status: deriveEmployeeContractStatus(contract, timeZone),
createdAt: contract.created_at?.toISOString?.() ?? null,
@@ -369,20 +384,6 @@ function resolveContractAttachmentDescriptor(mimetype) {
return CONTRACT_ATTACHMENT_ALLOWED_TYPES.get(mimetype) ?? null;
}
function inferAttachmentContentType(storagePath) {
const extension = path.extname(parseStoragePath(storagePath).key ?? "").toLowerCase();
if (extension === ".pdf") {
return "application/pdf";
}
if (extension === ".jpg" || extension === ".jpeg") {
return "image/jpeg";
}
if (extension === ".png") {
return "image/png";
}
return "application/octet-stream";
}
function buildContractAttachmentFilename(contract) {
const key = parseStoragePath(contract.attachment_path).key ?? "";
const extension = path.extname(key) || ".bin";
@@ -390,6 +391,116 @@ function buildContractAttachmentFilename(contract) {
return `${safeContractNo}${extension}`;
}
class ContractAttachmentSizeLimitError extends Error {
constructor() {
super("contract_attachment_size_limit");
this.name = "ContractAttachmentSizeLimitError";
}
}
function destroyAttachmentStream(stream) {
if (typeof stream?.destroy === "function" && !stream.destroyed) {
try {
stream.destroy();
return;
} catch {
// Continue with Web Stream cancellation when available.
}
}
if (typeof stream?.cancel === "function") {
try {
Promise.resolve(stream.cancel()).catch(() => {});
} catch {
// Cleanup must not replace the original validation failure.
}
}
}
async function readBoundedContractAttachmentStream(stream, maxBytes) {
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += bufferChunk.length;
if (size > maxBytes) {
destroyAttachmentStream(stream);
throw new ContractAttachmentSizeLimitError();
}
chunks.push(bufferChunk);
}
} catch (error) {
if (error instanceof ContractAttachmentSizeLimitError) {
throw error;
}
destroyAttachmentStream(stream);
throw error;
}
if (stream?.truncated) {
destroyAttachmentStream(stream);
throw new ContractAttachmentSizeLimitError();
}
return Buffer.concat(chunks, size);
}
async function writeEmployeeContractAudit(fastify, request, {
action,
employeeId,
contractId = null,
extra = {},
}, database = fastify.prisma) {
await database.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action,
targetType: contractId ? "employee_contract" : "employee",
targetId: contractId ?? employeeId,
extra: {
employeeId,
...extra,
},
}),
});
}
function buildContractAttachmentBlockedError() {
return new AppError({
code: "VALID_001",
message: "合同附件未通过安全校验",
statusCode: 409,
details: { attachment: "security_validation_failed" },
});
}
async function auditBlockedContractAttachmentDownload({
fastify,
request,
employeeId,
contractId,
reasonCode,
}) {
try {
await writeEmployeeContractAudit(fastify, request, {
action: "EMPLOYEE_CONTRACT_ATTACHMENT_DOWNLOAD_BLOCKED",
employeeId,
contractId,
extra: { reasonCode },
});
} catch {
fastify.log.error(
{
requestId: request.id,
contractId,
errorCode: "contract_attachment_blocked_audit_failed",
},
"Employee contract attachment blocked-download audit failed",
);
}
}
function resolveLocalStorageAbsolutePath(config, storagePath) {
const parsed = parseStoragePath(storagePath);
if (!parsed.key || parsed.driver !== "local") {
@@ -421,6 +532,41 @@ async function unlinkStoragePath(config, storagePath) {
}
}
function normalizeStorageCleanupErrorCode(error) {
const code = typeof error?.code === "string" ? error.code.trim().toUpperCase() : "UNKNOWN";
return /^[A-Z0-9_]{1,64}$/u.test(code) ? code : "UNKNOWN";
}
async function unlinkCommittedContractAttachment(fastify, contractId, storagePath) {
try {
await deleteObject({ config: fastify.config, storagePath });
} catch (error) {
fastify.log.warn(
{
contractId,
errorCode: normalizeStorageCleanupErrorCode(error),
},
"Committed contract attachment cleanup failed",
);
}
}
async function lockEmployeeContractForUpdate(tx, employeeId, contractId) {
const locked = await tx.$queryRaw`
SELECT id
FROM "employee_contracts"
WHERE id = ${contractId}::uuid
AND employee_id = ${employeeId}::uuid
FOR UPDATE
`;
if (!Array.isArray(locked) || locked.length === 0) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return ensureEmployeeContractExists(tx, employeeId, contractId);
}
function mapChecklistTask(item) {
return {
id: item.id,
@@ -474,7 +620,10 @@ async function getLifecycleBlockers(prisma, employeeId) {
}
async function ensureEmployeeExists(prisma, employeeId) {
const employee = await prisma.employee.findUnique({ where: { id: employeeId } });
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
select: { id: true },
});
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
@@ -709,7 +858,9 @@ async function employeesRoutes(fastify) {
await usersRoutes(fastify, {
basePath: "/employees",
allowDepartmentFilter: true,
listPermissionCodes: ["system.users", "report.dashboard.view"],
listPermissionCodes: ["expense.view_own", "system.users", "report.dashboard.view"],
directoryOnly: true,
managementPermissionCode: "system.users",
enableBatchActions: false,
});
@@ -844,8 +995,8 @@ async function employeesRoutes(fastify) {
const stats = await buildEmployeeStats(fastify.prisma, {
timeZone: fastify.config.appTimezone,
includeSalarySummary: fastify.hasPermission(request.employee, "system.settings"),
includePerformanceSummary: fastify.hasPermission(request.employee, "system.settings"),
includeSalarySummary: fastify.hasPermission(request.employee, "payroll.manage"),
includePerformanceSummary: fastify.hasPermission(request.employee, "system.users_sensitive"),
});
return sendData(reply, request, stats);
});
@@ -1005,11 +1156,8 @@ async function employeesRoutes(fastify) {
return sendData(reply, request, profile);
});
fastify.get("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => {
fastify.get("/employees/:id/contracts", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
await ensureEmployeeExists(fastify.prisma, employeeId);
@@ -1019,16 +1167,19 @@ async function employeesRoutes(fastify) {
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
});
await writeEmployeeContractAudit(fastify, request, {
action: "EMPLOYEE_CONTRACT_LIST_VIEWED",
employeeId,
extra: { itemCount: items.length },
});
return sendData(reply, request, {
items: items.map((item) => mapEmployeeContract(item, fastify.config.appTimezone)),
});
});
fastify.get("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => {
fastify.get("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const contractId = parseId(request.params?.contractId);
@@ -1038,28 +1189,111 @@ async function employeesRoutes(fastify) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
let opened;
try {
const opened = await openReadStream({
opened = await openReadStream({
config: fastify.config,
storagePath: contract.attachment_path,
});
reply.header("cache-control", "no-store");
reply.header("content-disposition", `attachment; filename="${buildContractAttachmentFilename(contract)}"`);
return reply.type(inferAttachmentContentType(contract.attachment_path)).send(opened.stream);
} catch (error) {
if (error instanceof StorageNotFoundError) {
await auditBlockedContractAttachmentDownload({
fastify,
request,
employeeId,
contractId,
reasonCode: "missing_object",
});
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
throw error;
}
let storedBuffer;
try {
storedBuffer = await readBoundedContractAttachmentStream(
opened.stream,
CONTRACT_ATTACHMENT_MAX_BYTES,
);
} catch (error) {
await auditBlockedContractAttachmentDownload({
fastify,
request,
employeeId,
contractId,
reasonCode: error instanceof ContractAttachmentSizeLimitError ? "size_limit" : "storage_error",
});
if (error instanceof ContractAttachmentSizeLimitError) {
throw buildContractAttachmentBlockedError();
}
throw error;
}
let validatedAttachment;
try {
validatedAttachment = await validateAndNormalizePurchaseAttachment({
buffer: storedBuffer,
fileName: buildContractAttachmentFilename(contract),
declaredMimeType: null,
maxBytes: CONTRACT_ATTACHMENT_MAX_BYTES,
normalizeImages: false,
});
} catch (error) {
if (!(error instanceof PurchaseAttachmentPolicyError)) {
throw error;
}
await auditBlockedContractAttachmentDownload({
fastify,
request,
employeeId,
contractId,
reasonCode: error.reasonCode,
});
throw buildContractAttachmentBlockedError();
}
try {
await writeEmployeeContractAudit(fastify, request, {
action: "EMPLOYEE_CONTRACT_ATTACHMENT_DOWNLOADED",
employeeId,
contractId,
});
} catch {
fastify.log.error(
{
requestId: request.id,
contractId,
errorCode: "contract_attachment_download_audit_failed",
},
"Employee contract attachment download audit failed",
);
throw new AppError({
code: "SYS_001",
message: "合同附件下载留痕失败,请稍后重试",
statusCode: 500,
details: { attachment: "audit_failed" },
});
}
reply.type("application/octet-stream");
reply.header(
"content-disposition",
buildAttachmentContentDisposition(validatedAttachment.originalName),
);
reply.header("x-content-type-options", "nosniff");
reply.header("cache-control", "private, no-store, max-age=0");
reply.header("pragma", "no-cache");
reply.header("referrer-policy", "no-referrer");
reply.header(
"content-security-policy",
"sandbox; default-src 'none'; base-uri 'none'; form-action 'none'",
);
reply.header("cross-origin-resource-policy", "same-origin");
return reply.send(validatedAttachment.buffer);
});
fastify.post("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => {
fastify.post("/employees/:id/contracts", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(CONTRACT_CREATE_SCHEMA, request.body);
@@ -1067,18 +1301,26 @@ async function employeesRoutes(fastify) {
await ensureEmployeeExists(fastify.prisma, employeeId);
try {
const contract = await fastify.prisma.employeeContract.create({
data: {
employee_id: employeeId,
contract_no: body.contractNo,
contract_type: body.contractType,
start_date: parseDateOnly(body.startDate),
end_date: parseDateOnly(body.endDate),
terminated_date: parseDateOnly(body.terminatedDate ?? null),
attachment_path: body.attachmentPath ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
const contract = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.employeeContract.create({
data: {
employee_id: employeeId,
contract_no: body.contractNo,
contract_type: body.contractType,
start_date: parseDateOnly(body.startDate),
end_date: parseDateOnly(body.endDate),
terminated_date: parseDateOnly(body.terminatedDate ?? null),
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
await writeEmployeeContractAudit(fastify, request, {
action: "EMPLOYEE_CONTRACT_CREATED",
employeeId,
contractId: created.id,
extra: { contractType: created.contract_type },
}, tx);
return created;
});
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
@@ -1090,39 +1332,43 @@ async function employeesRoutes(fastify) {
}
});
fastify.patch("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => {
fastify.patch("/employees/:id/contracts/:contractId", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const contractId = parseId(request.params?.contractId);
const body = parseBody(CONTRACT_PATCH_SCHEMA, request.body);
await ensureEmployeeExists(fastify.prisma, employeeId);
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
validateContractDates({
startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10),
endDate: body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10),
terminatedDate:
body.terminatedDate !== undefined
? body.terminatedDate
: current.terminated_date?.toISOString?.()?.slice(0, 10),
});
try {
const contract = await fastify.prisma.employeeContract.update({
where: { id: contractId },
data: {
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}),
...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate) } : {}),
...(body.terminatedDate !== undefined ? { terminated_date: parseDateOnly(body.terminatedDate ?? null) } : {}),
...(body.attachmentPath !== undefined ? { attachment_path: body.attachmentPath ?? null } : {}),
updated_by: request.employee.id,
},
const contract = await fastify.prisma.$transaction(async (tx) => {
const current = await ensureEmployeeContractExists(tx, employeeId, contractId);
validateContractDates({
startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10),
endDate: body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10),
terminatedDate:
body.terminatedDate !== undefined
? body.terminatedDate
: current.terminated_date?.toISOString?.()?.slice(0, 10),
});
const updated = await tx.employeeContract.update({
where: { id: contractId },
data: {
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}),
...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate) } : {}),
...(body.terminatedDate !== undefined ? { terminated_date: parseDateOnly(body.terminatedDate ?? null) } : {}),
updated_by: request.employee.id,
},
});
await writeEmployeeContractAudit(fastify, request, {
action: "EMPLOYEE_CONTRACT_UPDATED",
employeeId,
contractId,
extra: { changedFields: Object.keys(body) },
}, tx);
return updated;
});
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
@@ -1134,16 +1380,13 @@ async function employeesRoutes(fastify) {
}
});
fastify.put("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => {
fastify.put("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const contractId = parseId(request.params?.contractId);
await ensureEmployeeExists(fastify.prisma, employeeId);
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
if (!request.isMultipart()) {
throw buildValidationError({ file: "invalid_multipart" });
@@ -1170,63 +1413,113 @@ async function employeesRoutes(fastify) {
throw buildValidationError({ file: "file_too_large" });
}
let validatedAttachment;
try {
validatedAttachment = await validateAndNormalizePurchaseAttachment({
buffer: rawBuffer,
fileName: filePart.filename,
declaredMimeType: filePart.mimetype,
maxBytes: CONTRACT_ATTACHMENT_MAX_BYTES,
normalizeImages: true,
});
} catch (error) {
if (error instanceof PurchaseAttachmentPolicyError) {
throw buildValidationError({ file: error.reasonCode });
}
throw error;
}
const nextAttachmentPath = await writeBuffer({
config: fastify.config,
key: `contracts/${contractId}.${descriptor.extension}`,
buffer: rawBuffer,
contentType: descriptor.contentType,
key: `contracts/${contractId}-${crypto.randomUUID()}${validatedAttachment.extension}`,
buffer: validatedAttachment.buffer,
contentType: validatedAttachment.mimeType,
});
const contract = await fastify.prisma.employeeContract.update({
where: { id: contractId },
data: {
attachment_path: nextAttachmentPath,
updated_by: request.employee.id,
},
});
if (current.attachment_path && current.attachment_path !== nextAttachmentPath) {
await unlinkStoragePath(fastify.config, current.attachment_path);
let replacement;
try {
replacement = await fastify.prisma.$transaction(async (tx) => {
const current = await lockEmployeeContractForUpdate(tx, employeeId, contractId);
const updated = await tx.employeeContract.update({
where: { id: contractId },
data: {
attachment_path: nextAttachmentPath,
updated_by: request.employee.id,
},
});
await writeEmployeeContractAudit(fastify, request, {
action: "EMPLOYEE_CONTRACT_ATTACHMENT_REPLACED",
employeeId,
contractId,
extra: { replacedExisting: Boolean(current.attachment_path) },
}, tx);
return {
contract: updated,
previousAttachmentPath: current.attachment_path ?? null,
};
});
} catch (error) {
await unlinkCommittedContractAttachment(fastify, contractId, nextAttachmentPath);
throw error;
}
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
if (
replacement.previousAttachmentPath &&
replacement.previousAttachmentPath !== nextAttachmentPath
) {
await unlinkCommittedContractAttachment(
fastify,
contractId,
replacement.previousAttachmentPath,
);
}
return sendData(
reply,
request,
mapEmployeeContract(replacement.contract, fastify.config.appTimezone),
);
});
fastify.delete("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!fastify.hasPermission(request.employee, "system.users")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
fastify.delete("/employees/:id/contracts/:contractId", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
const employeeId = parseId(request.params?.id);
const contractId = parseId(request.params?.contractId);
await ensureEmployeeExists(fastify.prisma, employeeId);
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
const currentStatus = deriveEmployeeContractStatus(current, fastify.config.appTimezone);
const body = parseBody(CONTRACT_DELETE_SCHEMA, request.body);
await ensureFeatureEnabled(fastify, request);
if (currentStatus === "active") {
throw buildValidationError({ contract: "active_contract_must_be_terminated" }, 409);
}
const current = await fastify.prisma.$transaction(async (tx) => {
const contract = await ensureEmployeeContractExists(tx, employeeId, contractId);
const status = deriveEmployeeContractStatus(contract, fastify.config.appTimezone);
if (status === "active") {
throw buildValidationError({ contract: "active_contract_must_be_terminated" }, 409);
}
await fastify.prisma.employeeContract.delete({ where: { id: contractId } });
if (current.attachment_path) {
await unlinkStoragePath(fastify.config, current.attachment_path);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EMPLOYEE_CONTRACT_DELETED",
targetType: "employee_contract",
targetId: contractId,
extra: {
employeeId,
contractNo: current.contract_no,
status: currentStatus,
attachmentPath: current.attachment_path ?? null,
},
}),
await tx.employeeContract.delete({ where: { id: contractId } });
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EMPLOYEE_CONTRACT_DELETED",
targetType: "employee_contract",
targetId: contractId,
extra: {
employeeId,
status,
hadAttachment: Boolean(contract.attachment_path),
reason: body.reason,
},
}),
});
return contract;
});
if (current.attachment_path) {
await unlinkCommittedContractAttachment(
fastify,
contractId,
current.attachment_path,
);
}
return sendData(reply, request, {
contractId,
deleted: true,
+1 -1
查看文件
@@ -1762,7 +1762,7 @@ async function exportsRoutes(fastify) {
fastify.get(
"/exports/payslips",
{
preHandler: fastify.requirePermission("system.settings"),
preHandler: fastify.requirePermission("payroll.manage"),
},
async (request, reply) => {
const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query);
文件差异内容过多而无法显示 加载差异
+1 -1
查看文件
@@ -21,7 +21,7 @@ const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/);
const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]);
const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]);
const PAYROLL_MANAGE_PERMISSION = "system.settings";
const PAYROLL_MANAGE_PERMISSION = "payroll.manage";
const SALARY_ITEM_CREATE_SCHEMA = z
.object({
+16
查看文件
@@ -115,6 +115,12 @@ const PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA = z
})
.strict();
const PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA = z
.object({
reason: z.string().trim().min(2).max(200),
})
.strict();
const PURCHASE_SUMMARY_SELECT = {
id: true,
request_no: true,
@@ -1062,6 +1068,14 @@ function parsePurchaseAttachmentDownloadBody(body) {
return parsed.data;
}
function parsePurchaseAttachmentDeleteBody(body) {
const parsed = PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) {
return new AppError({
code: "VALID_001",
@@ -2521,6 +2535,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const attachmentId = parseAttachmentId(request.params?.attachmentId);
const { reason } = parsePurchaseAttachmentDeleteBody(request.body);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
@@ -2570,6 +2585,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
targetId: attachmentId,
extra: {
purchaseRequestId,
reason,
},
}),
});
+61
查看文件
@@ -70,6 +70,12 @@ const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
description: z.string().trim().max(500).nullable().optional(),
});
const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
.object({
reason: z.string().trim().min(2).max(200),
})
.strict();
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
.object({
ownerEmployeeId: z.string().uuid().optional(),
@@ -371,6 +377,19 @@ function parseReimbursementUpdateBody(body) {
return parsed.data;
}
function parseReimbursementAttachmentDeleteBody(body) {
const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseQuery(schema, query) {
const parsed = schema.safeParse(query ?? {});
if (!parsed.success) {
@@ -2621,6 +2640,7 @@ async function reimbursementRoutes(fastify) {
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const { reason } = parseReimbursementAttachmentDeleteBody(request.body);
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
if (!parsedAttachmentId.success) {
throw new AppError({
@@ -2671,6 +2691,19 @@ async function reimbursementRoutes(fastify) {
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_ATTACHMENT_DELETED",
targetType: "reimbursement_attachment",
targetId: parsedAttachmentId.data,
extra: {
reimbursementId,
reason,
},
}),
});
return { attachmentId: parsedAttachmentId.data };
});
@@ -3696,6 +3729,34 @@ async function reimbursementRoutes(fastify) {
domain: "expense",
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
});
const paymentBatchGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: true,
});
const activePaymentBatchItem = await tx.paymentBatchItem.findFirst({
where: {
entity_id: reimbursementId,
payment_batch: {
entity_type: "reimbursement",
status: { not: "cancelled" },
},
},
select: { id: true },
});
if (paymentBatchGate.enabled || activePaymentBatchItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
payment: "payment_batch_v1_required",
...(activePaymentBatchItem ? { paymentBatch: "active_batch_exists" } : {}),
},
});
}
if (!["approved", "paid"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
+264 -169
查看文件
@@ -23,7 +23,6 @@ const { hashPassword } = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
const {
decryptStringOrThrow,
decryptStringSafe,
encryptStringOrThrow,
maskBankAccount,
maskIdNumber,
@@ -38,12 +37,18 @@ const roleCodeSchema = z.enum([
"finance_mgr",
"administration",
"hr",
"system_admin",
"admin",
]);
const PROTECTED_ROLE_CODES = new Set(["admin", "system_admin"]);
const ACCOUNT_MANAGE_PERMISSION = "system.accounts.manage";
const ROLE_MANAGE_PERMISSION = "system.roles.manage";
const listPaginationSchema = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
scope: z.enum(["directory", "management"]).optional(),
});
const normalizedEmailSchema = z
@@ -143,10 +148,23 @@ function parseListQuery(query, options = {}) {
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
scope: options.allowManagementScope && parsed.data.scope === "management" ? "management" : "directory",
...parseEmployeeDirectoryFilters(query, options),
};
}
function mapEmployeeDirectoryItem(row) {
return {
id: row.id,
employeeNo: row.employee_no,
name: row.name,
title: row.title,
status: row.status,
department: row.department ? { id: row.department.id, name: row.department.name } : null,
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
};
}
function parseCreateBody(body) {
const parsed = createBodySchema.safeParse(body ?? {});
if (!parsed.success) {
@@ -236,21 +254,12 @@ function mapEmployeeSummary(row) {
};
}
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);
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,
bankName: row.bank_name ? String(row.bank_name) : null,
bankAccountMasked: bankAccountPlain ? maskBankAccount(bankAccountPlain) : null,
idNumberMasked: idNumberPlain ? maskIdNumber(idNumberPlain) : null,
};
}
@@ -330,6 +339,87 @@ function ensureHasAnyPermission(fastify, request, permissionCodes) {
});
}
function isEmergencyAdmin(fastify, request) {
return fastify.hasPermission(request.employee, "*")
|| (request.employee.roleCodes ?? []).includes("admin");
}
function assertProtectedRoleBoundary(fastify, request, previousRoles, nextRoles) {
if (isEmergencyAdmin(fastify, request)) {
return;
}
const previousProtected = new Set((previousRoles ?? []).filter((roleCode) => PROTECTED_ROLE_CODES.has(roleCode)));
const nextProtected = new Set((nextRoles ?? []).filter((roleCode) => PROTECTED_ROLE_CODES.has(roleCode)));
const changed = [...PROTECTED_ROLE_CODES].some(
(roleCode) => previousProtected.has(roleCode) !== nextProtected.has(roleCode),
);
if (changed) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { role: "protected_role_change_not_allowed" },
});
}
}
function assertCanManageOrdinaryAccount(fastify, request, roleCodes) {
if (isEmergencyAdmin(fastify, request)) {
return roleCodes;
}
if (roleCodes.some((roleCode) => PROTECTED_ROLE_CODES.has(roleCode))) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { user: "protected_account_not_manageable" },
});
}
return roleCodes;
}
async function lockEmployeeRoleCodes(tx, employeeId) {
const employeeRows = await tx.$queryRaw`
SELECT id
FROM "employees"
WHERE id = ${employeeId}::uuid
FOR UPDATE
`;
if (!Array.isArray(employeeRows) || employeeRows.length !== 1) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
details: { employeeId },
});
}
const roleRows = await tx.$queryRaw`
SELECT role_code::text AS role_code
FROM "employee_roles"
WHERE employee_id = ${employeeId}::uuid
ORDER BY role_code
FOR UPDATE
`;
return Array.isArray(roleRows) ? roleRows.map((item) => item.role_code) : [];
}
async function lockEmployeeRoleCodesById(tx, employeeIds) {
const rolesByEmployeeId = new Map();
for (const employeeId of Array.from(new Set(employeeIds)).sort()) {
rolesByEmployeeId.set(employeeId, await lockEmployeeRoleCodes(tx, employeeId));
}
return rolesByEmployeeId;
}
function assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId) {
for (const roleCodes of rolesByEmployeeId.values()) {
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
}
}
async function ensureLifecycleFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
@@ -406,24 +496,6 @@ async function ensureManagerUsable(prisma, managerId, options = {}) {
}
}
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 },
@@ -992,10 +1064,12 @@ async function usersRoutes(fastify, opts = {}) {
const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`;
const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
const allowDepartmentFilter = opts.allowDepartmentFilter === true;
const directoryOnly = opts.directoryOnly === true;
const allowManagementScope = directoryOnly && typeof opts.managementPermissionCode === "string";
const listPermissionCodes =
Array.isArray(opts.listPermissionCodes) && opts.listPermissionCodes.length
? [...new Set(opts.listPermissionCodes)]
: ["system.users"];
: [ACCOUNT_MANAGE_PERMISSION];
const enableBatchActions = routeBasePath === "/users" && opts.enableBatchActions !== false;
fastify.get(
@@ -1004,10 +1078,16 @@ async function usersRoutes(fastify, opts = {}) {
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureHasAnyPermission(fastify, request, listPermissionCodes);
const query = parseListQuery(request.query, { allowDepartmentFilter });
const query = parseListQuery(request.query, { allowDepartmentFilter, allowManagementScope });
const managementScope = allowManagementScope && query.scope === "management";
if (managementScope) {
fastify.assertPermission(request.employee, opts.managementPermissionCode);
} else {
ensureHasAnyPermission(fastify, request, listPermissionCodes);
}
const skip = (query.page - 1) * query.pageSize;
const where = buildEmployeeDirectoryWhere(query);
const where = buildEmployeeDirectoryWhere(query, { includeContactSearch: !directoryOnly || managementScope });
const narrowDirectory = directoryOnly && !managementScope;
const [items, total] = await Promise.all([
fastify.prisma.employee.findMany({
@@ -1015,32 +1095,42 @@ async function usersRoutes(fastify, opts = {}) {
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 } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
select: narrowDirectory
? {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
avatar_path: true,
department: { select: { id: true, name: true } },
}
: {
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 } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
}),
fastify.prisma.employee.count({ where }),
]);
@@ -1049,7 +1139,7 @@ async function usersRoutes(fastify, opts = {}) {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map(mapEmployeeSummary),
items: items.map(narrowDirectory ? mapEmployeeDirectoryItem : mapEmployeeSummary),
});
},
);
@@ -1061,7 +1151,7 @@ async function usersRoutes(fastify, opts = {}) {
},
async (request, reply) => {
const userId = parseUserId(request.params);
const canManage = fastify.hasPermission(request.employee, "system.users");
const canManage = fastify.hasPermission(request.employee, ACCOUNT_MANAGE_PERMISSION);
if (!canManage) {
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own");
@@ -1088,9 +1178,6 @@ async function usersRoutes(fastify, opts = {}) {
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,
@@ -1124,10 +1211,11 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
routeBasePath,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const body = parseCreateBody(request.body);
assertProtectedRoleBoundary(fastify, request, [], body.roles);
const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date();
const departmentId = body.departmentId ?? request.employee.department_id;
const oneTimePassword = body.password ?? generateOneTimePassword();
@@ -1167,9 +1255,7 @@ async function usersRoutes(fastify, opts = {}) {
targetId: employee.id,
extra: {
employeeNo: employee.employee_no,
email: employee.email,
phone: employee.phone,
roles: body.roles,
roleCodes: body.roles,
},
}),
});
@@ -1215,7 +1301,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/batch/transfer-department`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
await ensureLifecycleFeatureEnabled(fastify, request);
@@ -1223,7 +1309,8 @@ async function usersRoutes(fastify, opts = {}) {
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeesExist(tx, body.employeeIds);
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
for (const employeeId of body.employeeIds) {
await applyEmployeeTransfer(tx, {
@@ -1264,7 +1351,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/batch/set-manager`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
await ensureLifecycleFeatureEnabled(fastify, request);
@@ -1275,7 +1362,8 @@ async function usersRoutes(fastify, opts = {}) {
});
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeesExist(tx, body.employeeIds);
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
for (const employeeId of body.employeeIds) {
await applyEmployeeTransfer(tx, {
@@ -1316,7 +1404,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/batch/disable`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
@@ -1326,7 +1414,8 @@ async function usersRoutes(fastify, opts = {}) {
const now = new Date();
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeesExist(tx, body.employeeIds);
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
for (const employeeId of body.employeeIds) {
await setEmployeeAccountStatus(tx, request, {
@@ -1367,14 +1456,15 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/batch/enable`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
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);
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
for (const employeeId of body.employeeIds) {
await setEmployeeAccountStatus(tx, request, {
@@ -1417,7 +1507,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/import/dry-run`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const { filename, rows } = await readUserImportPayload(request);
@@ -1448,7 +1538,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/import/apply`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const { filename, rows } = await readUserImportPayload(request);
@@ -1470,6 +1560,10 @@ async function usersRoutes(fastify, opts = {}) {
);
}
for (const row of preview.validRows) {
assertProtectedRoleBoundary(fastify, request, [], row.normalized.roles);
}
const now = new Date();
const createdRows = [];
const createdByEmployeeNo = new Map();
@@ -1574,7 +1668,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.patch(
`${routeBasePath}/:id`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const userId = parseUserId(request.params);
@@ -1607,6 +1701,9 @@ async function usersRoutes(fastify, opts = {}) {
let updated = null;
try {
updated = await fastify.prisma.$transaction(async (tx) => {
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
const current = await tx.employee.findUnique({
where: { id: userId },
select: {
@@ -1620,9 +1717,6 @@ async function usersRoutes(fastify, opts = {}) {
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,
@@ -1696,9 +1790,6 @@ async function usersRoutes(fastify, opts = {}) {
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,
@@ -1720,7 +1811,7 @@ async function usersRoutes(fastify, opts = {}) {
action: "SYSTEM_USER_UPDATED",
targetType: "employee",
targetId: userId,
extra: { changes },
extra: { changedFields: Object.keys(changes) },
}),
});
}
@@ -1763,18 +1854,28 @@ async function usersRoutes(fastify, opts = {}) {
fastify.get(
`${routeBasePath}/:id/sensitive`,
{
preHandler: fastify.requirePermission("system.users_sensitive"),
preHandler: fastify.authenticate,
},
async (request, reply) => {
const userId = parseUserId(request.params);
const canReadBank = fastify.hasPermission(request.employee, "employee.bank.read")
|| fastify.hasPermission(request.employee, "employee.bank.manage");
const canReadIdentity = fastify.hasPermission(request.employee, "employee.identity.manage");
if (!canReadBank && !canReadIdentity) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: userId },
select: {
id: true,
bank_name: true,
bank_account_encrypted: true,
id_number_encrypted: true,
...(canReadBank
? {
bank_name: true,
bank_account_encrypted: true,
}
: {}),
...(canReadIdentity ? { id_number_encrypted: true } : {}),
},
});
@@ -1786,11 +1887,19 @@ async function usersRoutes(fastify, opts = {}) {
});
}
let bankAccount = null;
let idNumber = null;
const data = {};
try {
bankAccount = decryptStringOrThrow(fastify.config, employee.bank_account_encrypted);
idNumber = decryptStringOrThrow(fastify.config, employee.id_number_encrypted);
if (canReadBank) {
data.bankName = employee.bank_name ? String(employee.bank_name) : null;
data.bankAccount = employee.bank_account_encrypted
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
: null;
}
if (canReadIdentity) {
data.idNumber = employee.id_number_encrypted
? decryptStringOrThrow(fastify.config, employee.id_number_encrypted)
: null;
}
} catch (error) {
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
throw new AppError({
@@ -1807,58 +1916,46 @@ async function usersRoutes(fastify, opts = {}) {
action: "SYSTEM_USER_SENSITIVE_VIEWED",
targetType: "employee",
targetId: userId,
extra: {},
extra: {
scopes: [
...(canReadBank ? ["bank"] : []),
...(canReadIdentity ? ["identity"] : []),
],
},
}),
});
return sendData(reply, request, {
bankName: employee.bank_name ? String(employee.bank_name) : null,
bankAccount,
idNumber,
});
return sendData(reply, request, data);
},
);
fastify.patch(
`${routeBasePath}/:id/sensitive`,
{
preHandler: fastify.requirePermission("system.users_sensitive"),
preHandler: fastify.authenticate,
},
async (request, reply) => {
const userId = parseUserId(request.params);
const body = parseSensitiveBody(request.body);
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankName")
|| Object.prototype.hasOwnProperty.call(body, "bankAccount");
const writesIdentity = Object.prototype.hasOwnProperty.call(body, "idNumber");
if (writesBank) {
fastify.assertPermission(request.employee, "employee.bank.manage");
}
if (writesIdentity) {
fastify.assertPermission(request.employee, "employee.identity.manage");
}
const now = new Date();
let updated = null;
let updatedFields = [];
try {
updated = await fastify.prisma.$transaction(async (tx) => {
updatedFields = 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 } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
...(writesBank ? { bank_name: true } : {}),
},
});
if (!current) {
@@ -1903,35 +2000,10 @@ async function usersRoutes(fastify, opts = {}) {
changes.idNumberMasked = nextIdNumber ? maskIdNumber(nextIdNumber) : null;
}
const employee = await tx.employee.update({
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 } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
select: { id: true },
});
await tx.auditLog.create({
@@ -1944,7 +2016,7 @@ async function usersRoutes(fastify, opts = {}) {
}),
});
return employee;
return Object.keys(changes);
});
} catch (error) {
if (String(error?.message ?? "").includes("FIELD_ENCRYPTION_KEY")) {
@@ -1960,18 +2032,14 @@ async function usersRoutes(fastify, opts = {}) {
throw error;
}
return sendData(
reply,
request,
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
);
return sendData(reply, request, { id: userId, updatedFields });
},
);
fastify.post(
`${routeBasePath}/:id/archive`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const userId = parseUserId(request.params);
@@ -1983,9 +2051,11 @@ async function usersRoutes(fastify, opts = {}) {
details: { user: "self_disable_not_allowed" },
});
}
const now = new Date();
const employee = await fastify.prisma.$transaction(async (tx) => {
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
const updated = await tx.employee.update({
where: { id: userId },
data: {
@@ -2034,7 +2104,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/:id/disable`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const userId = parseUserId(request.params);
@@ -2046,9 +2116,11 @@ async function usersRoutes(fastify, opts = {}) {
details: { user: "self_disable_not_allowed" },
});
}
const now = new Date();
const employee = await fastify.prisma.$transaction(async (tx) => {
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
return setEmployeeAccountStatus(tx, request, {
employeeId: userId,
actorId: request.employee.id,
@@ -2072,13 +2144,16 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/:id/enable`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const userId = parseUserId(request.params);
const now = new Date();
const employee = await fastify.prisma.$transaction(async (tx) => {
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
return setEmployeeAccountStatus(tx, request, {
employeeId: userId,
actorId: request.employee.id,
@@ -2102,7 +2177,7 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/:id/reset-password`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const userId = parseUserId(request.params);
@@ -2115,7 +2190,6 @@ async function usersRoutes(fastify, opts = {}) {
details: parsed.error.flatten(),
});
}
const newPassword =
typeof parsed.data.newPassword === "string" && parsed.data.newPassword.trim()
? parsed.data.newPassword.trim()
@@ -2136,6 +2210,9 @@ async function usersRoutes(fastify, opts = {}) {
const now = new Date();
await fastify.prisma.$transaction(async (tx) => {
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
await tx.employee.update({
where: { id: userId },
data: {
@@ -2192,13 +2269,16 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/:id/unlock`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
},
async (request, reply) => {
const userId = parseUserId(request.params);
const now = new Date();
const result = await fastify.prisma.$transaction(async (tx) => {
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
const current = await tx.employee.findUnique({
where: { id: userId },
select: {
@@ -2256,11 +2336,19 @@ async function usersRoutes(fastify, opts = {}) {
fastify.post(
`${routeBasePath}/:id/roles`,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.requirePermission(ROLE_MANAGE_PERMISSION),
},
async (request, reply) => {
const userId = parseUserId(request.params);
if (userId === request.employee.id) {
if (!isEmergencyAdmin(fastify, request)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { user: "self_role_change_not_allowed" },
});
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -2283,6 +2371,10 @@ async function usersRoutes(fastify, opts = {}) {
const now = new Date();
await fastify.prisma.$transaction(async (tx) => {
const previousRoleCodes = await lockEmployeeRoleCodes(tx, userId);
assertCanManageOrdinaryAccount(fastify, request, previousRoleCodes);
assertProtectedRoleBoundary(fastify, request, previousRoleCodes, roles);
await tx.employeeRole.deleteMany({
where: { employee_id: userId },
});
@@ -2316,7 +2408,10 @@ async function usersRoutes(fastify, opts = {}) {
action: "SYSTEM_USER_ROLES_SET",
targetType: "employee",
targetId: userId,
extra: { roles },
extra: {
beforeRoleCodes: previousRoleCodes,
afterRoleCodes: roles,
},
}),
});
});
+10 -3
查看文件
@@ -3,7 +3,13 @@ const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const {
BUSINESS_APPROVAL_ROLE_CODES,
getWorkflowStepsSnapshot,
isBusinessApprovalRoleCode,
resolveActionableStep,
toMoneyString,
} = require("../utils/workflow");
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
@@ -75,7 +81,7 @@ const APPROVER_LIST_QUERY_SCHEMA = z.object({
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) =>
const APPROVER_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter((roleCode) =>
ROLE_PERMISSIONS[roleCode].some((permissionCode) => permissionCode.endsWith(".approve")),
);
@@ -878,7 +884,7 @@ function resolveBusinessApprovePermission(entityType) {
}
function roleHasBusinessApprovePermission(roleCode, approvePermission) {
if (!roleCode || roleCode === "admin" || !approvePermission) {
if (!isBusinessApprovalRoleCode(roleCode) || !approvePermission) {
return false;
}
@@ -3225,6 +3231,7 @@ module.exports.__testables = {
buildWorkflowEntityVisibilityWhere,
calculateWorkflowTaskDueAt,
canViewWorkflowEntityType,
employeeHasBusinessApproveRole,
handlePurchaseApprovalErpSync,
isWorkflowTaskOverdue,
listAllowedActionsForTask,