跳至主要内容

檔案處理設計指南

狀態: 已實作 更新日期: 2026-08-02 相關文件: file-storage.md(後端儲存實作)

1. 設計原則

1.1 核心概念

本系統的檔案處理設計遵循以下原則:

原則說明
FileDescriptor 統一格式前後端使用相同的 FileDescriptor 格式,可直接嵌入 Entity
fileId 即路徑fileId 是 ASCII-only opaque ID,可安全作為 Local/SFTP 路徑或 object key
原始檔名只存 metadatafilename 用於顯示與下載標頭,不參與實體檔名或 fileId
URL 動態生成url 欄位是 transient,由 Controller 層動態設定

FileDescriptor 不擁有應用路由。四參數 FileDescriptor.ofStaging(tempId, filename, size, mimeType) 只建立值物件, url 保持 null。Staging reference API adapter 優先採用 backend 的 StagingUploadInfo.directUploadUrl();application upload 時該值為 empty,由 adapter 依自己的 mapping 與 tempId 建立 URL。Presigned/SAS URL 才由 backend 產生。

1.2 FileDescriptor 結構

@Embeddable
public class FileDescriptor {
private String fileId; // 儲存識別碼(tempId 或永久 fileId)
private String filename; // 原始檔名
private Long size; // 檔案大小
private String mimeType; // MIME 類型

@Transient
private String url; // 動態生成,不存入資料庫
}

1.3 ID 格式

類型格式範例判斷方式
tempId{yyyy-MM-dd}/{uuid}2025-12-26/a1b2c3d4-e5f6-7890-...第二段是完整 UUID(36 字元)
fileId{yyyy-MM-dd}/{HH}/{uuid}2025-12-26/14/12345678-1234-1234-1234-123456789abc三段式、ASCII-only

1.4 架構圖

┌─────────────────────────────────────────────────────────────────┐
│ 前端 │
│ │
│ 1. 使用者選取檔案 │
│ 2. 上傳到 staging → 取得 FileDescriptor(含 staging url) │
│ 3. 表單提交時傳送 FileDescriptor │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 業務層(ProductService 等) │
│ │
│ 接收 FileDescriptor → 判斷 isStaging() │
│ → 若是 staging:持久化取得新的 FileDescriptor │
│ → 儲存 FileDescriptor 到 Entity │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ Controller 層 │
│ │
│ 回傳 Entity 前,設定 FileDescriptor 的 url 欄位 │
│ url = "/api/v1/products/{id}/image" │
└─────────────────────────────────────────────────────────────────┘

2. 上傳流程

2.1 暫存區 API

POST /api/v1/staging/files
Content-Type: multipart/form-data

Response: 201 Created
{
"fileId": "2025-12-26/a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"filename": "product-image.jpg",
"size": 102400,
"mimeType": "image/jpeg",
"url": "/api/v1/staging/files/2025-12-26/a1b2c3d4-e5f6-7890-..."
}

2.2 完整流程

Step 1: 使用者選取檔案

Step 2: 前端上傳到 staging
POST /api/v1/staging/files
→ 取得 FileDescriptor

Step 3: 表單提交
POST /api/v1/products/with-images
{
"product": { ... },
"mainImage": {
"fileId": "2025-12-26/a1b2c3d4-...",
"filename": "rose.jpg",
"size": 102400,
"mimeType": "image/jpeg"
},
"galleryImages": [...]
}

Step 4: 後端處理
判斷 mainImage.isStaging() → true
→ fileStorage.persist(tenantId, tempId)
→ 取得新的 FileDescriptor(永久區)
→ product.setMainImage(persistedFd)

2.3 表單提交格式

{
"product": {
"name": "玫瑰花束",
"category": "BOUQUET",
"basePrice": 1200
},
"mainImage": {
"fileId": "2025-12-26/a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"filename": "rose.jpg",
"size": 102400,
"mimeType": "image/jpeg"
},
"galleryImages": [
{
"fileId": "2025-12-26/b2c3d4e5-f6a7-8901-bcde-234567890abc",
"filename": "gallery-1.jpg",
"size": 85000,
"mimeType": "image/jpeg"
}
]
}

3. 更新圖片流程

3.1 API 格式

PATCH /api/v1/products/{id}/images

{
"mainImage": {
"fileId": "2025-12-26/new-uuid-...",
"filename": "new-image.jpg",
...
},
"galleryImages": [
{ "fileId": "2025-12-26/14/existing-a1b2c3d4.jpg", ... }, // 保留既有(permanent)
{ "fileId": "2025-12-26/new-uuid-...", ... } // 新上傳(staging)
]
}

3.2 欄位語義

FileDescriptor 狀態含義
null不更新
isEmpty() = true刪除
isStaging() = true新上傳,需持久化
isPermanent() = true保留既有

3.3 判斷方法

// FileDescriptor 內建方法
public boolean isStaging() {
// tempId 格式:yyyy-MM-dd/uuid(第二段是完整 UUID 36 字元)
String[] parts = fileId.split("/");
return parts.length == 2 && parts[1].length() == 36;
}

public boolean isPermanent() {
return fileId != null && !isStaging();
}

