实现业务附件受控入库 V1(05-28-business-attachment-governance-v1)

这个提交包含在:
2026-05-28 19:44:15 +08:00
父节点 2c067866ef
当前提交 52e5284954
共修改 25 个文件,包含 1119 行新增和 21 行删除
+86
查看文件
@@ -34,6 +34,16 @@ const {
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const { dispatchNotificationBatch } = require("../services/notificationDispatcher");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const UUID_SCHEMA = z.string().uuid();
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
@@ -207,6 +217,17 @@ const PURCHASE_DETAIL_SELECT = {
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
uploaded_by: true,
created_at: true,
uploader: {
@@ -623,6 +644,13 @@ function mapPurchaseRequestDetail(item, request) {
originalName: attachment.original_name,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
fileSha256: attachment.file_sha256 ?? null,
...mapGovernanceFields(attachment, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.other,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
uploadedBy: attachment.uploaded_by,
createdAt: toIsoDateTime(attachment.created_at),
uploader: mapEmployeeBasic(attachment.uploader),
@@ -984,6 +1012,7 @@ async function persistAttachment({ config, purchaseRequestId, filePart }) {
originalName: filePart.filename || safeName,
mimeType: filePart.mimetype || "application/octet-stream",
fileSize,
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
};
}
@@ -2199,8 +2228,13 @@ async function purchaseRequestRoutes(fastify) {
}
let filePart = null;
let governanceFields = {};
try {
for await (const part of request.parts()) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type === "file") {
if (filePart) {
throw buildValidationError({ file: "only_single_file_supported" });
@@ -2233,13 +2267,31 @@ async function purchaseRequestRoutes(fastify) {
original_name: stored.originalName,
mime_type: stored.mimeType,
file_size: stored.fileSize,
file_sha256: stored.fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.quote,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
uploaded_by: true,
created_at: true,
uploader: {
@@ -2262,6 +2314,8 @@ async function purchaseRequestRoutes(fastify) {
purchaseRequestId,
originalName: stored.originalName,
fileSize: stored.fileSize,
attachmentType: created.attachment_type,
sensitivityLevel: created.sensitivity_level,
},
}),
});
@@ -2275,6 +2329,13 @@ async function purchaseRequestRoutes(fastify) {
originalName: attachment.original_name,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
fileSha256: attachment.file_sha256 ?? null,
...mapGovernanceFields(attachment, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.quote,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
uploadedBy: attachment.uploaded_by,
createdAt: toIsoDateTime(attachment.created_at),
uploader: mapEmployeeBasic(attachment.uploader),
@@ -2373,6 +2434,10 @@ async function purchaseRequestRoutes(fastify) {
},
select: {
id: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
});
@@ -2383,6 +2448,10 @@ async function purchaseRequestRoutes(fastify) {
statusCode: 404,
});
}
const downloadReason = sanitizeDownloadReason(request.body?.reason ?? request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
@@ -2390,6 +2459,7 @@ async function purchaseRequestRoutes(fastify) {
purchaseRequestId,
attachmentId,
actorId: request.employee.id,
reason: downloadReason,
exp: expiresAt,
},
fastify.config.jwtSecret,
@@ -2408,6 +2478,10 @@ async function purchaseRequestRoutes(fastify) {
extra: {
purchaseRequestId,
expiresAt,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
@@ -2470,6 +2544,10 @@ async function purchaseRequestRoutes(fastify) {
storage_path: true,
mime_type: true,
original_name: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
});
@@ -2480,6 +2558,10 @@ async function purchaseRequestRoutes(fastify) {
statusCode: 404,
});
}
const downloadReason = sanitizeDownloadReason(request.query?.reason ?? verified.payload?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
let opened = null;
try {
@@ -2508,6 +2590,10 @@ async function purchaseRequestRoutes(fastify) {
extra: {
purchaseRequestId,
exp: verified.payload.exp,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});