跳至主要内容

檔案存儲模組

Package: io.leandev.appfuse.file.*

AppFuse Server 提供統一的檔案存儲介面,支援多種儲存後端,讓業務層只需處理 fileId,不需關心檔案實際儲存位置。

核心特色

1. 多後端支援

後端類別適用場景
LocalLocalFileStorage開發環境、單機部署
S3S3FileStorageAWS S3、MinIO
AzureAzureBlobFileStorageAzure Blob Storage、Azurite
SFTPSftpFileStorageSFTP 檔案伺服器

2. 暫存區 + 永久區架構

  • 暫存區:接收上傳,暫時儲存,定期清理
  • 永久區:業務確認後持久化,長期保存

3. 串流優先

使用 InputStream 避免大檔案載入記憶體,呼叫端負責關閉串流。

基本用法

建立 FileStorage

import io.leandev.appfuse.file.local.LocalFileStorage;
import io.leandev.appfuse.file.FileStorage;

// Local 儲存
FileStorage fileStorage = LocalFileStorage.builder()
.basePath("/data/files")
.stagingBaseUrl("/api/v1/staging/files")
.build();

關於 partition 參數:所有 FileStorage 方法的第一個參數 partition儲存分區鍵——由呼叫端控制的路徑/key 前綴,把不同範圍的檔案隔離在各自的儲存空間,與 entity 層的多租戶機制(TenantContext、Hibernate filter)無耦合。多租戶情境傳當前 tenant ID(TenantContext.getCurrentTenantId(),下方範例即此用法)達成租戶間檔案隔離;單租戶傳固定常數(如 "default")。不可傳 null(部分後端組路徑時會 NPE)。

上傳流程(暫存 → 永久)

// partition:儲存分區鍵;多租戶情境傳當前 tenant ID 達成租戶間檔案隔離
String partition = TenantContext.getCurrentTenantId();

// 1. 準備暫存上傳
StagingUploadInfo info = fileStorage.prepareStaging(
partition,
"document.pdf",
"application/pdf",
fileSize
);
// info.tempId() = "2024-01-15/uuid"
// info.uploadUrl() = "/api/v1/staging/files/2024-01-15/uuid"

// 2. 完成暫存上傳(由 Controller 在收到 PUT 請求時呼叫)
fileStorage.completeStagingUpload(partition, info.tempId(), inputStream, fileSize);

// 3. 業務驗證通過後,持久化到永久區
String fileId = fileStorage.persist(partition, info.tempId());
// fileId = "2024-01-15/14/document-abc123.pdf"

// 4. 儲存 fileId 到資料庫
product.setAttachmentFileId(fileId);

直接儲存(跳過暫存)

// 適用於後端產生的檔案(如報表、匯出檔)
String fileId = fileStorage.store(
partition,
"report.xlsx",
inputStream,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileSize
);

讀取檔案

// 取得 metadata
Optional<FileMetadata> meta = fileStorage.getMetadata(partition, fileId);
// FileMetadata(name="document.pdf", contentType="application/pdf", size=12345)

// 取得 InputStream(呼叫端負責關閉)
try (InputStream is = fileStorage.getInputStream(partition, fileId)) {
// 處理檔案內容
}

// 檢查是否存在
boolean exists = fileStorage.exists(partition, fileId);

// 刪除檔案
fileStorage.delete(partition, fileId);

FileDescriptor 值物件

FileDescriptor 是可嵌入 Entity 的檔案描述符,統一前後端的檔案資料格式。

嵌入 Entity

@Entity
public class Product {
@Id
private Long id;

private String name;

@Embedded
@AttributeOverrides({
@AttributeOverride(name = "fileId", column = @Column(name = "image_file_id")),
@AttributeOverride(name = "filename", column = @Column(name = "image_filename")),
@AttributeOverride(name = "size", column = @Column(name = "image_size")),
@AttributeOverride(name = "mimeType", column = @Column(name = "image_mime_type"))
})
private FileDescriptor mainImage;
}

使用工廠方法

// 暫存區上傳後
FileDescriptor staged = FileDescriptor.ofStaging(
tempId, filename, size, mimeType
);

// 持久化後
FileDescriptor persisted = FileDescriptor.ofPermanent(
fileId, filename, size, mimeType
);

// 設定存取 URL
persisted.withUrl("/api/v1/products/" + productId + "/image");

判斷檔案狀態

FileDescriptor fd = product.getMainImage();

if (fd.isEmpty()) {
// 無檔案
}

if (fd.isStaging()) {
// 暫存區檔案,需要持久化
String fileId = fileStorage.persist(partition, fd.getTempId());
}

