Spring Boot 3.x Multipart文件上传文件名中文乱码问题详解

一、问题背景

在Spring Boot 3.x中进行文件上传时,中文文件名经常出现乱码问题。这个问题通常由以下几个环节的编码不一致导致:

  1. 客户端编码
  2. HTTP请求编码
  3. Spring MVC解析编码
  4. Servlet容器编码
  5. 文件系统编码

二、问题现象

常见乱码场景:

// 原始文件名:测试文件.txt
// 获取到的文件名:测试文件.txt 或其他乱码形式

不同环境表现:

  1. Windows系统:GBK编码问题
  2. Linux系统:UTF-8编码问题
  3. 浏览器差异:不同浏览器编码处理方式不同
  4. 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文件上传的中文文件名乱码问题。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