对接群晖NAS,上传和删除踩坑记录

业务场景:老 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 工具类封装

下面我们直接上代码

bash 复制代码
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());
                }
            }
        }
    }
}

业务层调用代码

文件上传调用的代码

bash 复制代码
 /**
		    		     * 上传文件到 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", "文件目录", 文件名, 文件本地地址);

文件删除的代码

bash 复制代码
/**
	    		     * 删除 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 接口超时或参数校验失败导致的附件丢失/残留问题不再发生,网盘存储空间也得到了有效释放。同步动作被包裹在业务逻辑中,调用顺畅。

踩坑与反思:个人觉得精华部分

  1. 目录认知偏差,文件传不上去

    FTP 登录后的默认根目录是 /(通常是用户的主目录),并不是我们预想中的共享目录。一开始没做目录切换,一直提示 553 Permission denied,因为我们没有主目录的权限,导致一直失败,所以通过显式调用 changeWorkingDirectory(remoteDir) 切到目标目录,才得以解决。

  2. 编码设置顺序引发乱码,绕了大弯路

    因为编码的问题,我试了很多遍,期间各种报错,要么提示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。

  3. 调试排查全靠协议日志

    addProtocolCommandListener(new PrintCommandListener(...)) 这段代码是调试的神器。FTP 报错往往只有一句 401、550 或 553,根本看不出原因。开启监听后,能看到底层发出的 CWD 和 STOR 指令,一眼就能看出是不是路径拼错了、是不是少了斜杠、是不是服务器返回了 Permission denied。

相关推荐
计算机魔术师1 小时前
半年前说不缺钱,半年后150亿美元超募
前端
Mk598781 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端
用户5708462574401 小时前
自动化发布器里最贵的 Bug:返回成功,但什么都没发生
前端
雨中飘荡的记忆1 小时前
Claude Code 入门必会:@、!、Slash Commands,一篇讲清楚
人工智能·后端
爱吃馒头不吃辣1 小时前
加一道题,改一天代码:题库即数据的降维打击
前端·架构
aiks_yuan1 小时前
Requestly规则使用技巧:前端联调、接口Mock与网络请求调试实战
前端
程序员老赵1 小时前
Docker 部署 MaxKB:轻松搭建企业级智能体平台
前端·docker·agent
用户8356290780511 小时前
如何使用 Python 在 Word 文档中创建和格式化列表
后端·python
开心就好20251 小时前
uni-app 本地打包 iOS,在 Windows 上使用 KXBuild 产出 ipa 并上架的完整流程
后端·ios