檔案處理設計指南
狀態: 已實作 更新日期: 2026-08-02 相關文件: file-storage.md(後端儲存實作)
1. 設計原則
1.1 核心概念
本系統的檔案處理設計遵循以下原則:
| 原則 | 說明 |
|---|---|
| FileDescriptor 統一格式 | 前後端使用相同的 FileDescriptor 格式,可直接嵌入 Entity |
| fileId 即路徑 | fileId 是 ASCII-only opaque ID,可安全作為 Local/SFTP 路徑或 object key |
| 原始檔名只存 metadata | filename 用於顯示與下載標頭,不參與實體檔名或 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
FileStorage 是 write-once:store / persist 生成新的 fileId,沒有覆寫;換檔=存成新 fileId、實體改指新 id。因此同一 fileId 的內容永不改變,fileId 本身就是天然的強驗證器,無需額外的 hash 或 lastModified。
兩種 URL 策略
| URL 型態 | 內容是否會變 | 建議 Cache-Control | Builder 設定 |
|---|---|---|---|
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
需要縮圖時用 ImageResponseBuilder(與 FileResponseBuilder 分工:前者 buffered、專責轉換;後者串流、支援 Range)。它把縮圖做「對且省」:
- 變體 ETag:
ETag = "{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 短路的意義)。因此若日後調整縮放演算法或編碼品質,需遞增 ImageResponseBuilder 的 TRANSFORM_VERSION 常數,讓所有變體 ETag 一起變動、強制重抓,避免舊快取 stale。
前端與 CORS
<img>/ pdfjs 等 URL 型載入:瀏覽器依 ETag + Cache-Control 自動做條件式 GET / 直接吃快取,無需前端程式碼。- 以
fetch/ axios 抓 blob 再自建 object URL 的程式化下載才需在 client 顯式送If-None-Match/ 處理 304(多數場景可直接依賴瀏覽器 HTTP 快取)。 - 跨來源要讓前端讀到
ETag回應標頭,需在 CORSexposedHeaders放行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-27 | url 欄位改為 @Transient,由 Controller 動態設定 |
| 2025-12-27 | 移除 FileDescriptorDTO,統一使用 FileDescriptor |
| 2025-12-26 | 初始設計:使用 tempId/fileId 字串格式 |