RFC 5987 規定 Content-Disposition 標頭應使用 UTF-8 編碼,但歷史原因導致各瀏覽器解析方式不一:
filename*=UTF-8''encoded_name 格式(RFC 5987)filename="ISO-8859-1_encoded" 或 filename="GBK_encoded"因此需要根據 User-Agent 動態選擇編碼策略。
import javax.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public final class FileNameEncoder {
private FileNameEncoder() {}
/**
* 通用入口:自動識別瀏覽器並返回安全檔名
*/
public static String encode(HttpServletRequest request, String originalName) {
String agent = request.getHeader("User-Agent");
boolean isIE = agent != null && (agent.contains("MSIE") || agent.contains("Trident"));
boolean isFirefox = agent != null && agent.toLowerCase().contains("firefox");
boolean isChrome = agent != null && agent.toLowerCase().contains("chrome");
boolean isSafari = agent != null && agent.toLowerCase().contains("safari") && !isChrome;
try {
// 現代瀏覽器優先使用 RFC 5987 標準格式
if (isFirefox || isChrome || isSafari) {
return "filename*=UTF-8''" + URLEncoder.encode(originalName, StandardCharsets.UTF_8)
.replace("+", "%20") // 空格編碼為 %20 而非 +
.replace("*", "%2A")
.replace("'", "%27");
}
// IE 及舊版瀏覽器:ISO-8859-1 編碼
if (isIE) {
String encoded = URLEncoder.encode(originalName, StandardCharsets.UTF_8)
.replace("+", "%20");
// IE 對過長檔名有限制,超過 150 字元需特殊處理
if (encoded.length() > 150) {
return "filename=\"" + new String(originalName.getBytes(StandardCharsets.UTF_8), "ISO-8859-1") + "\"";
}
return "filename=\"" + encoded + "\"";
}
// 預設回退:雙重標頭兼容
String utf8Encoded = "filename*=UTF-8''" + URLEncoder.encode(originalName, StandardCharsets.UTF_8)
.replace("+", "%20");
String isoEncoded = "filename=\"" + new String(originalName.getBytes(StandardCharsets.UTF_8), "ISO-8859-1") + "\"";
return utf8Encoded + "; " + isoEncoded;
} catch (UnsupportedEncodingException e) {
// 極端情況回退:僅保留 ASCII
return "filename=\"" + originalName.replaceAll("[^\\x00-\\x7F]", "_") + "\"";
}
}
/**
* 直接用於 Response Header 設置
*/
public static void setContentDisposition(HttpServletRequest request, HttpServletResponse response, String fileName) {
response.setHeader("Content-Disposition", "attachment; " + encode(request, fileName));
}
}
@GetMapping("/download")
public void download(HttpServletRequest request, HttpServletResponse response) throws IOException {
String fileName = "產品手冊_2024版.pdf";
String filePath = "/data/files/manual_2024.pdf";
// 關鍵:設置編碼後的 Content-Disposition
FileNameEncoder.setContentDisposition(request, response, fileName);
response.setContentType("application/octet-stream");
try (InputStream in = new FileInputStream(filePath);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
out.flush();
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/download/**")
.addResourceLocations("file:/data/files/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath, Resource location) throws IOException {
Resource resource = super.getResource(resourcePath, location);
if (resource != null && resource.exists()) {
// 可在這裡攔截並設置 Header,但較複雜,建議用 Controller
}
return resource;
}
});
}
}
原文提供的 toUtf8String 手動拼接 %XX 的方式屬於早期 URLEncoder 未普及時的產物,現已不推薦使用,理由:
URLEncoder.encode 內部已正確處理 UTF-8 多字節字符,無需手動拆解 byte[]
// 替換原手動編碼邏輯
public static String toUtf8String(String s) {
try {
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e); // 不會發生
}
}
| 瀏覽器 | 測試檔名 | 預期結果 |
|---|---|---|
| Chrome 120+ | 測試_文件(1).pdf | 彈出下載,檔名完整無亂碼 |
| Firefox 115+ | 报表_中文_测试.xlsx | 正常下載,編碼正確 |
| IE 11 / Edge IE模式 | 附件_测试_文档.doc | 下載正常,無亂碼 |
| Safari 16+ | 图片_测试_名称.png | 正常保存 |
| 移動端微信內置瀏覽器 | 微信_文件_测试.pdf | 正常下載 |
FileNameEncoder 工具類,所有下載接口強制調用。filename*=UTF-8''...; filename="fallback" 最大兼容性。filename.contains("..") 過濾)。採用此方案,可徹底解決 99% 以上場景的中文檔名亂碼問題,且代碼簡潔、易維護、符合現代 HTTP 標準。