<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
@Configuration
@EnableWebMvc
@ComponentScan("com.example")
public class WebConfig implements WebMvcConfigurer {
// 1. 文件上傳解析器 (支援 100MB 以內)
@Bean
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
resolver.setDefaultEncoding("UTF-8");
resolver.setMaxUploadSize(100 * 1024 * 1024); // 100MB
resolver.setMaxInMemorySize(40960); // 40KB 閾值,超過寫臨時文件
return resolver;
}
// 2. 靜態資源映射 (可選)
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:/data/upload/");
}
}
@RestController
@RequestMapping("/api/file")
public class FileController {
private static final String BASE_DIR = "C:/tempDirectory/";
@GetMapping("/download/{filename:.+}")
public ResponseEntity<Resource> download(@PathVariable String filename, HttpServletRequest request) {
try {
// 1. 安全校驗:防止路徑遍歷攻擊
Path filePath = Paths.get(BASE_DIR).resolve(filename).normalize();
if (!filePath.startsWith(Paths.get(BASE_DIR))) {
return ResponseEntity.badRequest().build();
}
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isReadable()) {
return ResponseEntity.notFound().build();
}
// 2. 內容類型推斷
String contentType = request.getServletContext().getMimeType(resource.getFile().getAbsolutePath());
if (contentType == null) contentType = "application/octet-stream";
// 3. 中文文件名編碼 (RFC 5987)
String encodedName = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
String contentDisposition = "attachment; filename*=UTF-8''" + encodedName;
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.contentLength(resource.contentLength())
.body(resource);
} catch (Exception e) {
log.error("下載失敗: {}", filename, e);
return ResponseEntity.internalServerError().build();
}
}
}
@PostMapping("/upload")
public ResponseEntity<ApiResponse> upload(@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String bizType) {
// 1. 基礎校驗
if (file.isEmpty()) return ResponseEntity.badRequest().body(ApiResponse.fail("文件為空"));
// 2. 文件類型白名單
String contentType = file.getContentType();
if (!ALLOWED_TYPES.contains(contentType)) {
return ResponseEntity.badRequest().body(ApiResponse.fail("不支持的文件類型"));
}
// 3. 生成唯一文件名 (UUID + 原始後綴)
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
String saveName = UUID.randomUUID().toString().replace("-", "") + suffix;
// 4. 保存路徑:按日期分目錄避免單目錄文件過多
String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
Path saveDir = Paths.get(BASE_DIR, dateDir);
Files.createDirectories(saveDir);
Path target = saveDir.resolve(saveName);
try {
file.transferTo(target); // 底層用 NIO 複製,高效
} catch (IOException e) {
log.error("文件保存失敗", e);
return ResponseEntity.internalServerError().body(ApiResponse.fail("保存失敗"));
}
// 5. 業務入庫 (略)
// fileService.save(FileRecord.builder().originalName(originalName).savePath(target.toString()).build());
return ResponseEntity.ok(ApiResponse.success(Map.of("url", "/api/file/download/" + saveName)));
}
/upload/chunk 合併接口| 風險 | 防護措施 |
|---|---|
| 路徑遍歷 | 用 Path.normalize() + startsWith(baseDir) 校驗 |
| 惡意腳本上傳 | 白名單校驗 Content-Type + 後綴;存儲路徑與 Web 根目錄隔離 |
| 文件名注入 | 不使用原始文件名存儲,一律 UUID 重命名 |
| DoS 攻擊 | 限制單文件/總大小、限制並發上傳數、啟用防毒掃描 |
Spring MVC 文件上傳下載的核心在於:MultipartResolver 配置、Resource 抽象統一文件訪問、安全校驗全鏈路覆蓋。生產環境建議遷移至對象存儲 + 直傳模式,後端僅做元數據管理與簽名授權。