跳至主要内容

檔案存儲模組

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、影音串流與 PDF 按需預覽。只服務 GET 與 HEAD; 以 request(HttpServletRequest) 一次讀取方法、Range、If-Range 與 If-None-Match,HEAD 不讀取內容。

基本下載​

@GetMapping("/products/{id}/image")
public ResponseEntity<Resource> getProductImage(@PathVariable Long id, HttpServletRequest request) {

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

return FileResponseBuilder.from(fileStorage, partition, product.getImageFileId())
.request(request) // 方法、Range、If-Range、If-None-Match
.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, HttpServletRequest request) {

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

// 儲存支援區段讀取時,拖動進度條只傳輸所需區段
return FileResponseBuilder.from(fileStorage, partition, video.getFileId())
.request(request)
.build();
}

Range 與儲存能力​

FileStorage#rangeSupport() 為 NATIVE 的儲存(LocalFileStorage、S3FileStorage、AzureBlobFileStorage、 SftpFileStorage,以及轉送它們的 TransactionAwareFileStorage)以 openRange 只讀取所請求的區段並回 206; 預設為 NONE 的自訂儲存忽略 Range、回 200 完整內容與 Accept-Ranges: none。超出檔案範圍的 Range 回 416 (Content-Range: bytes */{size}),格式錯誤與多個 range 回 200;If-Range 只接受與 ETag 相同的強 ETag。 前端與 API 同來源(同協定、主機名與埠號,以路徑區分)時不需要 CORS;前端與 API 不同埠號、子網域或放在 CDN 時, 需要在 CORS exposedHeaders 加入 FileResponseBuilder.RANGE_EXPOSED_HEADERS、allowedHeaders 加入 Range、 If-Range。前端直接讀取預簽 URL 時,CORS 設定在儲存服務。詳見設計指南 檔案儲存 §4.10。

條件式下載(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();

// 逐一傳入的寫法;request(request) 一次讀取全部
return FileResponseBuilder.from(fileStorage, partition, product.getImageFileId())
.range(rangeHeader)
.ifNoneMatch(ifNoneMatch) // 命中回 304
.build();
}
方法說明
request(HttpServletRequest)讀取方法、Range、If-Range、If-None-Match;建議的用法
method(HttpMethod)只接受 GET/HEAD;未設定視為 GET,HEAD 不讀取內容
ifRange(String)傳入請求的 If-Range;只有與 ETag 相同的強 ETag 才處理 Range
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 304:If-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 短路)。故若日後調整縮放演算法或編碼品質,需遞增 ImageResponseBuilder 的 TRANSFORM_VERSION,讓變體 ETag 全體失效、強制重抓。

RangeUtils​

RangeUtils 是 RFC 9110 Range 請求的評估工具,供需要自行處理斷點續傳/部分內容下載的場景使用(FileResponseBuilder、ZipFileResponseBuilder 內部即用它)。

方法簽章說明
evaluatestatic RangeRequest evaluate(String rangeHeader, long size)回傳 Absent(回完整內容)、Satisfiable(ByteRange)(回 206)或 Unsatisfiable(size)(回 416)
parseRangestatic Optional<ByteRange> parseRange(String rangeHeader, long fileSize)已 deprecated:無法滿足與格式錯誤都回 Optional.empty(),改用 evaluate
isRangeRequeststatic boolean isRangeRequest(String rangeHeader)是否為有效的 Range 請求格式(以 bytes= 開頭)

evaluate 支援 bytes=0-1000(絕對範圍)、bytes=1000-(從指定位置到結尾)、bytes=-500(最後 N bytes),先檢查語法再判斷 可滿足性:格式錯誤、反向區間(含超出 long 的數字)與多重範圍(bytes=0-100,200-300)為 Absent;first 超過結尾、 bytes=-0 與空檔案為 Unsatisfiable。

ByteRange 值物件​

public record ByteRange(long start, long end, long total) {
long length(); // = end - start + 1
String toContentRange(); // "bytes start-end/total",可直接作為 Content-Range header
}
switch (RangeUtils.evaluate(rangeHeader, fileSize)) {
case RangeUtils.RangeRequest.Absent absent -> { /* 200 完整內容 */ }
case RangeUtils.RangeRequest.Unsatisfiable u -> { /* 416,Content-Range: u.toContentRange() */ }
case RangeUtils.RangeRequest.Satisfiable(RangeUtils.ByteRange r) -> {
// 206:以 fileStorage.openRange(partition, fileId, r.start(), r.length()) 只讀取該區段
// header 帶 r.toContentRange()
}
}

ZIP 下載 Response 建構器​

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

Builder運作方式Range 支援適用
ZipFileResponseBuilder先產生 ZIP 暫存檔再下載(串流結束自動刪除暫存檔;HEAD、416 於建構時刪除)✅ 支援(每次重新產生、不送 ETag,帶 If-Range 一律回完整內容)大型 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 另有 request(HttpServletRequest)、method(HttpMethod)、range(String) 與 ifRange(String)(回傳型別 ResponseEntity<Resource>,只接受 GET/HEAD);ZipStreamResponseBuilder 無 range(回傳型別 ResponseEntity<StreamingResponseBody>)。

ZipFileResponseBuilder(支援 Range)​

ZIP 每次請求重新產生、位元組不同,因此不送 ETag,帶 If-Range 的請求一律回完整內容;需要安全續傳時,先以 FileStorage 保存再以 FileResponseBuilder 下載。

@GetMapping("/products/{id}/images.zip")
public ResponseEntity<Resource> downloadImages(@PathVariable String id, HttpServletRequest request) {

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

下一步​