檔案存儲模組
Package:
io.leandev.appfuse.file.*
AppFuse Server 提供統一的檔案存儲介面,支援多種儲存後端,讓業務層只需處理 fileId,不需關心檔案實際儲存位置。
核心特色
1. 多後端支援
| 後端 | 類別 | 適用場景 |
|---|---|---|
| Local | LocalFileStorage | 開發環境、單機部署 |
| S3 | S3FileStorage | AWS S3、MinIO |
| Azure | AzureBlobFileStorage | Azure Blob Storage、Azurite |
| SFTP | SftpFileStorage | SFTP 檔案伺服器 |
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 請求的解析工具,供需要自行處理斷點續傳/部分內容下載的場景使用(FileResponseBuilder、ZipFileResponseBuilder 內部即用它)。
| 方法 | 簽章 | 說明 |
|---|---|---|
parseRange | static Optional<ByteRange> parseRange(String rangeHeader, long fileSize) | 解析 Range header,格式無效回 Optional.empty() |
isRangeRequest | static 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>);ZipStreamResponseBuilder 無 range(回傳型別 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);
}
下一步
- Content 模組 - MIME 類型檢測,搭配檔案上傳驗證
- Security 模組 - 權限控管
- Tenant 模組 - 多租戶數據隔離