FTP Unicode 文件名导致 `550` 的问题分析与解决方案

一、问题背景

应用程序通过 Spring Integration FTP 从旧系统下载报告文件,然后上传到 RustFS。某些文件可以正常下载,但文件名包含中文、全角括号或其他 Unicode 字符时,FTP 服务端返回 550:

text 复制代码
Failed to obtain InputStream for remote file ...: 550

典型文件名如下:

text 复制代码
S_UCloud(688158)Investmen_48287.pdf

这个文件名包含英文字符、全角括号、数字、空格和 PDF 扩展名。问题发生在 FTP 下载阶段, FTP 客户端发送文件名时,服务端没有正确识别该远程路径。

二、FTP 550 的实际含义

FTP 550 通常表示请求的远程文件不可用,但它不一定代表文件真的不存在。在本场景中,常见原因包括:

  1. 客户端发送的文件名与服务端实际文件名不一致。
  2. 客户端使用错误的控制通道编码发送文件名。
  3. 服务端没有启用 UTF-8 文件名协商。
  4. LIST 和 RETR 对文件名使用了不同的编码处理方式。
  5. 文件名中的全角字符在 FTP 服务端解析失败。
  6. FTP 账号没有读取该文件的权限。
  7. FTP 路径格式不符合服务端要求。

本次问题的关键证据是:目录列表和文件大小查询可以成功,但执行 RETR 下载时失败。

报错信息如下:

复制代码
o.s.m.MessagingException: Failed to execute on session
o.s.i.f.r.RemoteFileTemplate.execute(RemoteFileTemplate.java:461)
o.s.i.f.r.RemoteFileTemplate.get(RemoteFileTemplate.java:410)
c.c.r.w.d.s.ReportFileUploadService.uploadFromFtp(ReportFileUploadService.java:197) 
c.c.r.w.d.s.ReportFileUploadService.uploadFileToRustFS(ReportFileUploadService.java:125)
c.c.r.w.d.s.ReportFileUploadService.batchUploadFiles(ReportFileUploadService.java:543) 
c.c.r.w.d.s.FtpClientService.processReportFile(FtpClientService.java:1094)
c.c.r.w.d.c.ReportController.uploadFile(ReportController.java:172) 
j.i.r.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
j.l.reflect.Method.invoke(Method.java:565)\r\n\tat o.s.w.m.s.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:258) 
o.s.w.m.s.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:190)
o.s.w.s.m.m.a.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117) 
o.s.w.s.m.m.a.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:934) 
o.s.w.s.m.m.a.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:853)
o.s.w.s.m.m.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:86)
o.s.w.s.DispatcherServlet.doDispatch(DispatcherServlet.java:963)
o.s.w.s.DispatcherServlet.doService(DispatcherServlet.java:866)
o.s.w.s.FrameworkServlet.processRequest(FrameworkServlet.java:1003)
o.s.w.s.FrameworkServlet.doPost(FrameworkServlet.java:903)
j.s.http.HttpServlet.service(HttpServlet.java:649)
o.s.w.s.FrameworkServlet.service(FrameworkServlet.java:874) 
j.s.http.HttpServlet.service(HttpServlet.java:710) 
o.a.c.c.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128)
o.a.t.w.s.WsFilter.doFilter(WsFilter.java:53)
o.a.c.c.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107)
o.s.w.f.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) 
o.s.w.f.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
o.a.c.c.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) 
o.s.w.f.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
o.s.w.f.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)... 23 frames truncated
Caused by: java.io.IOException: Failed to obtain InputStream for remote file UCloud(688158)Investmen_48287.pdf: 550
o.s.i.f.s.FtpSession.readRaw(FtpSession.java:115)
o.s.i.f.r.s.CachingSessionFactory$CachedSession.readRaw(CachingSessionFactory.java:299) 
o.s.i.f.r.RemoteFileTemplate.lambda$get$0(RemoteFileTemplate.java:411)
o.s.i.f.r.RemoteFileTemplate.execute(RemoteFileTemplate.java:452)
o.s.i.f.r.RemoteFileTemplate.get(RemoteFileTemplate.java:410)
c.c.r.w.d.s.ReportFileUploadService.uploadFromFtp(ReportFileUploadService.java:197)
c.c.r.w.d.s.ReportFileUploadService.uploadFileToRustFS(ReportFileUploadService.java:125)
c.c.r.w.d.s.ReportFileUploadService.batchUploadFiles(ReportFileUploadService.java:543) 
c.c.r.w.d.s.FtpClientService.processReportFile(FtpClientService.java:1094)
c.c.r.w.d.c.ReportController.uploadFile(ReportController.java:172)
j.i.r.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
j.l.reflect.Method.invoke(Method.java:565)
o.s.w.m.s.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:258)
o.s.w.m.s.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:190)
o.s.w.s.m.m.a.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
o.s.w.s.m.m.a.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:934)
o.s.w.s.m.m.a.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:853)
o.s.w.s.m.m.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:86)
o.s.w.s.DispatcherServlet.doDispatch(DispatcherServlet.java:963)
o.s.w.s.DispatcherServlet.doService(DispatcherServlet.java:866)
o.s.w.s.FrameworkServlet.processRequest(FrameworkServlet.java:1003)
o.s.w.s.FrameworkServlet.doPost(FrameworkServlet.java:903)
j.s.http.HttpServlet.service(HttpServlet.java:649) 
o.s.w.s.FrameworkServlet.service(FrameworkServlet.java:874) 
j.s.http.HttpServlet.service(HttpServlet.java:710)
o.a.c.c.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) 
o.a.t.w.s.WsFilter.doFilter(WsFilter.java:53) 
o.a.c.c.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107)
o.s.w.f.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) 
o.s.w.f.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\r\n\t... 26 frames truncated