if (fd.isPermanent()) {
// 永久區檔案
}

FileResponseBuilder

提供統一的檔案下載回應建構,支援 Range Request 和影音串流。

基本下載

@GetMapping("/products/{id}/image")
public ResponseEntity<Resource> getProductImage(
@PathVariable Long id,
@RequestHeader(value = "Range", required = false) String rangeHeader) {

Product product = productService.findById(id);
// partition:多租戶情境傳當前 tenant ID 達成租戶間檔案隔離
String partition = TenantContext.getCurrentTenantId();

return FileResponseBuilder.from(fileStorage, partition, product.getImageFileId())
.range(rangeHeader) // 支援 Range Request
.build();
}

強制下載(而非瀏覽器開啟)

@GetMapping("/documents/{id}/download")
public ResponseEntity<Resource> downloadDocument(@PathVariable Long id) {
Document doc = documentService.findById(id);
String partition = TenantContext.getCurrentTenantId();

return FileResponseBuilder.from(fileStorage, partition, doc.getFileId())
.filename(doc.getOriginalFilename()) // 自訂下載檔名
.forceDownload() // Content-Disposition: attachment
.build();
}

影音串流

@GetMapping("/videos/{id}")
public ResponseEntity<Resource> streamVideo(
@PathVariable Long id,
@RequestHeader(value = "Range", required = false) String rangeHeader) {

Video video = videoService.findById(id);
String partition = TenantContext.getCurrentTenantId();

// 自動支援 Range Request,讓用戶可以拖動進度條
return FileResponseBuilder.from(fileStorage, partition, video.getFileId())
.range(rangeHeader)
.build();
}

RangeUtils

RangeUtils 是 RFC 7233 Range 請求的解析工具,供需要自行處理斷點續傳/部分內容下載的場景使用(FileResponseBuilderZipFileResponseBuilder 內部即用它)。

方法簽章說明
parseRangestatic Optional<ByteRange> parseRange(String rangeHeader, long fileSize)解析 Range header,格式無效回 Optional.empty()
isRangeRequeststatic boolean isRangeRequest(String rangeHeader)是否為有效的 Range 請求格式(以 bytes= 開頭)

支援的格式:bytes=0-1000(絕對範圍)、bytes=1000-(從指定位置到結尾)、bytes=-500(最後 N bytes)。僅支援單一範圍,多重範圍(bytes=0-100,200-300)回 Optional.empty()

ByteRange 值物件

public record ByteRange(long start, long end, long total) {
long length(); // = end - start + 1
String toContentRange(); // "bytes start-end/total",可直接作為 Content-Range header
}
Optional<RangeUtils.ByteRange> range = RangeUtils.parseRange("bytes=0-1000", fileSize);
if (range.isPresent()) {
// 回應 206 Partial Content
RangeUtils.ByteRange r = range.get();
inputStream.skip(r.start());
// 讀取 r.length() bytes,header 帶 r.toContentRange()
}

ZIP 下載 Response 建構器

框架另提供兩種 ZIP 批次下載建構器,差別在於是否支援 Range Request:

Builder運作方式Range 支援適用
ZipFileResponseBuilder先產生 ZIP 暫存檔再下載(串流結束自動刪除暫存檔)✅ 支援大型 ZIP 需斷點續傳
ZipStreamResponseBuilder邊壓縮邊傳輸(StreamingResponseBody❌ 不支援小型批次、檔案數量少

ZipStreamResponseBuilder 不支援 Range 是因 ZIP 的 Central Directory 在檔案結尾,串流模式無法預知總大小。需斷點續傳請改用 ZipFileResponseBuilder

兩者 API 對稱,皆以 create(zipFilename) 起始、addFile(...) 累加條目:

方法說明
static {Builder} create(String zipFilename)建立建構器
{Builder} addFile(String entryName, Supplier<InputStream> contentSupplier)以延遲供應者新增條目
{Builder} addFile(String entryName, FileStorage fileStorage, String partition, String fileId)直接從 FileStorage 取檔新增條目
boolean hasFiles() / int getFileCount()條目查詢
ResponseEntity<...> build()建構回應(無條目回 204 No Content)

ZipFileResponseBuilder 另有 range(String rangeHeader)(回傳型別 ResponseEntity<Resource>);ZipStreamResponseBuilderrange(回傳型別 ResponseEntity<StreamingResponseBody>)。

ZipFileResponseBuilder(支援斷點續傳)

@GetMapping("/products/{id}/images.zip")
public ResponseEntity<Resource> downloadImages(
@PathVariable String id,
@RequestHeader(value = "Range", required = false) String rangeHeader) {

Product product = productService.findById(id);
String partition = TenantContext.getCurrentTenantId();

ZipFileResponseBuilder builder = ZipFileResponseBuilder.create("product-images.zip");

if (product.getMainImage() != null) {
builder.addFile("main-image.jpg", fileStorage, partition,
product.getMainImage().getFileId());
}
int i = 1;
for (FileDescriptor fd : product.getGalleryImages()) {
builder.addFile("gallery-" + i++ + ".jpg", fileStorage, partition, fd.getFileId());
}

return builder.range(rangeHeader).build();
}

ZipStreamResponseBuilder(串流壓縮)

@GetMapping("/orders/{id}/attachments.zip")
public ResponseEntity<StreamingResponseBody> downloadAttachments(@PathVariable String id) {
Order order = orderService.findById(id);
String partition = TenantContext.getCurrentTenantId();

ZipStreamResponseBuilder builder = ZipStreamResponseBuilder.create("attachments.zip");

for (FileDescriptor fd : order.getAttachments()) {
builder.addFile(fd.getFilename(),
() -> fileStorage.getInputStream(partition, fd.getFileId()));
}

return builder.build();
}

儲存後端配置

Local 儲存

FileStorage fileStorage = LocalFileStorage.builder()
.basePath(Path.of("/data/files"))
.stagingBaseUrl("/api/v1/staging/files")
.idGenerator(new FileIdGenerator())
.build();

目錄結構

/data/files/
├── staging/ # 暫存區
│ └── {partition}/
│ └── {date}/{uuid}.bin
│ └── {date}/{uuid}.meta
└── files/ # 永久區
└── {partition}/
└── {yyyy-MM-dd}/
└── {HH}/
└── {filename-shortUuid}.{ext}

S3 儲存

FileStorage fileStorage = S3FileStorage.builder()
.s3Client(s3Client)
.bucket("my-bucket")
.prefix("uploads")
.presignedUrlExpiration(Duration.ofMinutes(15))
.build();

Azure Blob 儲存

FileStorage fileStorage = AzureBlobFileStorage.builder()
.blobServiceClient(blobServiceClient)
.containerName("uploads")
.presignedUrlExpiration(Duration.ofMinutes(15))
.build();

SFTP 儲存

FileStorage fileStorage = SftpFileStorage.builder()
.host("sftp.example.com")
.port(22)
.username("user")
.privateKey(privateKeyPath) // 或使用 .password("password")
.basePath("/uploads")
.build();

fileId 格式

AppFuse 的 fileId 採用可讀的路徑格式:

類型格式範例
暫存區 (tempId){date}/{uuid}2024-01-15/a1b2c3d4-e5f6-...
永久區 (fileId){date}/{hour}/{filename-shortUuid}.{ext}2024-01-15/14/document-abc123.pdf

設計優點

  • 可直接作為檔案路徑使用
  • 按日期/小時自動分散,避免單一目錄過多檔案
  • 檔名保留原始名稱,便於識別
  • Short UUID 確保唯一性

暫存區清理

各儲存後端都提供暫存區清理任務,可搭配排程自動清理過期的暫存檔案:

// Local
@Scheduled(cron = "0 0 * * * *") // 每小時執行
public void cleanupStagingFiles() {
localStagingCleanupTask.cleanup(Duration.ofHours(24));
}

// S3
@Scheduled(cron = "0 0 * * * *")
public void cleanupS3Staging() {
s3StagingCleanupTask.cleanup(Duration.ofHours(24));
}

最佳實踐

1. 權限控管

檔案下載應由業務層 Controller 實作,確保權限檢查:

@GetMapping("/orders/{orderId}/invoice")
public ResponseEntity<Resource> getInvoice(@PathVariable Long orderId) {
// 1. 權限檢查
Order order = orderService.findById(orderId);
if (!securityService.canAccessOrder(order)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}

// 2. 下載檔案
String partition = TenantContext.getCurrentTenantId();
return FileResponseBuilder.from(fileStorage, partition, order.getInvoiceFileId())
.build();
}

2. 大檔案處理

  • 使用串流 API 避免載入整個檔案到記憶體
  • 設定適當的上傳大小限制
  • 考慮使用 Presigned URL(S3/Azure)讓前端直傳

3. 檔案類型驗證

// 搭配 Content 模組驗證檔案類型
String detectedType = contentDetector.detect(inputStream);
if (!allowedTypes.contains(detectedType)) {
throw new InvalidFileTypeException("不支援的檔案類型: " + detectedType);
}

下一步