HTTP 標準 Content-Disposition 頭僅支援 ASCII,中文文件名需編碼。
各瀏覽器實現差異巨大:
URLEncoder.encode(fileName, "UTF-8")RFC 5987 格式 filename*=UTF-8''encodedName,或 ISO-8859-1 偽裝
public class DownloadFilenameEncoder {
/**
* 根據 User-Agent 自動選擇編碼策略,返回可直接用於 Content-Disposition 的文件名片段
* 使用示例:response.setHeader("Content-Disposition", "attachment; filename=\"" + encode(request, name) + "\"");
*/
public static String encode(HttpServletRequest request, String originalFilename) {
String userAgent = request.getHeader("User-Agent");
boolean isIE = userAgent != null && (userAgent.contains("MSIE") || userAgent.contains("Trident") || userAgent.contains("Edge"));
try {
if (isIE) {
// IE/舊 Edge: URLEncoder 編碼,空格變 + 需改為 %20
String encoded = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.name());
return encoded.replace("+", "%20");
} else {
// 現代瀏覽器: RFC 5987 標準格式,完美支援中文及特殊字符
// 格式: filename*=UTF-8''encodedValue
String encoded = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.name())
.replace("+", "%20")
.replace("%21", "!").replace("%27", "'")
.replace("%28", "(").replace("%29", ")")
.replace("%7E", "~"); // RFC 3986 保留字符不編碼
return "filename*=UTF-8''" + encoded;
}
} catch (UnsupportedEncodingException e) {
// 理論不發生,降級處理
return originalFilename.replace("\"", "");
}
}
}
@WebServlet("/download")
public class FileDownloadServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String fileName = "產品報表_2024_Q1.xlsx";
String filePath = "/data/reports/" + fileName;
File file = new File(filePath);
if (!file.exists()) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 1. 設置響應頭
resp.setContentType("application/octet-stream");
resp.setHeader("Content-Disposition", "attachment; " + DownloadFilenameEncoder.encode(req, fileName));
resp.setContentLengthLong(file.length());
// 2. 流式傳輸 (NIO 零拷貝更高效)
try (FileChannel in = FileChannel.open(file.toPath(), StandardOpenOption.READ);
WritableByteChannel out = Channels.newChannel(resp.getOutputStream())) {
in.transferTo(0, file.length(), out);
}
}
}
@GetMapping("/download")
public void download(HttpServletRequest request, HttpServletResponse response) throws IOException {
String fileName = "用戶導出_" + LocalDate.now() + ".csv";
File file = new File("/tmp/exports/" + fileName);
response.setContentType("text/csv;charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; " + DownloadFilenameEncoder.encode(request, fileName));
Files.copy(file.toPath(), response.getOutputStream());
}
| 瀏覽器 | User-Agent 關鍵字 | 預期結果 |
|---|---|---|
| Chrome 90+ | Chrome/ | RFC 5987 完美中文 |
| Firefox 88+ | Firefox/ | RFC 5987 完美中文 |
| Edge 85+ (Chromium) | Edg/ | RFC 5987 完美中文 |
| IE 11 | Trident/7.0 | URLEncoder 正常 |
| 舊 Edge (EdgeHTML) | Edge/ (非 Edg/) | URLEncoder 正常 |
| Safari | Safari/ (無 Chrome) | RFC 5987 正常 |
| 移動端微信/釘釘內嵌 | MicroMessenger/DingTalk | 按內核策略 (多為 Chrome/WebKit) |
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
// 可選:統一響應頭處理
}
}
// 或使用 ResponseEntity
@GetMapping("/download")
public ResponseEntity<Resource> download(HttpServletRequest request) {
String fileName = "報表.pdf";
Resource resource = new FileSystemResource("/data/" + fileName);
String encoded = DownloadFilenameEncoder.encode(request, fileName);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; " + encoded)
.contentType(MediaType.APPLICATION_PDF)
.body(resource);
}