Spring Boot 3.x Multipart文件上传文件名中文乱码问题详解
·
目录
Spring Boot 3.x Multipart文件上传文件名中文乱码问题详解
一、问题背景
在Spring Boot 3.x中进行文件上传时,中文文件名经常出现乱码问题。这个问题通常由以下几个环节的编码不一致导致:
- 客户端编码
- HTTP请求编码
- Spring MVC解析编码
- Servlet容器编码
- 文件系统编码
二、问题现象
常见乱码场景:
// 原始文件名:测试文件.txt
// 获取到的文件名:测试文件.txt 或其他乱码形式
不同环境表现:
- Windows系统:GBK编码问题
- Linux系统:UTF-8编码问题
- 浏览器差异:不同浏览器编码处理方式不同
- HTTP客户端差异:Postman、cURL等工具设置不同
三、根本原因分析
1. 编码链路上的关键节点
客户端(浏览器/工具) → HTTP请求 → Servlet容器 → Spring MVC → 文件系统
编码A 编码B 编码C 编码D 编码E
任何一个环节编码不一致都会导致乱码。
2. Spring Boot 3.x的变化
- 基于Jakarta EE 9+
- 默认字符编码为UTF-8
- 内嵌Tomcat配置变化
3. 常见乱码原因
- 客户端未指定正确编码
- Servlet容器解码错误
- Spring MVC配置缺失
- 操作系统默认编码不匹配
四、详细解决方案
方案1:全局配置解决方案(推荐)
1.1 application.yml/application.properties配置
# application.yml
spring:
servlet:
multipart:
enabled: true
# 文件大小限制
max-file-size: 10MB
max-request-size: 100MB
# 内存阈值
file-size-threshold: 0B
# 临时目录(确保目录存在且可写)
location: /tmp
# HTTP编码配置
http:
encoding:
charset: UTF-8
enabled: true
force: true
force-request: true
force-response: true
# Tomcat配置(如果使用Tomcat)
server:
tomcat:
# URI编码
uri-encoding: UTF-8
# 连接器配置
connection-timeout: 20000
max-connections: 8192
# 关闭连接时是否等待请求完成
relaxed-query-chars: '|,{,},[,]'
# 允许的特殊字符
relaxed-path-chars: '|'
# 请求头大小限制
max-http-header-size: 8192
# 服务器通用配置
port: 8080
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
1.2 配置类方案
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
import org.springframework.web.filter.CharacterEncodingFilter;
import org.springframework.web.multipart.MultipartResolver;
import org.springframework.web.multipart.support.StandardServletMultipartResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterRegistration;
import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.ServletContext;
import java.util.EnumSet;
@Configuration
public class MultipartConfig implements WebMvcConfigurer {
/**
* 配置MultipartResolver
* Spring Boot 3.x默认使用StandardServletMultipartResolver
*/
@Bean
public MultipartResolver multipartResolver() {
StandardServletMultipartResolver resolver = new StandardServletMultipartResolver();
// 启用对multipart请求的支持
resolver.setResolveLazily(true);
return resolver;
}
/**
* Multipart配置(替代application.yml配置)
*/
@Bean
public MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
// 设置文件大小限制
factory.setMaxFileSize(DataSize.ofMegabytes(10));
factory.setMaxRequestSize(DataSize.ofMegabytes(100));
// 设置临时目录(确保有写入权限)
String tempDir = System.getProperty("java.io.tmpdir");
factory.setLocation(tempDir);
// 设置内存阈值(超过此大小会写入临时文件)
factory.setFileSizeThreshold(DataSize.ofBytes(0));
return factory.createMultipartConfig();
}
/**
* 字符编码过滤器
* 解决请求和响应的中文乱码问题
*/
@Bean
public CharacterEncodingFilter characterEncodingFilter() {
CharacterEncodingFilter filter = new CharacterEncodingFilter();
filter.setEncoding("UTF-8");
filter.setForceEncoding(true);
filter.setForceRequestEncoding(true);
filter.setForceResponseEncoding(true);
return filter;
}
/**
* 手动注册编码过滤器(确保优先级最高)
*/
@Bean
public FilterRegistration<CharacterEncodingFilter> encodingFilterRegistration(
ServletContext servletContext
) {
FilterRegistration<CharacterEncodingFilter> registration =
new FilterRegistration<>();
registration.setFilter(characterEncodingFilter());
registration.addUrlPatterns("/*");
registration.setName("characterEncodingFilter");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.setDispatcherTypes(
EnumSet.of(
DispatcherType.REQUEST,
DispatcherType.ASYNC,
DispatcherType.FORWARD,
DispatcherType.ERROR
)
);
return registration;
}
}
方案2:自定义MultipartResolver
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
import jakarta.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.List;
/**
* 自定义MultipartResolver解决中文文件名乱码
* 兼容Spring Boot 3.x
*/
@Configuration
public class CustomMultipartConfig {
@Bean(name = "multipartResolver")
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver() {
private final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
@Override
public MultipartHttpServletRequest resolveMultipart(
HttpServletRequest request
) throws MultipartException {
// 处理请求编码
processRequestEncoding(request);
// 调用父类方法解析multipart请求
return super.resolveMultipart(request);
}
/**
* 处理请求编码
*/
private void processRequestEncoding(HttpServletRequest request) {
try {
// 1. 设置请求字符编码为UTF-8
if (request.getCharacterEncoding() == null) {
request.setCharacterEncoding(DEFAULT_CHARSET.name());
}
// 2. 处理文件名编码(针对不同的浏览器和客户端)
String contentType = request.getContentType();
if (contentType != null &&
contentType.toLowerCase().startsWith("multipart/")) {
// 检查Content-Type中是否指定了charset
if (contentType.contains("charset=")) {
String charset = contentType
.split("charset=")[1]
.split(";")[0]
.trim();
try {
request.setCharacterEncoding(charset);
} catch (UnsupportedEncodingException e) {
// 如果不支持该编码,回退到UTF-8
request.setCharacterEncoding(DEFAULT_CHARSET.name());
}
}
}
// 3. 处理请求参数编码
Enumeration<String> paramNames = request.getParameterNames();
while (paramNames.hasMoreElements()) {
String paramName = paramNames.nextElement();
String[] values = request.getParameterValues(paramName);
if (values != null) {
for (int i = 0; i < values.length; i++) {
values[i] = decodeValue(values[i]);
}
}
}
} catch (UnsupportedEncodingException e) {
throw new MultipartException("设置编码失败", e);
}
}
/**
* 解码值,尝试多种编码
*/
private String decodeValue(String value) {
if (value == null) {
return null;
}
// 已经是正确的UTF-8,直接返回
if (isValidUtf8(value)) {
return value;
}
// 尝试常用编码
String[] charsets = {
"UTF-8",
"GBK",
"GB2312",
"ISO-8859-1",
"Windows-1252"
};
for (String charset : charsets) {
try {
// 先按ISO-8859-1解码,再按目标编码重新编码
if ("ISO-8859-1".equals(charset)) {
return new String(value.getBytes(StandardCharsets.ISO_8859_1),
StandardCharsets.UTF_8);
}
String decoded = new String(
value.getBytes(StandardCharsets.ISO_8859_1),
charset
);
// 验证解码结果是否合理
if (isReasonableString(decoded)) {
return decoded;
}
} catch (UnsupportedEncodingException e) {
// 继续尝试下一个编码
continue;
}
}
// 所有编码尝试失败,返回原始值
return value;
}
/**
* 检查是否为有效的UTF-8字符串
*/
private boolean isValidUtf8(String str) {
try {
byte[] bytes = str.getBytes("UTF-8");
String decoded = new String(bytes, "UTF-8");
return str.equals(decoded);
} catch (UnsupportedEncodingException e) {
return false;
}
}
/**
* 检查字符串是否合理(不包含过多乱码字符)
*/
private boolean isReasonableString(String str) {
if (str == null || str.isEmpty()) {
return true;
}
int chineseCharCount = 0;
int garbageCharCount = 0;
for (char c : str.toCharArray()) {
// 中文字符范围
if (c >= '\u4E00' && c <= '\u9FFF') {
chineseCharCount++;
}
// 控制字符(可能表示乱码)
else if (Character.isISOControl(c) && c != '\n' && c != '\r' && c != '\t') {
garbageCharCount++;
}
}
// 如果中文字符数大于乱码字符数,认为是合理的
return chineseCharCount > garbageCharCount;
}
};
// 设置默认编码
resolver.setDefaultEncoding("UTF-8");
// 启用懒加载解析(提高性能)
resolver.setResolveLazily(true);
// 设置最大内存大小(字节)
resolver.setMaxInMemorySize(40960);
// 设置最大上传大小(字节)
resolver.setMaxUploadSize(104857600); // 100MB
// 设置最大上传文件大小(字节)
resolver.setMaxUploadSizePerFile(10485760); // 10MB
return resolver;
}
}
方案3:Servlet容器配置(Tomcat)
import org.apache.catalina.Context;
import org.apache.catalina.connector.Connector;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Tomcat容器配置
* 解决中文路径和文件名乱码
*/
@Configuration
public class TomcatConfig {
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
return factory -> {
factory.addConnectorCustomizers(connector -> {
// 配置连接器
configureConnector(connector);
});
factory.addContextCustomizers(context -> {
// 配置上下文
configureContext(context);
});
};
}
/**
* 配置Tomcat连接器
*/
private void configureConnector(Connector connector) {
// 设置URI编码
connector.setURIEncoding("UTF-8");
// 设置启用查询字符串解码
connector.setParseBodyMethods("POST,PUT,PATCH,DELETE");
// 设置最大POST大小
connector.setMaxPostSize(104857600); // 100MB
// 设置最大头大小
connector.setMaxHttpHeaderSize(8192);
// 设置连接超时
connector.setConnectionTimeout(30000);
// 启用压缩
connector.setProperty("compression", "on");
connector.setProperty("compressionMinSize", "1024");
connector.setProperty("compressableMimeType",
"text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json");
// 处理特殊字符
connector.setProperty("relaxedQueryChars", "|,{,},[,]");
connector.setProperty("relaxedPathChars", "|");
}
/**
* 配置Tomcat上下文
*/
private void configureContext(Context context) {
// 设置会话Cookie处理器
Rfc6265CookieProcessor cookieProcessor = new Rfc6265CookieProcessor();
cookieProcessor.setCharset("UTF-8");
context.setCookieProcessor(cookieProcessor);
// 禁用URL编码
context.setDisableURLRewriting(false);
// 设置编码
context.setRequestCharacterEncoding("UTF-8");
context.setResponseCharacterEncoding("UTF-8");
// 启用跨域支持
context.addWelcomeFile("index.html");
context.addWelcomeFile("index.htm");
}
/**
* 创建自定义Tomcat工厂
*/
@Bean
public TomcatServletWebServerFactory tomcatServletWebServerFactory() {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory() {
@Override
protected void postProcessContext(Context context) {
// 后处理上下文,确保编码设置生效
super.postProcessContext(context);
// 添加字符编码过滤器
addEncodingFilter(context);
// 配置MIME类型
configureMimeTypes(context);
}
};
// 添加上下文监听器
factory.addContextCustomizers(context -> {
context.addApplicationListener(new EncodingContextListener());
});
return factory;
}
/**
* 添加编码过滤器
*/
private void addEncodingFilter(Context context) {
FilterDef filterDef = new FilterDef();
filterDef.setFilterName("encodingFilter");
filterDef.setFilterClass(CharacterEncodingFilter.class.getName());
filterDef.addInitParameter("encoding", "UTF-8");
filterDef.addInitParameter("forceEncoding", "true");
context.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap();
filterMap.setFilterName("encodingFilter");
filterMap.addURLPattern("/*");
context.addFilterMap(filterMap);
}
/**
* 配置MIME类型
*/
private void configureMimeTypes(Context context) {
context.addMimeMapping("txt", "text/plain;charset=UTF-8");
context.addMimeMapping("html", "text/html;charset=UTF-8");
context.addMimeMapping("htm", "text/html;charset=UTF-8");
context.addMimeMapping("json", "application/json;charset=UTF-8");
context.addMimeMapping("xml", "application/xml;charset=UTF-8");
}
/**
* 编码上下文监听器
*/
public static class EncodingContextListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
ServletContext servletContext = sce.getServletContext();
// 设置上下文参数
servletContext.setInitParameter("fileEncoding", "UTF-8");
servletContext.setInitParameter("requestEncoding", "UTF-8");
servletContext.setInitParameter("responseEncoding", "UTF-8");
// 日志记录编码配置
System.out.println("Servlet上下文初始化,编码设置为UTF-8");
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
// 清理资源
}
}
}
方案4:文件上传处理器
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/files")
@CrossOrigin(origins = "*")
public class FileUploadController {
private static final String UPLOAD_DIR = "uploads";
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"txt", "pdf", "doc", "docx", "xls", "xlsx", "jpg", "jpeg", "png", "gif"
);
/**
* 单文件上传(兼容各种编码)
*/
@PostMapping("/upload")
public UploadResponse uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "charset", defaultValue = "UTF-8") String charset
) throws IOException {
// 1. 获取原始文件名并修复编码
String originalFilename = fixFilenameEncoding(file.getOriginalFilename(), charset);
// 2. 验证文件类型
validateFileExtension(originalFilename);
// 3. 生成安全文件名
String safeFilename = generateSafeFilename(originalFilename);
// 4. 创建上传目录
Path uploadPath = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize();
Files.createDirectories(uploadPath);
// 5. 保存文件
Path targetPath = uploadPath.resolve(safeFilename);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, targetPath);
}
// 6. 记录文件信息
FileInfo fileInfo = new FileInfo(
safeFilename,
originalFilename,
file.getSize(),
file.getContentType(),
targetPath.toString()
);
return UploadResponse.success("文件上传成功", fileInfo);
}
/**
* 多文件上传
*/
@PostMapping("/upload-multiple")
public UploadResponse uploadMultipleFiles(
HttpServletRequest request
) throws IOException {
List<FileInfo> uploadedFiles = new ArrayList<>();
List<String> errors = new ArrayList<>();
// 处理请求编码
request.setCharacterEncoding("UTF-8");
if (request instanceof MultipartHttpServletRequest) {
MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
// 获取所有文件
Map<String, MultipartFile> fileMap = multipartRequest.getFileMap();
for (Map.Entry<String, MultipartFile> entry : fileMap.entrySet()) {
try {
MultipartFile file = entry.getValue();
// 修复文件名编码
String originalFilename = fixFilenameEncoding(
file.getOriginalFilename(),
detectCharset(file.getOriginalFilename())
);
// 验证文件
validateFileExtension(originalFilename);
// 生成安全文件名
String safeFilename = generateSafeFilename(originalFilename);
// 保存文件
Path uploadPath = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize();
Files.createDirectories(uploadPath);
Path targetPath = uploadPath.resolve(safeFilename);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, targetPath);
}
// 记录文件信息
FileInfo fileInfo = new FileInfo(
safeFilename,
originalFilename,
file.getSize(),
file.getContentType(),
targetPath.toString()
);
uploadedFiles.add(fileInfo);
} catch (Exception e) {
errors.add(entry.getKey() + ": " + e.getMessage());
}
}
}
if (!uploadedFiles.isEmpty()) {
return UploadResponse.success(
"文件上传完成",
uploadedFiles,
errors.isEmpty() ? null : errors
);
} else {
return UploadResponse.error("文件上传失败: " + String.join(", ", errors));
}
}
/**
* 修复文件名编码(核心方法)
*/
private String fixFilenameEncoding(String filename, String charset) {
if (filename == null) {
return "unknown";
}
// 如果已经是正确的UTF-8,直接返回
if (isValidUtf8(filename)) {
return filename;
}
// 尝试使用指定编码解码
try {
return new String(filename.getBytes(StandardCharsets.ISO_8859_1), charset);
} catch (UnsupportedEncodingException e) {
// 尝试自动检测编码
return autoDetectAndFixEncoding(filename);
}
}
/**
* 自动检测并修复编码
*/
private String autoDetectAndFixEncoding(String filename) {
// 常用编码列表
String[] charsets = {"UTF-8", "GBK", "GB2312", "ISO-8859-1", "Windows-1252"};
for (String charset : charsets) {
try {
String decoded = new String(
filename.getBytes(StandardCharsets.ISO_8859_1),
charset
);
// 检查解码结果是否合理
if (isReasonableString(decoded)) {
return decoded;
}
} catch (UnsupportedEncodingException e) {
continue;
}
}
// 所有尝试都失败,返回原始值
return filename;
}
/**
* 检测编码
*/
private String detectCharset(String filename) {
if (filename == null) {
return "UTF-8";
}
// 简单检测:检查是否包含中文
boolean containsChinese = filename.matches(".*[\u4e00-\u9fff].*");
// 检查是否是ISO-8859-1编码的中文
boolean isIso88591EncodedChinese = false;
try {
String utf8 = new String(filename.getBytes(StandardCharsets.ISO_8859_1), "UTF-8");
isIso88591EncodedChinese = utf8.matches(".*[\u4e00-\u9fff].*");
} catch (Exception e) {
// 忽略异常
}
if (containsChinese || isIso88591EncodedChinese) {
return "GBK"; // 中文系统常用编码
}
return "UTF-8"; // 默认编码
}
/**
* 生成安全文件名
*/
private String generateSafeFilename(String originalFilename) {
if (originalFilename == null || originalFilename.isEmpty()) {
return "file_" + System.currentTimeMillis();
}
// 提取文件名和扩展名
String nameWithoutExt;
String extension;
int lastDotIndex = originalFilename.lastIndexOf('.');
if (lastDotIndex > 0) {
nameWithoutExt = originalFilename.substring(0, lastDotIndex);
extension = originalFilename.substring(lastDotIndex);
} else {
nameWithoutExt = originalFilename;
extension = "";
}
// 清理文件名(移除特殊字符)
String safeName = nameWithoutExt
.replaceAll("[\\\\/:*?\"<>|]", "_") // 替换Windows非法字符
.replaceAll("\\s+", "_"); // 替换空格
// 添加时间戳防止重名
String timestamp = String.valueOf(System.currentTimeMillis());
String random = String.valueOf((int)(Math.random() * 1000));
return safeName + "_" + timestamp + "_" + random + extension;
}
/**
* 验证文件扩展名
*/
private void validateFileExtension(String filename) {
if (filename == null || filename.isEmpty()) {
throw new IllegalArgumentException("文件名不能为空");
}
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex <= 0) {
throw new IllegalArgumentException("文件必须包含扩展名");
}
String extension = filename.substring(lastDotIndex + 1).toLowerCase();
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("不支持的文件类型: " + extension);
}
}
/**
* 检查是否为有效的UTF-8
*/
private boolean isValidUtf8(String str) {
try {
byte[] bytes = str.getBytes("UTF-8");
String decoded = new String(bytes, "UTF-8");
return str.equals(decoded);
} catch (UnsupportedEncodingException e) {
return false;
}
}
/**
* 检查字符串是否合理
*/
private boolean isReasonableString(String str) {
if (str == null || str.isEmpty()) {
return true;
}
// 检查是否包含可打印字符
long printableCount = str.chars()
.filter(c -> c >= 32 && c <= 126 || c >= 0x4E00 && c <= 0x9FFF)
.count();
// 检查乱码字符比例
double printableRatio = (double) printableCount / str.length();
return printableRatio > 0.7; // 可打印字符比例超过70%
}
// 响应类和实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class UploadResponse {
private boolean success;
private String message;
private List<FileInfo> files;
private List<String> errors;
public static UploadResponse success(String message, FileInfo file) {
return success(message, Collections.singletonList(file), null);
}
public static UploadResponse success(String message, List<FileInfo> files, List<String> errors) {
UploadResponse response = new UploadResponse();
response.setSuccess(true);
response.setMessage(message);
response.setFiles(files);
response.setErrors(errors);
return response;
}
public static UploadResponse error(String message) {
UploadResponse response = new UploadResponse();
response.setSuccess(false);
response.setMessage(message);
return response;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class FileInfo {
private String safeFilename;
private String originalFilename;
private long size;
private String contentType;
private String filePath;
private Date uploadTime = new Date();
}
}
方案5:WebFlux响应式上传处理
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Map;
/**
* WebFlux响应式文件上传处理
* 适用于Spring WebFlux项目
*/
@RestController
@RequestMapping("/api/reactive/files")
public class ReactiveFileUploadController {
private static final String UPLOAD_DIR = "uploads";
/**
* 响应式单文件上传
*/
@PostMapping("/upload")
public Mono<UploadResponse> uploadFile(
@RequestPart("file") FilePart filePart,
@RequestPart(value = "charset", required = false) String charset,
ServerWebExchange exchange
) {
return Mono.fromCallable(() -> {
// 获取文件名并修复编码
String originalFilename = filePart.filename();
String fixedFilename = fixFilenameEncoding(
originalFilename,
charset != null ? charset : "UTF-8"
);
// 生成安全文件名
String safeFilename = generateSafeFilename(fixedFilename);
// 创建上传目录
Path uploadPath = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize();
if (!java.nio.file.Files.exists(uploadPath)) {
java.nio.file.Files.createDirectories(uploadPath);
}
// 保存文件
Path targetPath = uploadPath.resolve(safeFilename);
return filePart.transferTo(targetPath)
.then(Mono.just(UploadResponse.success(
"文件上传成功",
new FileInfo(
safeFilename,
fixedFilename,
java.nio.file.Files.size(targetPath),
filePart.headers().getContentType().toString(),
targetPath.toString()
)
)));
}).flatMap(mono -> mono);
}
/**
* 响应式多文件上传
*/
@PostMapping("/upload-multiple")
public Mono<UploadResponse> uploadMultipleFiles(
ServerWebExchange exchange
) {
return exchange.getMultipartData()
.flatMapMany(parts -> {
// 获取所有文件部分
Flux<FilePart> files = parts.values().stream()
.flatMap(partList -> partList.stream())
.filter(part -> part instanceof FilePart)
.map(part -> (FilePart) part)
.collect(Flux::fromArray, Flux::concatWith)
.orElse(Flux.empty());
// 处理每个文件
return files.flatMap(filePart -> {
try {
String originalFilename = filePart.filename();
String fixedFilename = fixFilenameEncoding(originalFilename, "UTF-8");
String safeFilename = generateSafeFilename(fixedFilename);
Path uploadPath = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize();
if (!java.nio.file.Files.exists(uploadPath)) {
java.nio.file.Files.createDirectories(uploadPath);
}
Path targetPath = uploadPath.resolve(safeFilename);
return filePart.transferTo(targetPath)
.then(Mono.just(new FileInfo(
safeFilename,
fixedFilename,
java.nio.file.Files.size(targetPath),
filePart.headers().getContentType().toString(),
targetPath.toString()
)));
} catch (Exception e) {
return Mono.error(e);
}
});
})
.collectList()
.map(fileInfos -> UploadResponse.success(
"文件上传成功",
fileInfos,
null
))
.onErrorResume(e -> Mono.just(UploadResponse.error(e.getMessage())));
}
/**
* 修复文件名编码
*/
private String fixFilenameEncoding(String filename, String charset) {
if (filename == null) {
return "unknown";
}
try {
// 尝试检测编码
if (charset.equals("auto")) {
charset = detectCharset(filename);
}
// 解码文件名
byte[] bytes = filename.getBytes(StandardCharsets.ISO_8859_1);
return new String(bytes, Charset.forName(charset));
} catch (Exception e) {
// 如果失败,尝试常见编码
return tryCommonEncodings(filename);
}
}
/**
* 尝试常见编码
*/
private String tryCommonEncodings(String filename) {
String[] charsets = {"UTF-8", "GBK", "GB2312", "Windows-1252"};
for (String charset : charsets) {
try {
String decoded = new String(
filename.getBytes(StandardCharsets.ISO_8859_1),
charset
);
// 简单的合理性检查
if (!decoded.contains("�")) { // 不含替换字符
return decoded;
}
} catch (Exception e) {
continue;
}
}
return filename;
}
/**
* 生成安全文件名
*/
private String generateSafeFilename(String originalFilename) {
// 实现同方案4
return originalFilename; // 简化实现
}
/**
* 检测编码
*/
private String detectCharset(String filename) {
// 实现同方案4
return "UTF-8";
}
}
方案6:测试工具和调试方法
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.StandardCharsets;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
public class FileUploadEncodingTest {
@Autowired
private MockMvc mockMvc;
/**
* 测试UTF-8编码的文件名
*/
@Test
public void testUtf8Filename() throws Exception {
String filename = "测试文件.txt";
String content = "这是测试内容";
MockMultipartFile file = new MockMultipartFile(
"file",
filename,
"text/plain",
content.getBytes(StandardCharsets.UTF_8)
);
mockMvc.perform(MockMvcRequestBuilders.multipart("/api/files/upload")
.file(file)
.characterEncoding("UTF-8"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.files[0].originalFilename").value(filename));
}
/**
* 测试GBK编码的文件名(模拟旧系统)
*/
@Test
public void testGbkFilename() throws Exception {
String gbkFilename = new String("测试文件.txt".getBytes("GBK"), StandardCharsets.ISO_8859_1);
String content = "GBK编码测试";
MockMultipartFile file = new MockMultipartFile(
"file",
gbkFilename,
"text/plain",
content.getBytes("GBK")
);
mockMvc.perform(MockMvcRequestBuilders.multipart("/api/files/upload")
.file(file)
.characterEncoding("GBK"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
/**
* 测试ISO-8859-1编码的文件名
*/
@Test
public void testIso88591Filename() throws Exception {
String isoFilename = new String("test_file.txt".getBytes(StandardCharsets.ISO_8859_1));
String content = "ISO-8859-1 content";
MockMultipartFile file = new MockMultipartFile(
"file",
isoFilename,
"text/plain",
content.getBytes(StandardCharsets.ISO_8859_1)
);
mockMvc.perform(MockMvcRequestBuilders.multipart("/api/files/upload")
.file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
/**
* 测试文件名包含特殊字符
*/
@Test
public void testSpecialCharactersInFilename() throws Exception {
String filename = "测试#文件&名称@2024.txt";
String content = "特殊字符测试";
MockMultipartFile file = new MockMultipartFile(
"file",
filename,
"text/plain",
content.getBytes(StandardCharsets.UTF_8)
);
mockMvc.perform(MockMvcRequestBuilders.multipart("/api/files/upload")
.file(file)
.characterEncoding("UTF-8"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
/**
* 测试大文件上传
*/
@Test
public void testLargeFileUpload() throws Exception {
// 生成2MB的测试文件
byte[] largeContent = new byte[2 * 1024 * 1024];
for (int i = 0; i < largeContent.length; i++) {
largeContent[i] = (byte) (i % 256);
}
MockMultipartFile file = new MockMultipartFile(
"file",
"大文件测试.dat",
"application/octet-stream",
largeContent
);
mockMvc.perform(MockMvcRequestBuilders.multipart("/api/files/upload")
.file(file)
.characterEncoding("UTF-8"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
/**
* 测试多文件上传
*/
@Test
public void testMultipleFileUpload() throws Exception {
MockMultipartFile file1 = new MockMultipartFile(
"files",
"文件1.txt",
"text/plain",
"内容1".getBytes(StandardCharsets.UTF_8)
);
MockMultipartFile file2 = new MockMultipartFile(
"files",
"文件2.txt",
"text/plain",
"内容2".getBytes(StandardCharsets.UTF_8)
);
mockMvc.perform(MockMvcRequestBuilders.multipart("/api/files/upload-multiple")
.file(file1)
.file(file2)
.characterEncoding("UTF-8"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.files.length()").value(2));
}
}
五、调试和诊断工具
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.*;
/**
* 调试端点,用于诊断文件上传编码问题
*/
@RestController
@RequestMapping("/debug")
public class DebugController {
/**
* 显示请求头信息
*/
@PostMapping("/headers")
public Map<String, Object> showHeaders(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
// 请求基本信息
result.put("method", request.getMethod());
result.put("contentType", request.getContentType());
result.put("contentLength", request.getContentLength());
result.put("characterEncoding", request.getCharacterEncoding());
result.put("locale", request.getLocale().toString());
// 请求头
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
headers.put(name, request.getHeader(name));
}
result.put("headers", headers);
// 参数信息
Map<String, String[]> parameters = request.getParameterMap();
result.put("parameters", parameters);
return result;
}
/**
* 显示Multipart请求详情
*/
@PostMapping("/multipart-detail")
public Map<String, Object> multipartDetail(HttpServletRequest request) throws IOException {
Map<String, Object> result = new HashMap<>();
if (request instanceof MultipartHttpServletRequest) {
MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
// Multipart配置信息
result.put("isMultipart", true);
result.put("multipartContentType", multipartRequest.getContentType());
// 文件信息
List<Map<String, Object>> files = new ArrayList<>();
multipartRequest.getFileMap().forEach((name, file) -> {
Map<String, Object> fileInfo = new HashMap<>();
fileInfo.put("parameterName", name);
fileInfo.put("originalFilename", file.getOriginalFilename());
fileInfo.put("filenameBytes", bytesToHex(file.getOriginalFilename().getBytes()));
fileInfo.put("size", file.getSize());
fileInfo.put("contentType", file.getContentType());
files.add(fileInfo);
});
result.put("files", files);
// 解析文件名编码
List<Map<String, Object>> encodingTests = new ArrayList<>();
multipartRequest.getFileMap().values().forEach(file -> {
String filename = file.getOriginalFilename();
Map<String, Object> test = new HashMap<>();
test.put("original", filename);
test.put("originalHex", bytesToHex(filename.getBytes()));
// 测试不同编码
Map<String, String> decoded = new HashMap<>();
String[] charsets = {"UTF-8", "GBK", "GB2312", "ISO-8859-1", "Windows-1252"};
for (String charset : charsets) {
try {
String decodedName = new String(
filename.getBytes(Charset.forName("ISO-8859-1")),
charset
);
decoded.put(charset, decodedName);
} catch (Exception e) {
decoded.put(charset, "ERROR: " + e.getMessage());
}
}
test.put("decodedWithDifferentCharsets", decoded);
encodingTests.add(test);
});
result.put("encodingTests", encodingTests);
} else {
result.put("isMultipart", false);
result.put("contentType", request.getContentType());
}
return result;
}
/**
* 测试编码转换
*/
@GetMapping("/encoding-test")
public Map<String, Object> encodingTest(@RequestParam String text) {
Map<String, Object> result = new HashMap<>();
result.put("original", text);
result.put("originalBytes", bytesToHex(text.getBytes()));
// 测试不同编码
Map<String, Object> encodings = new HashMap<>();
String[] charsetNames = {
"UTF-8", "GBK", "GB2312", "ISO-8859-1",
"Windows-1252", "Big5", "Shift_JIS"
};
for (String charsetName : charsetNames) {
try {
Charset charset = Charset.forName(charsetName);
byte[] bytes = text.getBytes(charset);
String decoded = new String(bytes, charset);
Map<String, Object> info = new HashMap<>();
info.put("bytes", bytesToHex(bytes));
info.put("decoded", decoded);
info.put("isValid", text.equals(decoded));
encodings.put(charsetName, info);
} catch (Exception e) {
encodings.put(charsetName, "ERROR: " + e.getMessage());
}
}
result.put("encodings", encodings);
return result;
}
/**
* 字节数组转十六进制字符串
*/
private String bytesToHex(byte[] bytes) {
if (bytes == null) {
return "null";
}
StringBuilder hex = new StringBuilder();
for (byte b : bytes) {
hex.append(String.format("%02X ", b));
}
return hex.toString().trim();
}
}
六、最佳实践总结
1. 统一编码策略
- 始终使用UTF-8作为系统默认编码
- 在HTTP请求/响应、数据库、文件系统等各层保持编码一致
- 明确指定编码,不要依赖平台默认值
2. 配置优先级
1. 系统环境变量(JVM参数)
2. application.yml/application.properties
3. 配置类(@Configuration)
4. 过滤器(CharacterEncodingFilter)
5. 控制器处理方法
3. 安全考虑
- 验证文件扩展名
- 限制文件大小
- 清理文件名中的特殊字符
- 使用安全的临时目录
4. 性能优化
- 使用懒加载解析大文件
- 设置合理的内存阈值
- 及时清理临时文件
- 考虑使用分片上传大文件
5. 兼容性处理
- 处理不同浏览器的编码差异
- 支持常见的中文编码(GBK、GB2312)
- 提供编码自动检测功能
6. 监控和日志
- 记录文件上传的编码信息
- 监控上传失败的原因
- 提供诊断端点供问题排查
七、常见问题FAQ
Q1: 为什么在Windows和Linux上表现不同?
A: Windows默认使用GBK编码,Linux使用UTF-8编码。需要在应用层统一编码。
Q2: 如何处理旧系统上传的GBK编码文件?
A: 使用编码检测和转换工具,将GBK转换为UTF-8。
Q3: 文件名中的特殊字符如何处理?
A: 清理或转义特殊字符,或使用URL编码。
Q4: Spring Boot 2.x和3.x在处理上有何不同?
A: Spring Boot 3.x基于Jakarta EE,配置项名称和默认值可能有变化。
Q5: 如何测试编码问题?
A: 使用提供的调试端点或编写测试用例覆盖不同编码场景。
通过以上解决方案,可以有效解决Spring Boot 3.x中Multipart文件上传的中文文件名乱码问题。
更多推荐




所有评论(0)