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

这个提交包含在:
2026-05-28 19:44:15 +08:00
父节点 2c067866ef
当前提交 52e5284954
共修改 25 个文件,包含 1119 行新增和 21 行删除
+64 -2
查看文件
@@ -16,6 +16,16 @@ const { flushNotificationDispatchTasks } = require("../services/notificationDisp
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
@@ -99,6 +109,12 @@ function mapReceiptAttachment(item) {
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
...mapGovernanceFields(item, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
attachmentType: ATTACHMENT_TYPE.receipt,
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
}),
uploadedBy: item.uploader
? {
id: item.uploader.id,
@@ -681,8 +697,13 @@ function buildNotFoundError() {
async function readReceiptUploadFiles(fastify, request) {
const files = [];
let governanceFields = {};
try {
for await (const part of request.parts()) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type !== "file") {
continue;
}
@@ -725,7 +746,7 @@ async function readReceiptUploadFiles(fastify, request) {
throw buildValidationError({ file: "missing" });
}
return files;
return files.map((file) => ({ ...file, governanceFields }));
}
async function paymentBatchesRoutes(fastify) {
@@ -841,6 +862,16 @@ async function paymentBatchesRoutes(fastify) {
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,
created_at: true,
uploader: {
select: {
@@ -888,6 +919,12 @@ async function paymentBatchesRoutes(fastify) {
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(file.governanceFields, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
attachmentType: ATTACHMENT_TYPE.receipt,
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
}),
},
select: {
id: true,
@@ -895,6 +932,16 @@ async function paymentBatchesRoutes(fastify) {
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,
created_at: true,
uploader: {
select: {
@@ -917,6 +964,7 @@ async function paymentBatchesRoutes(fastify) {
extra: {
attachmentIds: createdItems.map((item) => item.id),
attachmentCount: createdItems.length,
attachmentTypes: [...new Set(createdItems.map((item) => item.attachment_type))],
},
}),
});
@@ -940,11 +988,19 @@ async function paymentBatchesRoutes(fastify) {
storage_path: true,
original_name: true,
mime_type: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
});
if (!attachment) {
throw buildNotFoundError();
}
const downloadReason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
let stream = null;
try {
@@ -966,7 +1022,13 @@ async function paymentBatchesRoutes(fastify) {
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: { attachmentId: attachment.id },
extra: {
attachmentId: attachment.id,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.receipt,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
+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,
},
}),
});
+58
查看文件
@@ -31,6 +31,16 @@ const {
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
ATTACHMENT_TYPE,
DOWNLOAD_POLICY,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
} = require("../services/businessAttachmentGovernance");
const {
buildApprovalWorkflowSnapshot,
getWorkflowStepsSnapshot,
@@ -289,6 +299,12 @@ function mapReimbursementAttachment(item) {
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
...mapGovernanceFields(item, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
downloadPolicy: DOWNLOAD_POLICY.standard,
}),
uploadedBy: item.uploader
? {
id: item.uploader.id,
@@ -2434,8 +2450,13 @@ async function reimbursementRoutes(fastify) {
}
const files = [];
let governanceFields = {};
try {
for await (const part of request.parts()) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type !== "file") {
continue;
}
@@ -2505,6 +2526,12 @@ async function reimbursementRoutes(fastify) {
const datePrefix = compactDateString(fastify.config.appTimezone);
const createdItems = [];
const governanceDefaults = {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
downloadPolicy: DOWNLOAD_POLICY.standard,
};
for (const file of files) {
const fileSha256 = sha256Hex(file.buffer);
const extension = path.extname(file.originalName ?? "").toLowerCase();
@@ -2525,6 +2552,7 @@ async function reimbursementRoutes(fastify) {
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, governanceDefaults),
},
select: {
id: true,
@@ -2532,6 +2560,16 @@ async function reimbursementRoutes(fastify) {
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,
created_at: true,
uploader: {
select: {
@@ -2545,6 +2583,7 @@ async function reimbursementRoutes(fastify) {
createdItems.push(created);
}
const uploadedGovernance = governanceCreateData(governanceFields, governanceDefaults);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
@@ -2553,6 +2592,8 @@ async function reimbursementRoutes(fastify) {
targetId: reimbursementId,
extra: {
attachmentIds: createdItems.map((item) => item.id),
attachmentType: uploadedGovernance.attachment_type,
sensitivityLevel: uploadedGovernance.sensitivity_level,
},
}),
});
@@ -2657,6 +2698,10 @@ async function reimbursementRoutes(fastify) {
storage_path: true,
original_name: true,
mime_type: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
reimbursement_form: {
select: {
owner_id: true,
@@ -2674,6 +2719,15 @@ async function reimbursementRoutes(fastify) {
}
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
const downloadReason = sanitizeDownloadReason(request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { reason: "required" },
});
}
let stream = null;
try {
@@ -2701,6 +2755,10 @@ async function reimbursementRoutes(fastify) {
targetId: reimbursementId,
extra: {
attachmentId: attachment.id,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
@@ -0,0 +1,143 @@
const GOVERNANCE_STATUS = Object.freeze({
draft: "draft",
underApproval: "under_approval",
pendingReview: "pending_review",
approved: "approved",
archived: "archived",
returned: "returned",
rejected: "rejected",
quarantined: "quarantined",
});
const SENSITIVITY_LEVEL = Object.freeze({
public: "public",
internal: "internal",
confidential: "confidential",
restricted: "restricted",
indexOnly: "index_only",
});
const ATTACHMENT_TYPE = Object.freeze({
contractDraft: "contract_draft",
contractStamped: "contract_stamped",
invoice: "invoice",
receipt: "receipt",
quote: "quote",
acceptance: "acceptance",
delivery: "delivery",
policyDocument: "policy_document",
supportingMaterial: "supporting_material",
other: "other",
});
const DOWNLOAD_POLICY = Object.freeze({
standard: "standard",
reasonRequired: "reason_required",
restricted: "restricted",
});
const GOVERNANCE_STATUS_VALUES = new Set(Object.values(GOVERNANCE_STATUS));
const SENSITIVITY_LEVEL_VALUES = new Set(Object.values(SENSITIVITY_LEVEL));
const ATTACHMENT_TYPE_VALUES = new Set(Object.values(ATTACHMENT_TYPE));
const DOWNLOAD_POLICY_VALUES = new Set(Object.values(DOWNLOAD_POLICY));
const DEFAULT_GOVERNANCE = Object.freeze({
governanceStatus: GOVERNANCE_STATUS.approved,
sensitivityLevel: SENSITIVITY_LEVEL.internal,
attachmentType: ATTACHMENT_TYPE.other,
downloadPolicy: DOWNLOAD_POLICY.standard,
});
function normalizeEnumValue(value, allowedValues, fallback) {
if (typeof value !== "string") {
return fallback;
}
const normalized = value.trim();
return allowedValues.has(normalized) ? normalized : fallback;
}
function normalizeGovernanceInput(input = {}, defaults = DEFAULT_GOVERNANCE) {
return {
governanceStatus: normalizeEnumValue(input.governanceStatus ?? input.governance_status, GOVERNANCE_STATUS_VALUES, defaults.governanceStatus),
sensitivityLevel: normalizeEnumValue(input.sensitivityLevel ?? input.sensitivity_level, SENSITIVITY_LEVEL_VALUES, defaults.sensitivityLevel),
attachmentType: normalizeEnumValue(input.attachmentType ?? input.attachment_type, ATTACHMENT_TYPE_VALUES, defaults.attachmentType),
downloadPolicy: normalizeEnumValue(input.downloadPolicy ?? input.download_policy, DOWNLOAD_POLICY_VALUES, defaults.downloadPolicy),
};
}
function governanceCreateData(input = {}, defaults = DEFAULT_GOVERNANCE) {
const normalized = normalizeGovernanceInput(input, defaults);
return {
governance_status: normalized.governanceStatus,
sensitivity_level: normalized.sensitivityLevel,
attachment_type: normalized.attachmentType,
download_policy: normalized.downloadPolicy,
};
}
function mapGovernanceFields(item = {}, defaults = DEFAULT_GOVERNANCE) {
const normalized = normalizeGovernanceInput({
governance_status: item.governance_status,
sensitivity_level: item.sensitivity_level,
attachment_type: item.attachment_type,
download_policy: item.download_policy,
}, defaults);
return {
governanceStatus: normalized.governanceStatus,
sensitivityLevel: normalized.sensitivityLevel,
attachmentType: normalized.attachmentType,
downloadPolicy: normalized.downloadPolicy,
reviewedBy: item.reviewed_by ?? null,
reviewedAt: item.reviewed_at?.toISOString?.() ?? null,
reviewNote: item.review_note ?? null,
archivedBy: item.archived_by ?? null,
archivedAt: item.archived_at?.toISOString?.() ?? null,
archiveNote: item.archive_note ?? null,
};
}
function parseMultipartGovernanceField(part, current = {}) {
if (!part || part.type !== "field") {
return current;
}
const fieldName = part.fieldname;
if (!["attachmentType", "sensitivityLevel", "governanceStatus", "downloadPolicy"].includes(fieldName)) {
return current;
}
return {
...current,
[fieldName]: typeof part.value === "string" ? part.value : "",
};
}
function requiresDownloadReason(attachment = {}) {
const governance = normalizeGovernanceInput(attachment);
return (
governance.downloadPolicy === DOWNLOAD_POLICY.reasonRequired ||
governance.downloadPolicy === DOWNLOAD_POLICY.restricted ||
governance.sensitivityLevel === SENSITIVITY_LEVEL.confidential ||
governance.sensitivityLevel === SENSITIVITY_LEVEL.restricted
);
}
function sanitizeDownloadReason(reason) {
if (typeof reason !== "string") {
return null;
}
const trimmed = reason.trim();
return trimmed ? trimmed.slice(0, 200) : null;
}
module.exports = {
ATTACHMENT_TYPE,
DEFAULT_GOVERNANCE,
DOWNLOAD_POLICY,
GOVERNANCE_STATUS,
SENSITIVITY_LEVEL,
governanceCreateData,
mapGovernanceFields,
normalizeGovernanceInput,
parseMultipartGovernanceField,
requiresDownloadReason,
sanitizeDownloadReason,
};