跳至主要内容

檔案儲存設計

狀態: 實作完成(後端) 建立日期: 2024-12-23 最後更新: 2026-01-14 相關文件: file-handling.md(前後端整合設計)

1. 概述

本文件定義 app-server 的檔案上傳與儲存架構,採用 Binary-Separate 策略搭配統一的暫存區與永久區機制

1.1 設計目標

  • 前後端解耦:檔案上傳與業務邏輯分離
  • 權限精確控管:業務層決定檔案的最終儲存
  • 避免垃圾檔案:未提交的檔案自動過期清理
  • 統一介面:暫存區與永久區使用相同的儲存類型,確保高效的內部複製
  • 多儲存後端:支援本地磁碟、Database、S3/MinIO(未來可擴展 Azure Blob)
  • 串流處理:大檔案支援,避免 OOM(記憶體溢出)
  • Range 請求:支援斷點續傳與部分內容下載
  • Presigned URL:S3 支援前端直傳模式,減少後端負載

1.2 架構演進

版本架構說明
v1StagingFileService + PermanentFileStorage暫存區固定用本地磁碟
v2 (現行)FileStorage統一介面,暫存區與永久區使用相同儲存類型

2. 架構設計

2.1 統一的 FileStorage 介面

app.storage.type = local | database | s3 | azure | sftp


┌─────────────┐
│ FileStorage │ (統一介面)
└─────────────┘

┌────────────┬───┴───┬────────────┬────────────┐
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐
│ Local │ │ Database │ │ S3 │ │ Azure │ │ SFTP │
├────────┤ ├──────────┤ ├─────────┤ ├───────────┤ ├──────────┤
│staging/│ │staging_ │ │staging/ │ │staging/ │ │staging/ │
│files/ │ │file 表 │ │files/ │ │files/ │ │files/ │
└────────┘ └──────────┘ └─────────┘ └───────────┘ └──────────┘

2.2 流程圖

前端                              後端
┌─────────────┐
│ MediaInput │ 1. 上傳到暫存區
│ │ ─────────────────→ ┌──────────────────┐
│ │ ←───────────────── │ POST /api/v1/ │
│ │ { tempId } │ staging/files │
│ │ └────────┬─────────┘
│ │ │
│ │ ▼
│ │ ┌──────────────────┐
│ │ │ FileStorage │
│ │ │ .stage() │
│ │ │ │
│ │ │ (依 storage.type│
│ │ │ 存到對應暫存區) │
│ │ └────────┬─────────┘
│ │ │
│ │ 2. 提交表單 │
│ │ ─────────────────→ ┌────────▼─────────┐
│ │ { images: [ │ ProductService │
│ │ tempId1, │ .create() │
│ │ tempId2 ] } │ │
└─────────────┘ │ - 權限檢查 │
│ - 呼叫 persist() │
│ - 內部複製到永久區│
└────────┬─────────┘


┌──────────────────┐
│ FileStorage │
│ .persist() │
│ │
│ (同類型內部複製) │
└──────────────────┘

2.3 為什麼使用統一儲存類型?

優點說明
高效內部複製Local: Files.move();Database: 同一事務內複製
一致性開發/測試/生產環境行為一致
簡單配置只需設定一個 app.storage.type
成本優化雲端儲存內部複製通常免費或低成本

3. API 規格

3.1 統一上傳流程(推薦)

所有儲存類型(Local、Database、S3)使用統一的前端上傳邏輯:

┌─────────────────────────────────────────────────────────────────────────┐
│ Step 1: Prepare(取得上傳 URL) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ POST /api/v1/staging/files/prepare │
│ Content-Type: application/json │
│ Authorization: Bearer {token} │
│ │
│ Request: │
│ { │
│ "filename": "image.jpg", │
│ "contentType": "image/jpeg", │
│ "size": 1234567 │
│ } │
│ │
│ Response: 200 OK │
│ { │
│ "fileId": "2026-01-02/a1b2c3d4-...", │
│ "filename": "image.jpg", │
│ "size": 1234567, │
│ "mimeType": "image/jpeg", │
│ "url": "..." ← 上傳 URL │
│ } │
│ │
│ url 的值由後端儲存類型決定: │
│ - Local/Database: "/api/v1/staging/files/2026-01-02/uuid" │
│ - S3 (presigned=false): "/api/v1/staging/files/2026-01-02/uuid" │
│ - S3 (presigned=true): "https://bucket.s3...?X-Amz-Signature=..." │
│ │
├─────────────────────────────────────────────────────────────────────────┤
│ Step 2: Upload(統一 PUT binary) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ PUT {url} │
│ Content-Type: image/jpeg │
│ Content-Length: 1234567 │
│ Authorization: Bearer {token} ← 後端 API 需要;S3 Presigned 不需要 │
│ │
│ Body: <binary data> │
│ │
│ Response: 200 OK │
│ │
└─────────────────────────────────────────────────────────────────────────┘