public boolean isEmpty() {
return fileId == null || fileId.isBlank();
}

4. 下載流程

4.1 業務端點

GET /api/v1/products/{id}/image → 主圖
GET /api/v1/products/{id}/gallery/{idx} → 副圖

4.2 Response 格式

API 回傳的 Product 包含完整的 FileDescriptor(含 url):

{
"id": "product-123",
"name": "玫瑰花束",
"mainImage": {
"fileId": "2025-12-26/14/12345678-1234-1234-1234-123456789abc",
"filename": "rose.jpg",
"size": 102400,
"mimeType": "image/jpeg",
"url": "/api/v1/products/product-123/image"
},
"galleryImages": [
{
"fileId": "2025-12-26/14/23456789-2345-2345-2345-234567890abc",
"filename": "gallery-1.jpg",
"size": 85000,
"mimeType": "image/jpeg",
"url": "/api/v1/products/product-123/gallery/0"
}
]
}

4.3 快取與條件式下載(ETag / 304 + 伺服端縮圖)

檔案下載是熱路徑(商品圖、訂單照片常被列表 / gallery 反覆載入)。框架以 ETag + 條件式下載讓瀏覽器與共享快取避免重複傳輸——URL 型媒體(<img>、pdfjs)由瀏覽器自動做條件式 GET,不需前端程式碼

為什麼 ETag = fileId

FileStoragewrite-oncestore / persist 生成新的 fileId,沒有覆寫;換檔=存成新 fileId、實體改指新 id。因此同一 fileId 的內容永不改變,fileId 本身就是天然的強驗證器,無需額外的 hash 或 lastModified。

兩種 URL 策略

URL 型態內容是否會變建議 Cache-ControlBuilder 設定
fileId 定址/files/{fileId}否(永不變)public, max-age=31536000, immutable.immutable()
實體型/products/{id}/image,URL 穩定但可換圖)是(換圖時)no-cache(帶 ETag 每次 revalidate)預設

實體型 URL immutable():換圖後 URL 不變,長效快取會讓使用者持續看到舊圖。維持預設,換圖時因 controller 傳入不同 fileId → ETag 變 → 自動失效。

原圖下載:FileResponseBuilder

FileResponseBuilder 於 200 完整回應與 206 Range 回應皆帶 ETag(= fileId)與 Cache-Control。傳入 If-None-Match 後,在開啟儲存串流之前就比對 ETag,命中直接回 304 Not Modified,同時省下傳輸與 storage 讀取:

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

FileDescriptor fd = productService.getMainImage(id).orElseThrow();
return FileResponseBuilder.from(fileStorage, TenantContext.getCurrentTenantId(), fd.getFileId())
.range(rangeHeader)
.ifNoneMatch(ifNoneMatch) // 條件式下載
.build();
}

伺服端縮圖:ImageResponseBuilder

需要縮圖時用 ImageResponseBuilderFileResponseBuilder 分工:前者 buffered、專責轉換;後者串流、支援 Range)。它把縮圖做「對且省」:

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

FileDescriptor fd = productService.getMainImage(id).orElseThrow();
String partition = TenantContext.getCurrentTenantId();

// 有尺寸 → 縮圖(buffered、變體 ETag、不吃 Range)
if (width != null || height != null) {
return ImageResponseBuilder.from(fileStorage, partition, fd.getFileId())
.resize(width, height)
.ifNoneMatch(ifNoneMatch)
.build();
}
// 無尺寸 → 原圖(串流、支援 Range、ETag = fileId)
return FileResponseBuilder.from(fileStorage, partition, fd.getFileId())
.range(rangeHeader)
.ifNoneMatch(ifNoneMatch)
.build();
}

ImageResponseBuilder 在寬高皆 null 或來源非可解碼影像時,會自動委派 FileResponseBuilder 服務原圖,故上例的分支僅為讓原圖路徑保有 Range 支援。

轉換版本(transformVersion)

變體 ETag 由 (fileId, width, height, format) 推導、不含實際輸出的 hash(若含 hash 就得先做完縮圖才算得出 ETag,失去 pre-decode 短路的意義)。因此若日後調整縮放演算法或編碼品質,需遞增 ImageResponseBuilderTRANSFORM_VERSION 常數,讓所有變體 ETag 一起變動、強制重抓,避免舊快取 stale。

前端與 CORS

  • <img> / pdfjs 等 URL 型載入:瀏覽器依 ETag + Cache-Control 自動做條件式 GET / 直接吃快取,無需前端程式碼
  • fetch / axios 抓 blob 再自建 object URL 的程式化下載才需在 client 顯式送 If-None-Match / 處理 304(多數場景可直接依賴瀏覽器 HTTP 快取)。
  • 跨來源要讓前端讀到 ETag 回應標頭,需在 CORS exposedHeaders 放行 ETag(框架 WebConfig 預設已含)。

5. Entity 設計

5.1 嵌入 FileDescriptor