日志中先出现:

text 复制代码
File size not provided Retrieved FTP file size for reportId=607818730,
ftpPath=S_UCloud?688158?Investmen_48287.pdf,
fileSize=496460

随后下载同一个文件时返回:

text 复制代码
Failed to obtain InputStream for remote file ...: 550

这说明 FTP 目录访问本身正常,但文件名在控制通道中的处理存在问题。

三、为什么只修改编码仍然可能失败

FTP 文件名处理不是单纯的 Java 字符串问题,而是一个协议交互过程:

text 复制代码
FTP 服务端 --LIST--> 客户端解析目录名
FTP 客户端 --RETR 文件名--> FTP 服务端查找文件

理论上,两个方向应该使用同一种编码。但老旧 FTP 服务端可能存在以下行为:

  • 服务端没有通过 FEAT 正确声明 UTF-8 能力;
  • 服务端支持 OPTS UTF8 ON,但客户端没有主动发送该命令;
  • 客户端默认使用 windows-1252 或其他本地编码;
  • 目录列表返回的字节被错误解码成 ?;
  • 客户端随后使用包含 ? 的文件名发送 RETR;
  • 服务端把 ? 当成真实文件名字符,最终返回 550。

例如服务器真实文件名是:

text 复制代码
S_UCloud(688158)Investmen_48287.pdf

如果客户端使用不兼容的编码解析,可能看到:

text 复制代码
S_UCloud?688158?Investmen_48287.pdf

如果客户端又发送:

text 复制代码
RETR .../S_UCloud?688158?Investmen_48287.pdf

服务端查找的就是包含问号的文件名,而真实文件名中并没有问号,因此返回 550。

因此,单独把编码从 windows-1252 改为 GBK、GB18030 或 UTF-8,并不能保证服务端已经进入正确的 UTF-8 模式。客户端还需要在连接建立后主动进行 UTF-8 协商。

四、解决思路

修复 FTP 控制连接本身的 Unicode 配置。

处理流程如下:

text 复制代码
创建 FTPClient
        ↓
设置控制通道编码为 UTF-8
        ↓
开启 Commons Net 的 UTF-8 自动识别
        ↓
建立 FTP 连接
        ↓
连接成功后发送 OPTS UTF8 ON
        ↓
使用原始远程路径执行 RETR
        ↓
读取文件流并上传 RustFS

这个方案的核心是:让客户端和 FTP 服务端在执行 LIST、SIZE、RETR 等操作前,明确使用 UTF-8 处理文件名。

五、为什么需要三个配置动作

5.1 设置控制通道编码

java 复制代码
ftpClient.setControlEncoding(StandardCharsets.UTF_8.name());

FTP 控制通道传输的是命令和响应。文件名会作为 FTP 命令的一部分发送,因此客户端需要知道 Java 字符串应该如何转换成命令字节。

如果不设置,Apache Commons Net 可能使用默认编码,导致全角括号或中文字符无法正确转换。

5.2 开启 UTF-8 自动识别

java 复制代码
ftpClient.setAutodetectUTF8(true);

5.3 连接成功后发送 OPTS UTF8 ON

java 复制代码
ftpClient.sendCommand("OPTS", "UTF8 ON");

setControlEncoding 是客户端本地配置,不能保证服务端已经切换到 UTF-8 文件名模式。因此在连接完成后,还需要向服务端发送:

text 复制代码
OPTS UTF8 ON

