跳至主要内容

檔案存儲模組

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();

上傳流程(暫存 → 永久)

// 1. 準備暫存上傳
StagingUploadInfo info = fileStorage.prepareStaging(
tenantId,
"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(tenantId, info.tempId(), inputStream, fileSize);

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

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

直接儲存(跳過暫存)

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

讀取檔案

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

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

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

// 刪除檔案
fileStorage.delete(tenantId, 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(tenantId, 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);
String tenantId = TenantContext.getCurrentTenantId();

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

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

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

return FileResponseBuilder.from(fileStorage, tenantId, 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 tenantId = TenantContext.getCurrentTenantId();

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

儲存後端配置

Local 儲存

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

目錄結構

/data/files/
├── staging/ # 暫存區
│ └── {tenantId}/
│ └── {date}/{uuid}.bin
│ └── {date}/{uuid}.meta
└── files/ # 永久區
└── {tenantId}/
└── {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. 下載檔案
return FileResponseBuilder.from(fileStorage, tenantId, order.getInvoiceFileId())
.build();
}

2. 大檔案處理

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

3. 檔案類型驗證

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

下一步