跳至主要内容

檔案存儲模組

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")
.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.directUploadUrl() = Optional.empty()(application adapter 自行建立 HTTP URL)

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

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

// 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
).withUrl(uploadInfo.directUploadUrl()
.orElseGet(() -> "/api/v1/staging/files/" + tempId));

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

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

四參數 ofStaging(...) 只建立值物件,不會猜測應用路由,因此 url 預設為 null。Reference API adapter 應優先使用 directUploadUrl();empty 時再依自己的 mapping 與 tempId 建立 application upload URL。

判斷檔案狀態

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

條件式下載(ETag / 304)

回應一律帶 ETag(= write-once 的 fileId,強驗證器)與 Cache-Control。傳入請求的 If-None-Match 後,相符時在開啟儲存串流之前直接回 304 Not Modified,同時省下傳輸與 storage 讀取。

@GetMapping("/products/{id}/image")
public ResponseEntity<Resource> getImage(
@PathVariable Long id,
@RequestHeader(value = "Range", required = false) String rangeHeader,
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {

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

return FileResponseBuilder.from(fileStorage, partition, product.getImageFileId())
.range(rangeHeader)
.ifNoneMatch(ifNoneMatch) // 命中回 304
.build();
}
方法說明
ifNoneMatch(String)傳入請求的 If-None-Match;相符則於開串流前回 304
immutable()Cache-Control: public, max-age=31536000, immutable。用於以 fileId 定址的 write-once URL(如 /files/{fileId});用於「URL 穩定但可換內容」的實體型 URL
cacheControl(CacheControl)覆寫預設 no-cache(帶 ETag、每次 revalidate、命中回 304)

<img> / pdfjs 等 URL 型載入的條件式 GET 由瀏覽器 HTTP 快取自動處理,不需前端程式碼。跨來源要讓前端讀到 ETag,需於 CORS exposedHeaders 放行(框架預設已含)。策略選擇(fileId 定址 → immutable、實體型 URL → revalidate)詳見設計指南 檔案處理 §4.3

ImageResponseBuilder

伺服端縮圖 + 條件式下載,與 FileResponseBuilder(原樣串流 + Range)分工。把 ETag 做「對且省」:

  • 變體 ETag = "{fileId}-w{width}-h{height}-{format}-t{transformVersion}"——尺寸與格式納入驗證器,不同尺寸各自快取、不會互相拿到錯圖。
  • pre-decode 304If-None-Match 相符時在解碼前短路,跳過 decode → scale → encode。
  • buffered:縮圖輸出需編碼後才知大小,故走記憶體 buffered(正確 Content-Length)。builder 本身不解析 Range,但因回應體為 ByteArrayResource,Spring 會自動就緩衝內容提供 range。大檔(需串流、低記憶體)仍用 FileResponseBuilder
@GetMapping("/products/{id}/image")
public ResponseEntity<Resource> getImage(
@PathVariable Long id,
@RequestParam(required = false) Integer width,
@RequestParam(required = false) Integer height,
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {

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

return ImageResponseBuilder.from(fileStorage, partition, product.getImageFileId())
.resize(width, height) // 寬高皆 null → 委派 FileResponseBuilder 服務原圖
.ifNoneMatch(ifNoneMatch)
.build();
}
方法說明
resize(Integer width, Integer height)等比縮小(只縮不放、保持長寬比);皆 null 或來源非可解碼影像 → 委派 FileResponseBuilder 服務原圖
format(String)輸出格式(如 "png""jpeg");預設依原檔 content-type 推導、不跨格式轉換
ifNoneMatch(String) / immutable() / cacheControl(CacheControl)FileResponseBuilder

縮圖是原檔(write-once)的決定性衍生。變體 ETag 由 (fileId, width, height, format) 推導、不含輸出 hash(否則得先縮完才算得出、失去 pre-decode 短路)。故若日後調整縮放演算法或編碼品質,需遞增 ImageResponseBuilderTRANSFORM_VERSION,讓變體 ETag 全體失效、強制重抓。

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"))
.idGenerator(new FileIdGenerator())
.build();

目錄結構

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

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 採用 ASCII-only opaque 路徑格式;原始檔名只保存在 metadata:

類型格式範例
暫存區 (tempId){date}/{uuid}2024-01-15/a1b2c3d4-e5f6-...
永久區 (fileId){date}/{hour}/{uuid}2024-01-15/14/12345678-1234-1234-1234-123456789abc

設計優點

  • 可直接作為檔案路徑使用
  • 按日期/小時自動分散,避免單一目錄過多檔案
  • 不含使用者輸入或副檔名,不依賴主機的非 ASCII 檔名編碼
  • 完整 UUID 確保唯一性;顯示名稱與 MIME type 由 metadata 提供

舊格式仍可由 storage API 讀取;需要移除既有非 ASCII 實體檔名時,依 檔案儲存設計的遷移步驟處理。

暫存區清理

各儲存後端提供相同的 StagingCleanupTask 能力,由應用 Feature 的單一 StagingCleanupScheduler 排程執行:

@Scheduled(cron = "${app.storage.staging.cleanup.cron:0 0 * * * *}")
public void cleanupStagingFiles() {
stagingCleanupTask.run();
}

Feature lifecycle 與附帶 reference adapter 的運行政策集中在 app.storage.staging.*;HTTP path 是 reference controller 的 source-level contract, batch/authorization 也由 reference implementation 擁有:

app:
storage:
staging:
retention: 24h
max-batch-files: 10
cleanup:
enabled: true
cron: "0 0 * * * *"

Feature 只接受零或一個 task:未配置 backend 時略過;若同時出現多個 task, 應於啟動時失敗,避免重複或互相衝突的清理。

最佳實踐

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 模組驗證檔案類型
MediaType detectedType = contentDetector.detect(inputStream);
if (!allowedTypes.contains(detectedType)) {
throw new InvalidFileTypeException("不支援的檔案類型: " + detectedType);
}

下一步