@Entity
public class Product {

@Embedded
@AttributeOverrides({
@AttributeOverride(name = "fileId", column = @Column(name = "main_image_id")),
@AttributeOverride(name = "filename", column = @Column(name = "main_image_name")),
@AttributeOverride(name = "size", column = @Column(name = "main_image_size")),
@AttributeOverride(name = "mimeType", column = @Column(name = "main_image_mime_type"))
})
private FileDescriptor mainImage;

@ElementCollection
@CollectionTable(name = "product_gallery_image", joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "sort_order")
private List<FileDescriptor> galleryImages = new ArrayList<>();
}

5.2 資料庫結構

product 表

┌────────────────┬────────────────────┬───────────────────┬─────────────────────┐
│ main_image_id │ main_image_name │ main_image_size │ main_image_mime_type│
├────────────────┼────────────────────┼───────────────────┼─────────────────────┤
│ 2025-12-26/... │ rose.jpg │ 102400 │ image/jpeg │
└────────────────┴────────────────────┴───────────────────┴─────────────────────┘

product_gallery_image 表

┌────────────┬────────────┬────────────────┬───────────┬───────────┬───────────────┐
│ product_id │ sort_order │ file_id │ file_name │ file_size │ file_mime_type│
├────────────┼────────────┼────────────────┼───────────┼───────────┼───────────────┤
│ prod-123 │ 0 │ 2025-12-26/... │ gal-1.jpg │ 85000 │ image/jpeg │
│ prod-123 │ 1 │ 2025-12-26/... │ gal-2.jpg │ 92000 │ image/jpeg │
└────────────┴────────────┴────────────────┴───────────┴───────────┴───────────────┘

6. Service 層實作

@Service
@RequiredArgsConstructor
public class ProductService {

private final FileStorage fileStorage;

public Product createWithImages(
String tenantId,
Product product,
FileDescriptor mainImage,
List<FileDescriptor> galleryImages) {

Product saved = create(tenantId, product);

// 處理主圖
if (mainImage != null && !mainImage.isEmpty()) {
FileDescriptor persisted = persistFileDescriptor(tenantId, mainImage);
saved.setMainImage(persisted);
}

// 處理副圖
if (galleryImages != null && !galleryImages.isEmpty()) {
List<FileDescriptor> persistedImages = galleryImages.stream()
.filter(fd -> fd != null && !fd.isEmpty())
.map(fd -> persistFileDescriptor(tenantId, fd))
.toList();
saved.setGalleryImages(persistedImages);
}

return productRepository.save(saved);
}

private FileDescriptor persistFileDescriptor(String tenantId, FileDescriptor fd) {
if (!fd.isStaging()) {
return fd; // 已是永久區,直接返回
}

// FileStorage 的第一個參數是 partition(儲存分區鍵);多租戶情境傳當前 tenant ID
String partition = tenantId;
String fileId = fileStorage.persist(partition, fd.getFileId());

return FileDescriptor.ofPermanent(
fileId,
fd.getFilename(),
fd.getSize(),
fd.getMimeType()
);
}
}

7. Controller 層實作

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

@PostMapping("/with-images")
public ResponseEntity<Product> createWithImages(
@RequestBody CreateProductWithImagesRequest request,
Authentication authentication) {

String tenantId = getCurrentTenantId(authentication);

Product created = productService.createWithImages(
tenantId,
request.product(),
request.mainImage(),
request.galleryImages()
);

// 設定回傳的圖片 URL
enrichProductImageUrls(created);

return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

private void enrichProductImageUrls(Product product) {
if (product.getMainImage() != null && !product.getMainImage().isEmpty()) {
product.getMainImage().setUrl("/api/v1/products/" + product.getId() + "/image");
}

if (product.getGalleryImages() != null) {
for (int i = 0; i < product.getGalleryImages().size(); i++) {
FileDescriptor fd = product.getGalleryImages().get(i);
if (fd != null && !fd.isEmpty()) {
fd.setUrl("/api/v1/products/" + product.getId() + "/gallery/" + i);
}
}
}
}

public record CreateProductWithImagesRequest(
@Valid Product product,
FileDescriptor mainImage,
List<FileDescriptor> galleryImages
) {}
}

8. 設計優點

優點說明
前後端一致FileDescriptor 格式統一,file-input 可直接使用
Metadata 儲存filename、size、mimeType 儲存在 Entity,查詢時不需讀取檔案
URL 動態生成url 是 transient,可根據業務邏輯動態設定
嵌入式設計FileDescriptor 嵌入 Entity,資料庫查詢效率高
型別安全比 String fileId 更具語義,IDE 支援更好

9. 變更歷史

日期變更
2026-07-27明確化 staging URL ownership:FileDescriptor 不推導路由,由 Feature adapter 與 backend upload info 明確提供
2026-07-13新增 §4.3:條件式下載(ETag = write-once fileId、pre-open 304)+ 伺服端縮圖 ImageResponseBuilder(變體 ETag、pre-decode 304、與 Range 分道)
2025-12-27重構:FileDescriptor 改為嵌入式值物件,可直接嵌入 Entity
2025-12-27url 欄位改為 @Transient,由 Controller 動態設定
2025-12-27移除 FileDescriptorDTO,統一使用 FileDescriptor
2025-12-26初始設計:使用 tempId/fileId 字串格式