前端統一實作

async function uploadFile(file: File): Promise<FileDescriptor> {
// Step 1: Prepare - 取得上傳 URL
const descriptor = await api.post<FileDescriptor>('/staging/files/prepare', {
filename: file.name,
contentType: file.type,
size: file.size
});

// Step 2: Upload - 統一用 PUT binary
await fetch(descriptor.url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});

return descriptor;
}

設計說明

為什麼需要 prepare 端點?

問題背景:不同儲存類型需要不同的上傳方式

// ❌ 沒有 prepare 端點時,前端需要判斷儲存類型
if (storageType === 's3-presigned') {
// S3 直傳:PUT binary 到 Presigned URL
await fetch(presignedUrl, { method: 'PUT', body: file })
} else {
// 後端 API:POST multipart
const formData = new FormData()
formData.append('file', file)
await api.post('/staging/files', formData)
}

解決方案:透過 prepare 端點統一前端邏輯

prepare 端點的三大優勢:

  1. 統一前端上傳邏輯

    • 所有儲存類型(Local、Database、S3、Azure、SFTP)使用相同的前端程式碼
    • 前端不需要知道後端使用哪種儲存類型
    • 切換儲存後端時,前端程式碼無需修改
  2. 支援雲端直傳模式

    傳統模式(檔案經過後端):

    前端 ──upload──→ 後端 API ──upload──→ S3
    (20MB) (20MB)

    問題:後端頻寬壓力大、上傳速度受後端限制

    直傳模式(Presigned URL):

    前端 ──prepare──→ 後端 API
    (metadata) ↓
    生成 Presigned URL

    ←──url──────┘

    前端 ──upload──→ S3(直接上傳,不經過後端)
    (20MB)

    優點:
    ✅ 後端只處理 metadata(幾 KB)
    ✅ 前端直連 S3,上傳速度快
    ✅ 減少後端伺服器負載和頻寬成本
  3. 預先驗證與控制

    • 檔案大小、類型驗證
    • 配額檢查
    • 建立 metadata 記錄
為什麼回傳 FileDescriptor 而非 URL 字串?

問題:如果只回傳 URL,前端需要自己組裝資料

// ❌ 簡化版:只回傳 URL
const uploadUrl = await api.post<string>('/staging/files/prepare', {...})
await fetch(uploadUrl, { method: 'PUT', body: file })

// 表單提交時需要 FileDescriptor,但我們只有 URL
await api.post('/products', {
name: 'Product',
mainImage: ??? // 需要自己記住並組裝 FileDescriptor
})

解決:回傳完整的 FileDescriptor,前端可直接使用

// ✅ 現行設計:回傳 FileDescriptor
const descriptor = await api.post<FileDescriptor>('/staging/files/prepare', {...})
await fetch(descriptor.url, { method: 'PUT', body: file })

// 表單提交時直接使用
await api.post('/products', {
name: 'Product',
mainImage: descriptor // 開箱即用
})

前端需要的不只是 URL:

欄位用途
fileId後端用來 persist 暫存檔案
filename顯示原始檔名、UI 反饋
size驗證、顯示檔案大小
mimeType驗證檔案類型
url上傳目標 URL

回傳完整的 FileDescriptor 讓前端:

  • 無需自己記住並組裝資料
  • 可直接嵌入 MediaInput 等組件
  • 可直接提交給業務 API
DTO 轉換流程

prepare 端點涉及兩個資料結構的轉換:

┌─────────────────────────────────────────────────────────────┐
│ API 層(Controller) │
│ - 回傳 FileDescriptor(統一的檔案資料格式) │
│ - 前端期望的格式 │
└─────────────────────────────────────────────────────────────┘
↓ 轉換
┌─────────────────────────────────────────────────────────────┐
│ Service 層(FileStorage) │
│ - 回傳 StagingUploadInfo(準備上傳的專用資料) │
│ - 包含 isExternalUrl() 等內部邏輯 │
└─────────────────────────────────────────────────────────────┘

實作細節(StagingFileController.java:87-127):

@PostMapping("/prepare")
public ResponseEntity<FileDescriptor> prepare(...) {

// 1. FileStorage 層:回傳 StagingUploadInfo(內部 DTO)
StagingUploadInfo uploadInfo = fileStorage.prepareStaging(
tenantId,
request.filename(),
request.contentType(),
request.size()
);

// 2. 轉換成 FileDescriptor(外部 DTO)
FileDescriptor descriptor = FileDescriptor.ofStaging(
uploadInfo.tempId(), // ← 從 StagingUploadInfo 取值
uploadInfo.filename(),
uploadInfo.size(),
uploadInfo.contentType(),
uploadInfo.uploadUrl()
);

// 3. API 回傳 FileDescriptor
return ResponseEntity.ok(descriptor);
}

