文件上傳是 Web 開發高頻需求。本文基於 Spring Boot 3.x / Servlet 6.0 規範,系統講解單文件/多文件上傳、配置調優、儲存策略、安全防護、大文件分片上傳與斷點續傳完整方案。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 內嵌 Tomcat 自動配置 MultipartResolver -->
spring:
servlet:
multipart:
enabled: true
max-file-size: 100MB # 單文件最大值
max-request-size: 500MB # 整個請求最大值 (含多文件)
file-size-threshold: 10MB # 閾值:超過寫入臨時檔,否則記憶體
location: /tmp/spring-upload # 臨時檔目錄 (需可寫、足夠空間)
resolve-lazily: true # 延遲解析 (按需讀取),降低記憶體佔用
server:
tomcat:
max-swallow-size: -1 # 防止大文件上傳被截斷 (Tomcat 9+)
connection-timeout: 300s # 長連線超時
max-threads: 200
@RestController
@RequestMapping("/api/upload")
@Slf4j
public class FileUploadController {
private final FileStorageService storageService;
private final VirusScanner virusScanner; // 介面,可接入 ClamAV
@PostMapping("/single")
public Result uploadSingle(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "bizType", defaultValue = "default") String bizType,
HttpServletRequest request) {
// 1. 基礎校驗
if (file.isEmpty()) return Result.fail("文件為空");
validateFile(file);
// 2. 安全掃描 (異步非阻塞)
CompletableFuture.runAsync(() -> virusScanner.scan(file.getInputStream()));
// 3. 儲存
FileVO vo = storageService.store(file, bizType, getCurrentUserId());
// 4. 审計日誌
log.info("文件上傳: user={}, file={}, size={}, type={}, ip={}",
getCurrentUserId(), vo.getOriginalName(), vo.getSize(), vo.getContentType(), getClientIp(request));
return Result.success(vo);
}
private void validateFile(MultipartFile file) {
// 檯面類型白名單
String contentType = file.getContentType();
if (!ALLOWED_TYPES.contains(contentType)) {
throw new BizException("FILE_TYPE_NOT_ALLOWED", "不支援的文件類型: " + contentType);
}
// 檔名安全:防止路徑遍歷
String filename = Paths.get(file.getOriginalFilename()).getFileName().toString();
if (filename.contains("..") || filename.contains(":") || filename.contains("\\")) {
throw new BizException("INVALID_FILENAME", "非法文件名");
}
// 魔數驗證 (可選):讀取前幾位元組確認真實類型
// if (!MagicNumberUtil.match(file.getInputStream(), contentType)) ...
}
}
@PostMapping("/multiple")
public Result> uploadMultiple(
@RequestParam("files") MultipartFile[] files,
@RequestParam(value = "bizType", defaultValue = "default") String bizType) {
if (files == null || files.length == 0) return Result.fail("無文件");
if (files.length > 20) return Result.fail("單次上傳文件數不能超過 20");
List results = new ArrayList<>();
List> futures = new ArrayList<>();
for (MultipartFile file : files) {
if (!file.isEmpty()) {
futures.add(CompletableFuture.supplyAsync(() -> {
validateFile(file);
return storageService.store(file, bizType, getCurrentUserId());
}, uploadExecutor)); // 自定義執行緒池隔離 IO
}
}
// 等待所有完成
for (CompletableFuture f : futures) {
results.add(f.join());
}
return Result.success(results);
}
public interface FileStorageService {
FileVO store(MultipartFile file, String bizType, Long userId);
Resource load(String storageKey);
void delete(String storageKey);
}
@Service
@Primary
@ConditionalOnProperty(name = "storage.type", havingValue = "local", matchIfMissing = true)
public class LocalFileStorageService implements FileStorageService {
@Value("${storage.local.base-path:/data/upload}")
private String basePath;
@Override
public FileVO store(MultipartFile file, String bizType, Long userId) {
// 1. 生成唯一儲存鍵: bizType/year/month/day/uuid.ext
String ext = FilenameUtils.getExtension(file.getOriginalFilename());
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String storageKey = String.format("%s/%s/%s.%s", bizType, datePath, IdUtil.fastUUID(), ext);
Path targetPath = Paths.get(basePath, storageKey);
try {
Files.createDirectories(targetPath.getParent());
// 直接流式寫入,避免記憶體佔用
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new BizException("STORAGE_FAILED", "文件儲存失敗", e);
}
// 2. 返回元資料
return FileVO.builder()
.storageKey(storageKey)
.originalName(file.getOriginalFilename())
.contentType(file.getContentType())
.size(file.getSize())
.storageType("LOCAL")
.url("/api/file/" + storageKey) // 下載介面
.build();
}
@Override
public Resource load(String storageKey) {
Path path = Paths.get(basePath, storageKey);
if (!Files.exists(path)) throw new BizException("FILE_NOT_FOUND", "文件不存在");
try {
return new UrlResource(path.toUri());
} catch (MalformedURLException e) {
throw new BizException("LOAD_FAILED", "文件讀取失敗", e);
}
}
@Override
public void delete(String storageKey) {
try {
Files.deleteIfExists(Paths.get(basePath, storageKey));
} catch (IOException e) {
log.error("刪除失敗: {}", storageKey, e);
}
}
}
// S3/MinIO 實現同理,注入 S3Client,使用 PutObjectRequest/GetObjectRequest
@Service
@ConditionalOnProperty(name = "storage.type", havingValue = "s3")
public class S3FileStorageService implements FileStorageService { ... }
// 概念:文件 → 切片 (5MB/片) → 計算每片 MD5 (SparkMD5) → 並行上傳 // 關鍵點:斷點續傳需「文件唯一標識 (文件 MD5) + 切片索引 + 切片 MD5」
// 2.1 檢查秒傳/已上傳切片
@GetMapping("/check")
public Result checkChunk(
@RequestParam String fileMd5,
@RequestParam String fileName,
@RequestParam long fileSize,
@RequestParam(required = false) Integer chunkIndex) {
// 查 Redis/DB:是否已合併完成、已上傳哪些切片
// 返回 { uploaded: true/false, uploadedChunks: [1,3,5] }
}
// 2.2 上傳單一切片
@PostMapping("/chunk")
public Result uploadChunk(
@RequestParam("file") MultipartFile chunk,
@RequestParam String fileMd5,
@RequestParam int chunkIndex,
@RequestParam int totalChunks,
@RequestParam String chunkMd5) {
// 1. 驗證切片 MD5
// 2. 儲存到臨時目錄: /tmp/chunks/{fileMd5}/{chunkIndex}
// 3. 記錄切片完成狀態到 Redis (Set/ Bitmap)
// 4. 返回成功
}
// 2.3 合併切片
@PostMapping("/merge")
public Result mergeChunks(
@RequestParam String fileMd5,
@RequestParam String fileName,
@RequestParam long fileSize,
@RequestParam int totalChunks,
@RequestParam String bizType) {
// 1. 校驗所有切片是否齊全
// 2. 異步合併 (大文件合併耗時長,返回受理狀態,WebSocket/輪詢通知結果)
mergeExecutor.submit(() -> {
try {
Path finalPath = storageService.assembleChunks(fileMd5, fileName, totalChunks, bizType);
// 更新 DB 狀態、發送完成事件
} catch (Exception e) {
// 標記失敗、清理臨時切片
}
});
return Result.success("合併任務已受理");
}
public Path assembleChunks(String fileMd5, String fileName, int totalChunks, String bizType) {
Path tempDir = Paths.get(chunkTempBase, fileMd5);
Path finalPath = generateFinalPath(bizType, fileName);
try (FileChannel outChannel = FileChannel.open(finalPath,
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
for (int i = 0; i < totalChunks; i++) {
Path chunkPath = tempDir.resolve(String.valueOf(i));
try (FileChannel inChannel = FileChannel.open(chunkPath, StandardOpenOption.READ)) {
// 零拷貝:直接在核心態傳輸,極快
long transferred = 0;
long size = inChannel.size();
while (transferred < size) {
transferred += inChannel.transferTo(transferred, size - transferred, outChannel);
}
}
Files.deleteIfExists(chunkPath); // 即時清理
}
Files.deleteIfExists(tempDir);
return finalPath;
} catch (IOException e) {
throw new BizException("MERGE_FAILED", "切片合併失敗", e);
}
}
| 風險 | 防護措施 |
|---|---|
| 惡意文件上傳 (WebShell) | 1. 白名單 Content-Type + 檔名後綴雙重校驗 2. 魔數檢測真實格式 3. 儲存路徑與 Web 根目錄隔離 (不可直接訪問) 4. 病毒掃描 (ClamAV 集成) 5. 圖片重繪 (ImageIO 讀寫去除腳本) |
| 路徑遍歷攻擊 | 檔名正規化:Paths.get(filename).getFileName().toString() |
| DoS (大文件佔滿磁碟/記憶體) | max-file-size、max-request-size、file-size-threshold、磁碟配額監控 |
| 文件覆蓋/刪除 | 儲存鍵使用 UUID、資料庫記錄所有權、軟刪除 |
| 未授權下載 | 下載介面驗證權限、簽名 URL 時效性、防盜鏈 Referer 檢查 |
Spring Boot 文件上傳看似簡單,實則涉及流式處理、安全防護、分散式儲存、大文件斷點續傳等多個工程難點。掌握 MultipartFile 流式 API、FileChannel 零拷貝、預簽名直傳、切片 MD5 校驗等核心技術,配合分層儲存抽象 (Local/S3/HDFS)、完善的安全白名單與審計日誌,即可構建企業級、高效能、安全合規的文件上傳服務。