檔案處理設計指南
狀態: 已實作 更新日期: 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-27 | url 欄位改為 @Transient,由 Controller 動態設定 |
| 2025-12-27 | 移除 FileDescriptorDTO,統一使用 FileDescriptor |
| 2025-12-26 | 初始設計:使用 tempId/fileId 字串格式 |