設計理由:關注點分離

資料結構用途特性
StagingUploadInfoFileStorage 內部使用isExternalUrl() 等內部邏輯
FileDescriptor前後端統一格式@Embeddable,可存入 Entity

雖然增加了一層轉換,但保持了清晰的分層架構:

  • FileStorage 層:專注於儲存邏輯
  • Controller 層:專注於 API 契約
  • 前端:只需知道 FileDescriptor

3.2 傳統上傳流程(相容舊版)

注意:此流程已標記為 @Deprecated,建議使用統一上傳流程。

上傳單一檔案

POST /api/v1/staging/files
Content-Type: multipart/form-data
Authorization: Bearer {token}

Form Data:
- file: (binary)

Response: 201 Created
{
"fileId": "2024-12-23/a1b2c3d4-...",
"filename": "image.jpg",
"size": 1234567,
"mimeType": "image/jpeg"
}

批次上傳

POST /api/v1/staging/files/batch
Content-Type: multipart/form-data
Authorization: Bearer {token}

Form Data:
- files[]: (binary)
- files[]: (binary)
...

Response: 201 Created
[
{ "fileId": "2024-12-23/a1b2c3d4-..." },
{ "fileId": "2024-12-23/b2c3d4e5-..." }
]

限制

  • 單次最多 10 個檔案(可配置)
  • 單檔最大 20MB(可配置)

3.2 檔案下載 API(業務層實作)

重要設計決策:本系統不提供統一的檔案下載端點(如 GET /api/v1/files/{path})。 檔案下載必須由各業務 Controller 實作,以確保權限控管。

設計理由

考量統一端點業務層端點
權限控管❌ 難以實作細粒度權限✅ 業務邏輯決定存取權限
資源隔離⚠️ 需額外驗證✅ 自然整合租戶隔離
彈性❌ 所有檔案同一規則✅ 不同業務可有不同規則

業務層實作範例

// ProductController.java
@GetMapping("/{id}/image")
@PreAuthorize("hasAuthority('product:read')") // 權限控管
public ResponseEntity<byte[]> getMainImage(
@PathVariable String id,
Authentication authentication) {

String tenantId = getCurrentTenantId(); // 租戶隔離

return productService.getMainImage(id, tenantId)
.map(file -> buildImageResponse(file))
.orElse(ResponseEntity.notFound().build());
}

API 路徑設計慣例

GET /api/v1/{resource}/{id}/image          # 主圖
GET /api/v1/{resource}/{id}/gallery/{idx} # 副圖
GET /api/v1/{resource}/{id}/attachments # 附件列表
GET /api/v1/{resource}/{id}/attachments/{attachmentId} # 單一附件

3.3 getUrl() 方法支援情況

FileStorage.getUrl() 方法的行為因儲存類型而異:

儲存類型getUrl() 行為說明
Local❌ 拋出 UnsupportedOperationException業務層 Controller 實作下載
Database❌ 拋出 UnsupportedOperationException業務層 Controller 實作下載
SFTP❌ 拋出 UnsupportedOperationException業務層 Controller 實作下載
S3✅ 回傳 Presigned URL 或 CDN URL前端可直接存取
Azure Blob✅ 回傳 SAS URL 或 CDN URL前端可直接存取

設計理由

  • Local/Database/SFTP:這些儲存類型無法提供前端可直接存取的 URL,必須經過後端 API。為確保權限控管,檔案下載由業務層 Controller 實作。
  • S3/Azure Blob:支援 Presigned URL / SAS URL 機制,前端可直接存取雲端儲存,減少後端負載。

使用建議

// ❌ 錯誤:不應假設 getUrl() 可用
String url = fileStorage.getUrl(tenantId, fileId);

// ✅ 正確:檢查儲存類型或使用 try-catch
try {
String url = fileStorage.getUrl(tenantId, fileId);
// S3/Azure: 使用 URL
} catch (UnsupportedOperationException e) {
// Local/Database/SFTP: 由業務層 Controller 處理
}

// ✅ 更好:業務層直接使用 getInputStream()
try (InputStream is = fileStorage.getInputStream(tenantId, fileId)) {
// 串流回應給前端
}

4. 實作規格

4.1 Package 結構