服务端接受后,后续文件名相关命令才会按照 UTF-8 协议处理。

六、为什么要使用 _connectAction_()

OPTS UTF8 ON 必须在 FTP 连接建立之后发送。连接建立之前,客户端还没有可用的控制连接,直接调用 sendCommand 没有意义。

本项目使用 Apache Commons Net FTPClient 的连接动作钩子:

java 复制代码
private static class Utf8FtpClient extends FTPClient {

    @Override
    protected void _connectAction_() throws IOException {
        super._connectAction_();

        int replyCode = sendCommand("OPTS", "UTF8 ON");
        if (!FTPReply.isPositiveCompletion(replyCode)) {
            throw new IOException(
                    "FTP server rejected OPTS UTF8 ON: "
                            + getReplyString());
        }
    }
}

执行顺序很重要:

  1. super._connectAction_() 先完成 Commons Net 的标准连接动作。
  2. 控制连接可用后发送 OPTS UTF8 ON。
  3. 检查 FTP 返回码。
  4. 服务端接受后,连接才继续用于目录和文件操作。

如果服务端拒绝该命令,代码抛出异常,而不是继续使用一个可能不支持 Unicode 文件名的连接。这可以避免后面出现难以定位的 550。

七、当前 FTP 配置类的核心写法

java 复制代码
@Service
@Slf4j
@Setter
@ConfigurationProperties("pplusftp")
public class PPlusFtpClientService {
    private String host;
    private String username;
    private String password;
    private int port;
    private int defaultTimeout;
    private int bufferSize;
    private String documentPath;

    public PPlusFtpClientService() {
    }
    public byte[] downloadFile(String filePath) throws IOException {
        String remotePath = filePath.startsWith("/") ? filePath : documentPath + filePath;
        log.debug("Downloading FTP file: {}", remotePath);
        FTPClient ftp = new FTPClient();
        try {
            ftp.setDefaultTimeout(defaultTimeout);
            ftp.setBufferSize(bufferSize > 0 ? bufferSize : 1024);
            ftp.setControlEncoding(StandardCharsets.UTF_8.name());
            ftp.setAutodetectUTF8(true);
            ftp.connect(host, port);
            if (!ftp.login(username, password)) {
                throw new ResearchException(ResultCode.FAILURE,"FTP login failed for user: " + username);
            }else{
                log.info("login success: {},{}", username,password);
            }
            ftp.sendCommand("OPTS", "UTF8 ON");
            ftp.enterLocalPassiveMode();
            ftp.setFileType(FTP.BINARY_FILE_TYPE);

            try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
                boolean retrieved = ftp.retrieveFile(remotePath, out);
                if (!retrieved) {
                    log.warn("FTP file not found: {}", remotePath);
                    throw new ResearchException(ResultCode.FAILURE,"File not found on FTP server: " + remotePath);
                }
                log.debug("Downloaded {} bytes from {}", out.size(), remotePath);
                return out.toByteArray();
            }
        } finally {
            if (ftp.isConnected()) {
                try {
                    ftp.logout();
                    ftp.disconnect();
                } catch (IOException e) {
                    log.warn("Error closing FTP connection", e);
                }
            }
        }
    }
}

适用于当前 Spring Integration FTP 依赖的核心结构如下:

java 复制代码
@Configuration
@ConfigurationProperties("ftp")
@Setter
public class FtpClientConfig {

    private String host;
    private String username;
    private String password;
    private int port;
    private int dataTimeout;
    private int defaultTimeout;
    private int bufferSize;
    // Cache size for reusable FTP sessions.
    private int sessionCacheSize = 8;
    // Max wait time for an available cached session (milliseconds).
    private long sessionWaitTimeout = 10000L;

    @Bean
    public DefaultSftpSessionFactory sftpSessionFactory() {
        DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory();
        factory.setHost(host);
        factory.setPort(22);
        factory.setUser(username);
        factory.setPassword(password);
        factory.setAllowUnknownKeys(true);
        return factory;
    }

    @Bean
    public SftpRemoteFileTemplate template() {
        return new SftpRemoteFileTemplate(sftpSessionFactory());
    }

