跳至主要内容

檔案處理設計指南

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

1. 設計原則

1.1 核心概念

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

原則說明
FileDescriptor 統一格式前後端使用相同的 FileDescriptor 格式,可直接嵌入 Entity
fileId 即路徑fileId 格式可讀,同時作為儲存路徑(Local/S3/Azure)
URL 動態生成url 欄位是 transient,由 Controller 層動態設定

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}/{filename}-{shortUuid}.{ext}2025-12-26/14/rose-a1b2c3d4.jpg三段式,含副檔名

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/rose-a1b2c3d4.jpg",
"filename": "rose.jpg",
"size": 102400,
"mimeType": "image/jpeg",
"url": "/api/v1/products/product-123/image"
},
"galleryImages": [
{
"fileId": "2025-12-26/14/gallery-1-b2c3d4e5.jpg",
"filename": "gallery-1.jpg",
"size": 85000,
"mimeType": "image/jpeg",
"url": "/api/v1/products/product-123/gallery/0"
}
]
}

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; // 已是永久區,直接返回
}

String fileId = fileStorage.persist(tenantId, 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. 變更歷史

日期變更
2025-12-27重構:FileDescriptor 改為嵌入式值物件,可直接嵌入 Entity
2025-12-27url 欄位改為 @Transient,由 Controller 動態設定
2025-12-27移除 FileDescriptorDTO,統一使用 FileDescriptor
2025-12-26初始設計:使用 tempId/fileId 字串格式