feat(server): MinIO/S3 对象存储兼容(F21)

- 新增 storage 抽象:local 与 s3: 前缀兼容(不改 schema)
- invoice-pool 上传/预览/ocr 读取适配 S3/MinIO
- trialrun compose 增加 minio profile + env 示例与 runbook 指引
- 更新 README 与 v3 CSV 状态推进
这个提交包含在:
2026-02-09 20:42:53 +08:00
父节点 6ecebf8ad6
当前提交 c791e67b20
共修改 12 个文件,包含 2558 行新增和 28 行删除
+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(