    @Bean
    public SessionFactory<FTPFile> ftpSessionFactory() {
        DefaultFtpSessionFactory targetFactory = new Utf8FtpSessionFactory();
        targetFactory.setHost(host);
        targetFactory.setPort(port);
        targetFactory.setUsername(username);
        targetFactory.setPassword(password);
        targetFactory.setDataTimeout(dataTimeout);
        targetFactory.setDefaultTimeout(defaultTimeout);
        targetFactory.setBufferSize(bufferSize);
        targetFactory.setControlEncoding(StandardCharsets.UTF_8.name());

        targetFactory.setClientMode(PASSIVE_LOCAL_DATA_CONNECTION_MODE);

        CachingSessionFactory<FTPFile> cachingSessionFactory = new CachingSessionFactory<>(targetFactory, sessionCacheSize);
        cachingSessionFactory.setSessionWaitTimeout(sessionWaitTimeout);
        return cachingSessionFactory;
    }

    private static class Utf8FtpSessionFactory
            extends DefaultFtpSessionFactory {

        @Override
        protected FTPClient createClientInstance() {
            FTPClient client = new Utf8FtpClient();
            client.setControlEncoding(StandardCharsets.UTF_8.name());
            client.setAutodetectUTF8(true);
            return client;
        }

        private static class Utf8FtpClient extends FTPClient {

            @Override
            protected void _connectAction_() throws IOException {
                super._connectAction_();
                int replyCode = sendCommand("OPTS", "UTF8 ON");
                if (!FTPReply.isPositiveCompletion(replyCode)) {
                    throw new IOException(
                            "FTP server rejected OPTS UTF8 ON: " + getReplyString());
                }
            }
        }
    }

    @Bean
    public FtpRemoteFileTemplate ftpTemplate() {
        return new FtpRemoteFileTemplate(ftpSessionFactory());
    }
}

其中:自定义客户端:

java 复制代码
private static class Utf8FtpSessionFactory
        extends DefaultFtpSessionFactory {

    @Override
    protected FTPClient createClientInstance() {
        FTPClient ftpClient = new Utf8FtpClient();
        ftpClient.setControlEncoding(
                StandardCharsets.UTF_8.name());
        ftpClient.setAutodetectUTF8(true);
        return ftpClient;
    }
}

注意:setAutodetectUTF8(true) 是 FTPClient 的方法,不能写成:

java 复制代码
targetFactory.setAutoDetectUTF8(true);

同样,不能假设当前 Spring Integration 版本一定提供:

java 复制代码
postProcessClient(FTPClient client)

如果当前依赖没有这个方法,应该使用当前 Commons Net 版本提供的 _connectAction_()。

八、uploadFromFtp() 中的调用关系

业务层调用链如下:

text 复制代码
ReportController.uploadFile()
        ↓
FtpClientService.processReportFile()
        ↓
ReportFileUploadService.batchUploadFiles()
        ↓
ReportFileUploadService.uploadFileToRustFS()
        ↓
ReportFileUploadService.uploadFromFtp()
        ↓
ftpRemoteFileTemplate.get()
        ↓
FTPClient RETR

uploadFromFtp() 不需要把文件名改成 ASCII,也不需要把全角字符转换成半角字符。它继续使用数据库中保存的原始远程路径:

java 复制代码
String ftpPath = normalizeFtpPath(originalPath);
ftpRemoteFileTemplate.get(ftpPath, stream -> {
    // 将 FTP 文件流上传到 RustFS
});

文件名能否正确发送,交给底层 UTF-8 FTP 客户端和服务端协商解决。

九、为什么不能只依赖目录列表返回值

之前的排查中,目录列表可能返回:

text 复制代码
S_UCloud?688158?Investmen_48287.pdf

这说明客户端已经在解析阶段丢失了原始字符。此时再把 FTPFile.getName() 拼回路径,可能会得到一个包含 ? 的错误路径。

因此当前方案的关键不是对乱码结果做二次加工,而是从连接建立阶段修复 UTF-8 协商,使 FTPFile.getName() 和后续 RETR 都基于正确的 UTF-8 文件名。

也就是说:

text 复制代码
错误做法:拿到乱码后再修正文件名
正确做法:在 FTP 连接初始化阶段避免产生乱码

十、FTP 会话缓存的影响

项目使用了:

java 复制代码
CachingSessionFactory<FTPFile>

它会复用已经创建的 FTP 会话。如果修改了以下配置:

java 复制代码
setControlEncoding("UTF-8")
setAutodetectUTF8(true)
OPTS UTF8 ON

必须重启应用,使旧的缓存会话失效。否则,应用可能继续使用修改前创建的 FTP 连接,导致看起来像是配置没有生效。

这也是部署后必须重启应用容器的原因之一。

十一、如何验证配置是否真正生效

建议在连接初始化阶段记录不包含密码的诊断日志:

java 复制代码
log.info("FTP UTF-8 mode initialization completed, host={}", host);

不要记录 FTP 密码、Authorization 信息或其他敏感凭据。