io.leandev.app/
├── controller/
│ ├── base/
│ │ └── StagingFileController.java # 暫存區 API(上傳)
│ └── {domain}/
│ └── {Domain}Controller.java # 業務層 API(含檔案下載)
├── config/
│ ├── S3Config.java # S3 客戶端配置
│ ├── S3Properties.java # S3 設定屬性
│ ├── AzureConfig.java # Azure Blob 客戶端配置
│ ├── AzureProperties.java # Azure 設定屬性
│ ├── SftpConfig.java # SFTP 客戶端配置
│ └── SftpProperties.java # SFTP 設定屬性
├── service/
│ └── file/
│ ├── FileStorage.java # 統一介面
│ ├── FileIdGenerator.java # 檔案 ID 生成器
│ ├── LocalFileStorage.java # 本地實作
│ ├── DatabaseFileStorage.java # Database 實作
│ ├── S3FileStorage.java # S3/MinIO 實作
│ ├── AzureBlobFileStorage.java # Azure Blob 實作
│ ├── SftpFileStorage.java # SFTP 實作
│ └── FileTooLargeForDatabaseException.java # Database 大小限制例外
├── entity/
│ └── file/
│ ├── DatabaseFile.java # 資料庫檔案 Entity(統一暫存/永久)
│ ├── DatabaseFileContent.java # 資料庫檔案內容(Lazy Loading)
│ └── FileStatus.java # 檔案狀態列舉
├── repository/
│ └── file/
│ └── DatabaseFileRepository.java # 資料庫檔案 Repository
└── scheduler/
├── StagingCleanupScheduler.java # 暫存清理排程(Local)
├── DatabaseStagingCleanupScheduler.java # 暫存清理排程(Database)
├── S3StagingCleanupScheduler.java # S3 暫存清理排程
├── AzureStagingCleanupScheduler.java # Azure 暫存清理排程
└── SftpStagingCleanupScheduler.java # SFTP 暫存清理排程

4.2 FileStorage 介面

public interface FileStorage {

// ========== 統一上傳流程(推薦) ==========

/** 準備暫存上傳 - 回傳上傳 URL */
StagingUploadInfo prepareStaging(String tenantId, String filename, String contentType, long size);

/** 完成暫存上傳 - 接收 PUT 的檔案內容 */
void completeStagingUpload(String tenantId, String tempId, InputStream content, long size);

// ========== 暫存區操作 ==========

/** 上傳檔案到暫存區(傳統方式) @deprecated 使用 prepareStaging + completeStagingUpload */
@Deprecated
String stage(String tenantId, MultipartFile file);

/** 取得暫存檔案的 Metadata(不含內容) */
Optional<FileMetadata> getStagedMetadata(String tenantId, String tempId);

/** 取得暫存檔案的 InputStream(呼叫端負責關閉) */
InputStream getStagedInputStream(String tenantId, String tempId) throws FileNotFoundException;

/** 檢查暫存檔案是否存在 */
boolean existsStaged(String tenantId, String tempId);

/** 刪除暫存檔案 */
void deleteStaged(String tenantId, String tempId);

// ========== 永久區操作 ==========

/** 將暫存檔案持久化到永久區(高效內部複製) */
String persist(String tenantId, String tempId);

/** 直接儲存檔案到永久區(串流方式,跳過暫存) */
String store(String tenantId, String filename, InputStream content, String contentType, long size);

/** 取得永久區檔案的 Metadata(不含內容) */
Optional<FileMetadata> getMetadata(String tenantId, String fileId);

/** 取得永久區檔案的 InputStream(呼叫端負責關閉) */
InputStream getInputStream(String tenantId, String fileId) throws FileNotFoundException;

/** 檢查永久區檔案是否存在 */
boolean exists(String tenantId, String fileId);

/** 刪除永久區檔案 */
void delete(String tenantId, String fileId);

/**
* 取得永久區檔案的存取 URL(僅雲端儲存支援)
*
* 注意:此方法僅適用於雲端儲存(S3、Azure Blob),可回傳 Presigned URL 或 CDN URL。
* 對於 Local、Database、SFTP 儲存類型,此方法會拋出 UnsupportedOperationException,
* 因為檔案下載應由業務層 Controller 實作以確保權限控管。
*
* @throws UnsupportedOperationException 若儲存類型不支援 URL 存取
*/
String getUrl(String tenantId, String fileId);

/** 取得儲存類型名稱 */
String getStorageType();

// ========== DTO ==========

/** 檔案 Metadata(不含內容) */
record FileMetadata(String name, String contentType, long size) {}

/** 暫存上傳資訊 */
record StagingUploadInfo(
String tempId, // 暫存檔案 ID
String uploadUrl, // 上傳 URL(後端 API 或 S3 Presigned URL)
String filename,
String contentType,
long size
) {
/** 判斷是否為外部 URL(如 S3 Presigned URL) */
public boolean isExternalUrl() {
return uploadUrl != null && uploadUrl.startsWith("http");
}
}
}

