檔案儲存設計
狀態: 實作完成(後端) 建立日期: 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 架構演進
| 版本 | 架構 | 說明 |
|---|---|---|
| v1 | StagingFileService + 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 端點的三大優勢:
-
統一前端上傳邏輯
- 所有儲存類型(Local、Database、S3、Azure、SFTP)使用相同的前端程式碼
- 前端不需要知道後端使用哪種儲存類型
- 切換儲存後端時,前端程式碼無需修改
-
支援雲端直傳模式
傳統模式(檔案經過後端):
前端 ──upload──→ 後端 API ──upload──→ S3
(20MB) (20MB)
問題:後端頻寬壓力大、上傳速度受後端限制直傳模式(Presigned URL):
前端 ──prepare──→ 後端 API
(metadata) ↓
生成 Presigned URL
↓
←──url──────┘
前端 ──upload──→ S3(直接上傳,不經過後端)
(20MB)
優點:
✅ 後端只處理 metadata(幾 KB)
✅ 前端直連 S3,上傳速度快
✅ 減少後端伺服器負載和頻寬成本 -
預先驗證與控制
- 檔案大小、類型驗證
- 配額檢查
- 建立 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);
}
設計理由:關注點分離
| 資料結構 | 用途 | 特性 |
|---|---|---|
| StagingUploadInfo | FileStorage 內部使用 | 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_PREPARED→STAGING_COMPLETED→STORED persist()高效能:只執行UPDATE status和UPDATE fileId,不複製 BLOB 內容
效能比較
| 操作 | 舊設計(兩個 Entity) | 新設計(單一 Entity) |
|---|---|---|
| persist() 50MB 檔案 | ~100MB I/O(複製 BLOB) | ~1KB I/O(UPDATE 狀態) |
| persist() 記憶體使用 | ~100MB(readAllBytes) | ~0(不讀取 BLOB) |
| 表數量 | 4 | 2 |
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 | 單一檔案下載 | ✅ 支援 |
ZipFileResponseBuilder | ZIP 批次下載(暫存檔) | ✅ 支援 |
ZipStreamResponseBuilder | ZIP 串流下載 | ❌ 不支援 |
單一檔案下載(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"
}
處理建議:
- 調整
max-file-size配置(若儲存後端效能允許) - 前端在上傳前檢查檔案大小,提供即時反饋
- 使用適合大檔案的儲存類型(如 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 | ✅ 已實作 | 企業環境整合(支援密碼/私鑰驗證) |
實作新的儲存後端只需:
- 實作
FileStorage介面 - 加上
@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)