同时观察以下行为:

  1. FTP 连接是否成功建立。
  2. OPTS UTF8 ON 是否返回正向完成码。
  3. 目录列表中的全角括号是否仍显示为 ?。
  4. ftpRemoteFileTemplate.get() 是否能够执行 RETR。
  5. RustFS 是否收到完整文件流。

如果 OPTS UTF8 ON 返回失败,应先确认服务端是否支持该命令,而不是立即修改业务文件名。

十二、常见错误写法

错误一:在工厂上调用 Commons Net 方法

java 复制代码
targetFactory.setAutoDetectUTF8(true);

targetFactory 的类型是 DefaultFtpSessionFactory,不是 FTPClient。应改为:

java 复制代码
ftpClient.setAutodetectUTF8(true);

错误二:覆盖当前版本不存在的方法

java 复制代码
@Override
protected void postProcessClient(FTPClient client) {
}

如果当前 Spring Integration 版本没有这个扩展点,IDE 会报错。应使用当前 Commons Net 版本提供的 _connectAction_()。

错误三:把 FTP 路径当作 HTTP URL 编码

不要把文件名转换成:

text 复制代码
S_UCloud%EF%BC%88688158%EF%BC%89Investmen_48287.pdf

FTP RETR 不是 HTTP 请求,不应进行 URL 编码。

错误四:修改文件名文本绕过问题

不建议在应用层删除全角括号、替换中文字符、删除空格或重新猜测文件名。这些操作可能导致请求的文件名与服务器真实文件名更不一致。

十三、为什么当前方案能够解决问题

之前失败的方案是在应用层处理已经出现的结果:

text 复制代码
目录返回乱码
        ↓
应用尝试猜测真实文件名
        ↓
再次执行 RETR
        ↓
继续 550

当前方案在更靠前的位置解决问题:

text 复制代码
创建 FTPClient
        ↓
指定 UTF-8
        ↓
开启自动识别
        ↓
连接后发送 OPTS UTF8 ON
        ↓
目录和 RETR 使用正确的 Unicode 文件名
        ↓
下载成功

因此,当前方案不是"换了一个碰巧有效的编码",而是完整地完成了 FTP UTF-8 协商:

  1. 客户端设置 UTF-8 控制编码。
  2. 客户端开启 UTF-8 自动识别。
  3. 服务端通过 OPTS UTF8 ON 被明确要求进入 UTF-8 模式。
  4. 连接重建后,缓存会话不会继续使用旧配置。
  5. 原始文件名可以直接用于 RETR。

十四、总结

本问题的根因是 FTP 服务端和客户端对 Unicode 文件名的处理没有在连接层完成一致协商。windows-1252、GBK、GB18030 等编码单独切换没有解决问题,是因为它们只改变了客户端的字符解码方式,没有保证服务端也使用同一种文件名处理模式。

本项目最终采用以下方案:

java 复制代码
ftpClient.setControlEncoding(StandardCharsets.UTF_8.name());
ftpClient.setAutodetectUTF8(true);

连接成功后执行:

java 复制代码
ftpClient.sendCommand("OPTS", "UTF8 ON");

并在 FTP 会话缓存配置变化后重启应用容器,使新连接配置真正生效。

该方案保留原始文件路径,不修改上游文件名,不依赖报告 ID 模糊匹配,也不对 FTP 文件名进行 URL 编码,直接从 FTP 协议连接层解决包含中文、全角括号和其他 Unicode 字符的文件下载问题。

相关推荐
caoerzhong1 小时前
中小企业上 WMS 该先上哪几块:JeeWMS 开源 Java 仓库管理系统的分批上线清单
java·python·开源
WL学习笔记2 小时前
日期类(C++)
开发语言·c++·日期类实现
【JAVA】玩家2 小时前
Spring核心原理全解析:从零到生产实战
java·后端·spring
Wang's Blog3 小时前
Java框架 SpringCloud 快速入门: 实现 Feign 最佳实践(抽取方式)
java·spring cloud
谢亮_vipxieliang3 小时前
Go map与结构体的正确使用
开发语言·golang·哈希算法
H.莓飛3 小时前
【C++】命名空间、缺省参数、函数重载与引用
linux·开发语言·c++·visual studio
xxwl5853 小时前
数据结构知识点和代码实现总结(C语言实现)
c语言·开发语言·数据结构
MandalaO_O3 小时前
IDEA 开发(快捷键 + 调试 + 序列化)
java·ide·intellij-idea
xiaoqiMikko4 小时前
JVM 线上排查实战(七):jps 看不见它、jstack 连不上它,可它明明活得好好的
java·jvm