业务场景:老 ERP 系统的附件同步痛点
ERP系统中,需求部门提出要求:业务单据在系统内上传附件时,必须同步上传一份到公司的群晖网盘;业务侧删除单据或附件时,也要同步把群晖网盘上的文件删掉,确保两边数据一致。
方案设计:HTTP 与 FTP 的取舍
一开始考虑的是群晖官方的 FileStation HTTP API。但在联调时踩了深坑:先是自签名证书报 PKIX path building failed,通过信任所有证书绕过之后,又因为 multipart 参数传递格式不对,一直报 {"error":{"code":101}} 和 401。虽然最后通过调整参数位置和字段名能调通,但 HTTP 接口对参数极其敏感,稍有不慎就报未知错误。
对比之下,FTP 方案更直接:传输层只负责流式读写,不受复杂 JSON 和 multipart 解析的限制。最终决定放弃 HTTP,引入 commons-net.jar,写一个 FtpUtil 工具类,只实现上传和删除两个核心功能。
技术实现:FTP 工具类封装
下面我们直接上代码
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class FtpUtil {
/**
* 上传文件到 FTP 服务器
* 注意:如果失败会直接抛出 RuntimeException
*
* @param host FTP 服务器地址 (NAS 的 IP)
* @param port FTP 端口 (默认 21)
* @param username 用户名
* @param password 密码
* @param remoteDir 远程目录 (例如 "文件目录")
* @param remoteFileName 上传后的文件名
* @param localFilePath 本地文件路径
*/
public static void uploadFile(String host, int port, String username, String password,
String remoteDir, String remoteFileName, String localFilePath) {
FTPClient ftpClient = new FTPClient();
try (InputStream inputStream = new FileInputStream(localFilePath)) {
// 1. 设置传输模式 (必须在连接之前设置)
ftpClient.setControlEncoding("UTF-8");
// 2. 连接并登录
ftpClient.connect(host, port);
int reply = ftpClient.getReplyCode();
if (!FTPReply.isPositiveCompletion(reply)) {
ftpClient.disconnect();
throw new RuntimeException("FTP服务器拒绝连接,ReplyCode: " + reply);
}
boolean loggedIn = ftpClient.login(username, password);
if (!loggedIn) {
throw new RuntimeException("FTP登录失败,请检查账号密码。FTP返回: " + ftpClient.getReplyString());
}
// 登录后立刻发送开启 UTF-8 的指令
ftpClient.sendCommand("OPTS UTF8 ON");
ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
ftpClient.enterLocalPassiveMode();
// 3. 尝试切换目录
boolean changed = ftpClient.changeWorkingDirectory(remoteDir);
if (!changed) {
System.out.println("进入目录失败,尝试创建: " + remoteDir);
boolean made = ftpClient.makeDirectory(remoteDir);
if (made) {
changed = ftpClient.changeWorkingDirectory(remoteDir);
}
if (!changed) {
throw new RuntimeException("无法进入或创建目标目录: " + remoteDir + ",FTP返回: " + ftpClient.getReplyString());
}
}
// 4. 上传文件
boolean stored = ftpClient.storeFile(remoteFileName, inputStream);
if (!stored) {
// 抛出上传失败的异常,带上文件名和服务器原因
throw new RuntimeException("文件上传失败: " + remoteFileName + ",FTP返回: " + ftpClient.getReplyString());
}
// 5. 登出
ftpClient.logout();
} catch (IOException e) {
// 包装 IO 异常抛出
throw new RuntimeException("FTP上传发生网络或IO异常: " + e.getMessage(), e);
} finally {
// 统一在 finally 中处理断开连接,防止内存泄漏
if (ftpClient.isConnected()) {
try {
ftpClient.disconnect();
} catch (IOException ex) {
System.err.println("断开FTP连接时发生异常: " + ex.getMessage());
}
}
}
}
/**
* 删除 FTP 服务器上的文件
* 注意:如果失败会直接抛出 RuntimeException
*
* @param host FTP 服务器地址
* @param port FTP 端口
* @param username 用户名
* @param password 密码
* @param remoteFilePath 要删除的远程文件完整路径 (例如 "/文件目录/test.xlsx")
*/
public static void deleteFile(String host, int port, String username, String password,
String remoteFilePath) {
FTPClient ftpClient = new FTPClient();
try {
// 1. 设置传输模式
ftpClient.setControlEncoding("UTF-8");
ftpClient.connect(host, port);
ftpClient.login(username, password);
ftpClient.sendCommand("OPTS UTF8 ON");
ftpClient.enterLocalPassiveMode();
// 2. 执行删除
boolean deleted = ftpClient.deleteFile(remoteFilePath);
if (!deleted) {
throw new RuntimeException("文件删除失败: " + remoteFilePath + ",FTP返回: " + ftpClient.getReplyString());
}
ftpClient.logout();
} catch (IOException e) {
throw new RuntimeException("FTP删除发生网络或IO异常: " + e.getMessage(), e);
} finally {
if (ftpClient.isConnected()) {
try {
ftpClient.disconnect();
} catch (IOException ex) {
System.err.println("断开FTP连接时发生异常: " + ex.getMessage());
}
}
}
}
}
业务层调用代码
文件上传调用的代码
/**
* 上传文件到 FTP 服务器
*
* @param host FTP 服务器地址 (NAS 的 IP)
* @param port FTP 端口 (默认 21)
* @param username 用户名
* @param password 密码
* @param remoteDir 远程目录 (例如 "nas开放的目录") 切记,前面不能带/
* @param remoteFileName 上传后的文件名
* @param localFilePath 本地文件路径
*
*/
FtpUtil.uploadFile("192.168.1.100", 21, "aaa", "bbb", "文件目录", 文件名, 文件本地地址);
文件删除的代码
/**
* 删除 FTP 服务器上的文件
*
* @param host FTP 服务器地址
* @param port FTP 端口
* @param username 用户名
* @param password 密码
* @param remoteFilePath 要删除的远程文件完整路径 (例如 "/文件目录/test.xlsx")
*
*/
FtpUtil.deleteFile("192.168.1.100", 21, "aaa", "bbb", "nas对应的文件路径");
上线效果:业务反馈与数据变化
业务侧更新和删除附件顺利,实现了 ERP 附件与网盘文件的双向同步。原来因 HTTP 接口超时或参数校验失败导致的附件丢失/残留问题不再发生,网盘存储空间也得到了有效释放。同步动作被包裹在业务逻辑中,调用顺畅。
踩坑与反思:个人觉得精华部分
-
目录认知偏差,文件传不上去
FTP 登录后的默认根目录是/(通常是用户的主目录),并不是我们预想中的共享目录。一开始没做目录切换,一直提示553 Permission denied,因为我们没有主目录的权限,导致一直失败,所以通过显式调用changeWorkingDirectory(remoteDir)切到目标目录,才得以解决。 -
编码设置顺序引发乱码,绕了大弯路
因为编码的问题,我试了很多遍,期间各种报错,要么提示550 No such file or directory,要么报550 Wildcard is ambiguous ,最后在没搞清楚编码设置时,我试过强制转码new String(xxx.getBytes("UTF-8"), "ISO-8859-1"),确实可行。但后面重新梳理了下,才发现是设置编码的先后问题,最后通过如下的设置解决问题:-
setControlEncoding("UTF-8")必须在connect之前执行。 -
连接登录后,必须发送
OPTS UTF8 ON指令。少了任何一步,群晖服务器上的中文文件名都会变成乱码,或者中文目录直接报550 Wildcard is ambiguous。
-
-
调试排查全靠协议日志
addProtocolCommandListener(new PrintCommandListener(...))这段代码是调试的神器。FTP 报错往往只有一句401、550或553,根本看不出原因。开启监听后,能看到底层发出的CWD和STOR指令,一眼就能看出是不是路径拼错了、是不是少了斜杠、是不是服务器返回了Permission denied。