重要變更:v2.1 版本移除了 byte[] 相關的方法(getStaged()load()store(byte[])), 改用 InputStream 進行串流處理,以支援大型檔案且避免 OOM。

4.3 LocalFileStorage

  • 暫存區:${basePath}/staging/{tenantId}/{date}/{uuid}.bin
  • 永久區:${basePath}/files/{tenantId}/{path}
  • persist() 使用 Files.move() 實現 O(1) 複製

4.4 DatabaseFileStorage

使用單一 database_file 表搭配 FileStatus 狀態欄位,讓 persist() 只需更新狀態,不需複製 BLOB:

  • 單一表:database_file 表 + database_file_content
  • 狀態欄位:STAGING_PREPAREDSTAGING_COMPLETEDSTORED
  • persist() 高效能:只執行 UPDATE statusUPDATE fileId,不複製 BLOB 內容

效能比較

操作舊設計(兩個 Entity)新設計(單一 Entity)
persist() 50MB 檔案~100MB I/O(複製 BLOB)~1KB I/O(UPDATE 狀態)
persist() 記憶體使用~100MB(readAllBytes)~0(不讀取 BLOB)
表數量42

4.5 S3FileStorage

  • 暫存區:{keyPrefix}/staging/{tenantId}/{date}/{uuid}
  • 永久區:{keyPrefix}/files/{tenantId}/{fileId}
  • persist() 使用 S3 CopyObject(服務端複製,高效)
  • Metadata 使用 S3 Object Metadata 儲存(x-amz-meta-original-filename
  • 支援 MinIO 等 S3 相容服務(需設定 path-style-access: true

4.6 AzureBlobFileStorage

  • 暫存區:{blobPrefix}/staging/{tenantId}/{date}/{uuid}
  • 永久區:{blobPrefix}/files/{tenantId}/{fileId}
  • persist() 使用 Azure Blob CopyFromUrl(服務端複製,高效)
  • Metadata 使用 Azure Blob Metadata 儲存(original-filename
  • 支援 Azurite 模擬器(需設定 endpoint
  • SAS URL 對應 S3 的 Presigned URL 功能

4.7 SftpFileStorage

  • 暫存區:{basePath}/staging/{tenantId}/{date}/{uuid}
  • 永久區:{basePath}/files/{tenantId}/{fileId}
  • persist() 透過讀取後寫入實現複製(SFTP 不支援 rename across directories)
  • Metadata 使用 .meta 檔案儲存(SFTP 不支援自訂 metadata)
  • 支援密碼驗證和私鑰驗證
  • 使用 Apache MINA SSHD 作為 SFTP 客戶端

4.8 Entity 設計(單一 Entity + 狀態欄位)

使用單一 DatabaseFile Entity 統一暫存區和永久區,搭配 FileStatus 狀態欄位區分:

public enum FileStatus {
STAGING_PREPARED, // metadata 已建立,等待內容上傳
STAGING_COMPLETED, // 內容已上傳,等待 persist()
STORED // 已持久化
}

@Entity
@Table(name = "database_file")
public class DatabaseFile {
@Id
private String id; // 內部 UUID 主鍵,永不改變

private String tenantId;
private String tempId; // 暫存 ID(persist 後清除)
private String fileId; // 永久 ID(persist 時設定)

@Enumerated(EnumType.STRING)
private FileStatus status; // 檔案狀態

private String name;
private String contentType;
private Long size;

@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private DatabaseFileContent fileContent;

// 審計欄位:createdBy, createdDate, lastModifiedBy, lastModifiedDate
}

@Entity
@Table(name = "database_file_content")
public class DatabaseFileContent {
@Id
private String id;

// 不使用 @Lob:在 PostgreSQL 會映射到 OID
@Column(length = 1000000000)
private byte[] content;
}

persist() 實作(高效能)

@Override
public String persist(String tenantId, String tempId) {
var file = repository.findByTenantIdAndTempId(tenantId, tempId)
.orElseThrow(() -> new IllegalArgumentException("Staging file not found"));

// 只需 UPDATE,不複製 BLOB!
String fileId = idGenerator.generateId(file.getName());
file.setFileId(fileId);
file.setStatus(FileStatus.STORED);
file.setTempId(null); // 清除 tempId

repository.save(file);
return fileId;
}

4.9 業務層使用範例

@Service
@RequiredArgsConstructor
public class ProductService {

private final FileStorage fileStorage;

public Product createWithImages(String tenantId, Product product,
String mainImageTempId, List<String> galleryTempIds) {
Product saved = productRepository.save(product);

// 處理主圖:從暫存區持久化到永久區
if (mainImageTempId != null) {
fileStorage.getStagedMetadata(tenantId, mainImageTempId)
.filter(this::isValidImage)
.ifPresent(metadata -> {
String path = "products/" + saved.getId() + "/main.jpg";
String url = fileStorage.persist(tenantId, mainImageTempId, path);
saved.setMainImageUrl(url);
});
}

return productRepository.save(saved);
}

/** 取得圖片路徑(Controller 使用此路徑進行串流下載) */
public Optional<String> getMainImagePath(String id, String tenantId) {
return productRepository.findByIdAndTenantId(id, tenantId)
.map(Product::getMainImageUrl)
.map(this::extractPathFromUrl);
}

private boolean isValidImage(FileStorage.FileMetadata metadata) {
return ALLOWED_IMAGE_TYPES.contains(metadata.contentType());
}
}

4.10 檔案下載 Response 建構器(appfuse-server 框架)

框架提供三種 ResponseBuilder,簡化檔案下載實作:

Builder用途Range 支援
FileResponseBuilder單一檔案下載✅ 支援
ZipFileResponseBuilderZIP 批次下載(暫存檔)✅ 支援
ZipStreamResponseBuilderZIP 串流下載❌ 不支援

單一檔案下載(FileResponseBuilder)

支援 Range Request,適用於影音串流、大圖片、斷點續傳:

@RestController
@RequiredArgsConstructor
public class ProductController {

private final FileStorage fileStorage;
private final ProductService productService;

/**
* GET /api/v1/products/{id}/image
* 支援 Range Request 的圖片下載
*/
@GetMapping("/{id}/image")
public ResponseEntity<Resource> getMainImage(
@PathVariable String id,
@RequestHeader(value = "Range", required = false) String rangeHeader,
@RequestParam(value = "download", defaultValue = "false") boolean download) {

String tenantId = TenantContext.getCurrentTenantId();
FileDescriptor fd = productService.getMainImage(id).orElse(null);

if (fd == null || fd.isEmpty()) {
return ResponseEntity.notFound().build();
}

FileResponseBuilder builder = FileResponseBuilder
.from(fileStorage, tenantId, fd.getFileId())
.range(rangeHeader); // 支援 Range Request

if (download) {
builder.forceDownload(); // Content-Disposition: attachment
}

return builder.build();
}
}

批次下載(ZipFileResponseBuilder)

先產生 ZIP 暫存檔,再提供下載。支援 Range Request(斷點續傳):

/**
* GET /api/v1/products/{id}/images.zip
* 批次下載所有商品圖片(支援斷點續傳)
*/
@GetMapping("/{id}/images.zip")
public ResponseEntity<Resource> downloadAllImages(
@PathVariable String id,
@RequestHeader(value = "Range", required = false) String rangeHeader) {

String tenantId = TenantContext.getCurrentTenantId();
Product product = productService.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found"));

ZipFileResponseBuilder builder = ZipFileResponseBuilder
.create(product.getSku() + "-images.zip");

// 新增主圖
if (product.getMainImage() != null) {
builder.addFile("main-image.jpg", fileStorage, tenantId,
product.getMainImage().getFileId());
}

// 新增副圖
int i = 1;
for (FileDescriptor fd : product.getGalleryImages()) {
builder.addFile("gallery-" + i++ + ".jpg", fileStorage, tenantId, fd.getFileId());
}

return builder.range(rangeHeader).build();
}

串流 ZIP 下載(ZipStreamResponseBuilder)

邊壓縮邊傳輸,不支援 Range Request,適用於小型批次下載:

/**
* GET /api/v1/orders/{id}/attachments.zip
* 串流 ZIP 下載(不支援斷點續傳)
*/
@GetMapping("/{id}/attachments.zip")
public ResponseEntity<StreamingResponseBody> downloadAttachments(@PathVariable String id) {

String tenantId = TenantContext.getCurrentTenantId();
Order order = orderService.findById(id);

ZipStreamResponseBuilder builder = ZipStreamResponseBuilder.create("attachments.zip");

for (FileDescriptor fd : order.getAttachments()) {
builder.addFile(fd.getFilename(),
() -> fileStorage.getInputStream(tenantId, fd.getFileId()));
}

return builder.build();
}

選擇指南

場景建議使用
影音串流、大圖片FileResponseBuilder
大型 ZIP 需要斷點續傳ZipFileResponseBuilder
小型 ZIP、檔案數量少ZipStreamResponseBuilder

5. 配置說明

5.1 application.yml

app:
# 暫存區設定
staging:
max-batch-files: 10 # 批次上傳最大檔案數

# 儲存設定
storage:
# 類型: local | database | s3 | azure | sftp
type: local

# 統一的檔案大小限制(適用於所有儲存類型)
# 此設定同時用於:
# - prepare 階段的預先驗證(在檔案上傳前攔截)
# - 各儲存後端的實際儲存限制
max-file-size: 50MB

# === Local 設定 ===
local:
base-path: ${app.home:${user.home}}/var/files

spring:
servlet:
multipart:
max-file-size: 500MB # HTTP multipart 限制(傳統上傳流程用)
max-request-size: 500MB

5.2 統一的檔案大小限制

app.storage.max-file-size 提供統一的檔案大小限制,在多個階段進行檢查:

┌─────────────────────────────────────────────────────────────────────────┐
│ 檢查階段 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Prepare 階段(最早攔截) │
│ POST /api/v1/staging/files/prepare │
│ - 檢查 request.size 是否超過 max-file-size │
│ - 檔案尚未上傳,節省頻寬 │
│ │
│ 2. Storage 階段(最後防線) │
│ - 所有 FileStorage 實作都會在 prepareStaging() 和 store() 檢查 │
│ - Local、Database、S3、Azure、SFTP 都使用統一的 maxFileSize │
│ - 確保繞過 Controller 的直接呼叫也會被攔截 │
│ │
└─────────────────────────────────────────────────────────────────────────┘

為什麼需要統一設定?

問題解決方案
staging 和 storage 限制不一致使用單一 max-file-size 設定
PUT binary 不受 multipart 限制在 prepare 階段檢查 request.size
檔案上傳後才被拒絕浪費頻寬在 prepare 階段預先驗證

錯誤處理

當檔案超過限制時,會拋出 FileTooLargeException

{
"type": "https://httpstatuses.io/413",
"title": "Payload Too Large",
"status": 413,
"detail": "File size 75.0 MB exceeds limit 50.0 MB"
}

處理建議

  1. 調整 max-file-size 配置(若儲存後端效能允許)
  2. 前端在上傳前檢查檔案大小,提供即時反饋
  3. 使用適合大檔案的儲存類型(如 S3、Azure Blob)

5.3 環境別配置範例

開發環境(Local)

app:
storage:
type: local

生產環境(簡易部署)

app:
storage:
type: database

生產環境(S3/MinIO)

app:
storage:
type: s3
s3:
bucket: my-app-bucket
region: ap-northeast-1
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
key-prefix: appfuse # 可選:物件 Key 前綴
# cdn-url: https://cdn.example.com # 可選:CDN URL

生產環境(Azure Blob)

app:
storage:
type: azure
azure:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING}
container: appfuse-files
blob-prefix: appfuse # 可選:Blob 名稱前綴
# cdn-url: https://cdn.example.com # 可選:CDN URL
# 或使用 account-name + account-key
# account-name: ${AZURE_STORAGE_ACCOUNT}
# account-key: ${AZURE_STORAGE_KEY}

MinIO(本地開發)

app:
storage:
type: s3
s3:
bucket: appfuse-dev
region: us-east-1 # MinIO 可用任意值
access-key: minioadmin
secret-key: minioadmin
endpoint: http://localhost:9000
path-style-access: true # MinIO 必須設為 true

S3 Presigned URL(直傳模式)

app:
storage:
type: s3
s3:
bucket: my-app-bucket
region: ap-northeast-1
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
presigned-url-expiration: 900 # 15 分鐘
presigned-upload-enabled: true # 前端直傳 S3
presigned-download-enabled: true # 前端直接從 S3 下載

Azure SAS URL(直傳模式)

app:
storage:
type: azure
azure:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING}
container: appfuse-files
sas-token-expiration: 900 # 15 分鐘
sas-upload-enabled: true # 前端直傳 Azure Blob
sas-download-enabled: true # 前端直接從 Azure Blob 下載

Azurite(本地開發)

app:
storage:
type: azure
azure:
account-name: devstoreaccount1
account-key: Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
container: appfuse-dev
endpoint: http://127.0.0.1:10000/devstoreaccount1

SFTP(密碼驗證)

app:
storage:
type: sftp
sftp:
host: sftp.example.com
port: 22
username: appfuse
password: ${SFTP_PASSWORD}
base-path: var/files # 相對於家目錄

SFTP(私鑰驗證)

app:
storage:
type: sftp
sftp:
host: sftp.example.com
port: 22
username: appfuse
private-key-path: /home/user/.ssh/id_rsa
private-key-passphrase: ${SFTP_KEY_PASSPHRASE} # 若私鑰有加密
base-path: var/files # 相對於家目錄
strict-host-key-checking: true
known-hosts-path: /home/user/.ssh/known_hosts

6. Presigned URL 模式

6.1 設計理念

透過 Presigned URL,前端可以直接與 S3 通訊,減少後端負載:

傳統模式:    前端 → 後端 API → S3
Presigned: 前端 → S3(直接)

6.2 統一 FileDescriptor 回傳

無論使用哪種模式,API 都回傳 FileDescriptor,前端根據 url 判斷行為:

// 透過後端上傳(url 是相對路徑)
{
"fileId": "2025-01-02/uuid...",
"filename": "image.jpg",
"url": "/api/v1/files/staging/tenant-001/2025-01-02/uuid..."
}

// Presigned 上傳(url 是 S3 URL)
{
"fileId": "2025-01-02/uuid...",
"filename": "image.jpg",
"url": "https://bucket.s3.amazonaws.com/...?X-Amz-Signature=..."
}

6.3 上傳流程

1. 前端請求準備上傳
POST /api/v1/staging/files/prepare
{ "filename": "image.jpg", "contentType": "image/jpeg", "size": 12345 }


2. 後端回傳 FileDescriptor
- presignedUploadEnabled=false:url = 後端 API
- presignedUploadEnabled=true:url = Presigned PUT URL


3. 前端判斷 url 類型並上傳
- 相對路徑 → POST multipart 到後端
- 外部 URL → PUT binary 到 S3


4. 表單提交時使用 FileDescriptor

6.4 下載流程

getUrl() 回傳優先順序:
1. presignedDownloadEnabled=true → Presigned GET URL
2. cdnUrl 有設定 → CDN URL
3. 其他 → 後端 API URL

6.5 前端實作範例

async function uploadFile(file: File): Promise<FileDescriptor> {
// 1. 請求準備上傳
const descriptor = await api.post<FileDescriptor>('/staging/files/prepare', {
filename: file.name,
contentType: file.type,
size: file.size
});

// 2. 根據 url 判斷上傳方式
if (descriptor.url.startsWith('http')) {
// Presigned URL:直接 PUT 到 S3
await fetch(descriptor.url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
} else {
// 後端 API:POST multipart
const formData = new FormData();
formData.append('file', file);
await api.post(descriptor.url, formData);
}

return descriptor;
}

7. 未來擴展

以下儲存後端可按需實作:

類型狀態說明
Local✅ 已實作開發環境、單機部署
Database✅ 已實作簡易部署、小量檔案
S3/MinIO✅ 已實作AWS 或自建物件儲存(MinIO)
Azure Blob✅ 已實作Azure 雲端環境(支援 Azurite 模擬器)
SFTP✅ 已實作企業環境整合(支援密碼/私鑰驗證)

實作新的儲存後端只需:

  1. 實作 FileStorage 介面
  2. 加上 @ConditionalOnProperty(name = "app.storage.type", havingValue = "xxx")

7. 待辦事項

已完成

  • 設計統一的 FileStorage 介面
  • 實作 LocalFileStorage(含暫存區)
  • 實作 DatabaseFileStorage(含暫存區)
  • 實作 DatabaseFile / DatabaseFileContent Entity(單一 Entity 設計)
  • 更新 StagingFileController(上傳 API)
  • 更新 ProductService 使用 FileStorage
  • 實作 ProductController 圖片下載端點(業務層)
  • 實作 StagingCleanupScheduler(Local 類型)
  • 串流 API 支援:重構 FileStorage 介面改用 InputStream(v2.1)
  • Range 請求支援:實作 RangeUtils 與 206 Partial Content 回應
  • Database 大檔案限制:超過閾值時拋出 FileTooLargeForDatabaseException
  • 檔案下載 Response 建構器(2026-01-14):
    • FileResponseBuilder - 單一檔案下載(支援 Range)
    • ZipFileResponseBuilder - ZIP 批次下載(支援 Range)
    • ZipStreamResponseBuilder - ZIP 串流下載
  • ProductController 整合(2026-01-14):使用 ResponseBuilder 重構圖片下載端點

待實作

  • 實作 DatabaseStagingCleanupScheduler(Database 類型)
  • 實作 SftpStagingCleanupScheduler(SFTP 類型)
  • 前端整合測試

最近完成

  • 實作 AzureStagingCleanupScheduler(2026-01-02)
  • 實作 S3StagingCleanupScheduler(2026-01-02)
  • 實作 SftpFileStorage(2026-01-02)
  • 實作 AzureBlobFileStorage(2026-01-02)
  • 新增 SAS URL 上傳/下載支援(2026-01-02)
  • 實作 S3FileStorage(2026-01-02)
  • 新增 Presigned URL 上傳/下載支援(2026-01-02)