一、问题背景
应用程序通过 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 通常表示请求的远程文件不可用,但它不一定代表文件真的不存在。在本场景中,常见原因包括:
- 客户端发送的文件名与服务端实际文件名不一致。
- 客户端使用错误的控制通道编码发送文件名。
- 服务端没有启用 UTF-8 文件名协商。
LIST和RETR对文件名使用了不同的编码处理方式。- 文件名中的全角字符在 FTP 服务端解析失败。
- FTP 账号没有读取该文件的权限。
- 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());
}
}
}
执行顺序很重要:
super._connectAction_()先完成 Commons Net 的标准连接动作。- 控制连接可用后发送
OPTS UTF8 ON。 - 检查 FTP 返回码。
- 服务端接受后,连接才继续用于目录和文件操作。
如果服务端拒绝该命令,代码抛出异常,而不是继续使用一个可能不支持 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 信息或其他敏感凭据。
同时观察以下行为:
- FTP 连接是否成功建立。
OPTS UTF8 ON是否返回正向完成码。- 目录列表中的全角括号是否仍显示为
?。 ftpRemoteFileTemplate.get()是否能够执行RETR。- 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 协商:
- 客户端设置 UTF-8 控制编码。
- 客户端开启 UTF-8 自动识别。
- 服务端通过
OPTS UTF8 ON被明确要求进入 UTF-8 模式。 - 连接重建后,缓存会话不会继续使用旧配置。
- 原始文件名可以直接用于
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 字符的文件下载问题。