feat(server): MinIO/S3 对象存储兼容(F21)
- 新增 storage 抽象:local 与 s3: 前缀兼容(不改 schema) - invoice-pool 上传/预览/ocr 读取适配 S3/MinIO - trialrun compose 增加 minio profile + env 示例与 runbook 指引 - 更新 README 与 v3 CSV 状态推进
这个提交包含在:
+28
-23
@@ -1,6 +1,4 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs/promises");
|
||||
const fsSync = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { z } = require("zod");
|
||||
@@ -13,6 +11,7 @@ const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { toMoneyString } = require("../utils/workflow");
|
||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||
const { isOcrEnabledForType } = require("../services/ocr");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
|
||||
const MIME_ALLOWLIST = new Set([
|
||||
"application/pdf",
|
||||
@@ -565,11 +564,13 @@ async function invoicePoolRoutes(fastify) {
|
||||
const fileSha256 = sha256Hex(fileBuffer);
|
||||
const datePrefix = compactDateString(fastify.config.appTimezone);
|
||||
const extension = path.extname(filePart.filename ?? "").toLowerCase();
|
||||
const storagePath = path.join(datePrefix, `${crypto.randomUUID()}${extension}`);
|
||||
const absoluteStoragePath = path.resolve(fastify.config.storageDir, storagePath);
|
||||
|
||||
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
|
||||
await fs.writeFile(absoluteStoragePath, fileBuffer);
|
||||
const objectKey = path.posix.join(datePrefix, `${crypto.randomUUID()}${extension}`);
|
||||
const storagePath = await writeBuffer({
|
||||
config: fastify.config,
|
||||
key: objectKey,
|
||||
buffer: fileBuffer,
|
||||
contentType: filePart.mimetype,
|
||||
});
|
||||
|
||||
const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available";
|
||||
|
||||
@@ -1918,23 +1919,27 @@ async function invoicePoolRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const storageRoot = path.resolve(fastify.config.storageDir);
|
||||
const absolutePath = path.resolve(storageRoot, invoice.storage_path);
|
||||
if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
} catch {
|
||||
const opened = await openReadStream({
|
||||
config: fastify.config,
|
||||
storagePath: invoice.storage_path,
|
||||
});
|
||||
stream = opened.stream;
|
||||
} catch (error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1960,7 +1965,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
"content-disposition",
|
||||
`inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`,
|
||||
);
|
||||
return reply.send(fsSync.createReadStream(absolutePath));
|
||||
return reply.send(stream);
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
|
||||
在新工单中引用
屏蔽一个用户