简介这份 Android FTP 服务器 Demo 面向希望在手机端搭建轻量级 FTP 服务的 Android 开发者以及学习物联网、局域网文件传输的读者核心基于 Apache FtpServer演示在 Android 应用中集成 FTP 服务并完成文件上传下载的基本思路。压缩包共 28 个文件约 1.72MB包含 7 个依赖 jar如 ftpserver-core、mina-core、commons-net-ftp、log4j、slf4j 等、11 个 XML 配置与界面资源、5 个 PNG 图标、1 个 Java 入口文件及 1 个 properties 配置整体为可导入的 Eclipse 工程结构。已有 593 人学习说明该示例对入门 FTP 服务端集成具有一定参考价值。读者可据此了解 Apache FtpServer 与 MINA、日志组件的依赖搭配查看 AndroidManifest、res 布局和 libs 组织方式并以此为骨架改造端口、用户权限、目录映射与文件传输流程适合作为局域网文件共享、设备间传输等场景的原型参考。1. 从一台旧手机说起android ftp服务器 demo 跑不起来多半是环境没对齐前阵子帮某实验室把一台退役的旧手机改成局域网文件中转站需求很朴素——PC 和手机不插线就能互传文件夹。手边只有一个 android ftp服务器 demo 工程看着代码不多编译也过了结果装到机器上就是连不上服务端日志显示已经在 2121 端口监听PC 端 FTP 客户端一直报425 Cant open data connection换了台电脑、换了根数据线情况一模一样。这类 demo 最容易让人误判。它不像装个 App 那样点两下就完事背后牵着四样东西JDK 与 AGP 的版本对不齐、ftpserver-core的 slf4j 依赖没换、清单里的权限少声明一个、被动模式端口没放行。少一样都表现为“连不上”但根因完全不同光看日志根本看不出是哪一样。下面的内容就是把这四样按安装顺序摆开从选版本、拉依赖、改清单到起服务、调参数、排查、验证一条线走完。适合手上有一套安卓工程、准备把手机当 FTP 服务器用的人也适合被这个 demo 坑过一次、想搞清楚到底哪一步错了的人。2. 环境清单Android FTP 服务器 Demo 要装的四样东西和版本对齐动手之前先把版本钉死。Android 上的 FTP 服务端不是系统能力全靠第三方库在应用进程里起一个 Socket 监听所以它对工具链版本比普通 App 敏感得多。版本一旦飘后面看到的报错会非常离谱——比如编译期提示Duplicate class运行期提示NoClassDefFoundError但都跟 FTP 逻辑本身没关系。2.1 JDK、Gradle、SDK 三者的版本对齐我一般按“AGP 定 JDK、JDK 定 Gradle”的顺序反推。ftpserver-core1.2.0 是 Java 8 字节码AGP 8.x 要求 JDK 17 编译所以 JDK 直接上 17不要图省事用 JDK 11否则 AGP 8 会直接拒绝构建。组件我固定用的版本说明JDK17AGP 8.x 最低要求低于此版本构建直接失败Gradle8.7与 AGP 8.5 配套不要用 9.x 尝鲜AGP8.5.0支持packaging {}块便于排除 META-INF 冲突compileSdk / targetSdk34前台服务类型必须在这一档声明minSdk21ftpserver-core依赖的 MINA 2.x 在 21 以下容易出 NIO 兼容问题minSdk这一项值得多说一句。有的 demo 为了兼容老设备会写 16 或 19编译能过但在低版本机器上一旦有客户端连接MINA 的NioSocketAcceptor可能抛UnsupportedOperationException因为早期 Android 的 NIO 实现并不完整。我踩过一次日志里只有一行java.lang.UnsupportedOperationException: None of the selectors are interested查了半天才发现是 minSdk 写低了。21 是这套组合下最省心的下限。2.2 ftpserver-core 1.2.0 与 slf4j 的替换引入依赖这一块是整个安装过程里最容易翻车的地方。org.apache.ftpserver:ftpserver-core:1.2.0会传递引入slf4j-api但它只是接口没有绑定实现。在纯 JVM 项目里你随便加一个slf4j-simple就完事可在 Android 上slf4j-simple会往 stdout 写日志全被吞掉而且启动时会打印Failed to load class org.slf4j.impl.StaticLoggerBinder。正确做法是把slf4j-api排掉换成slf4j-android。// app/build.gradle.kts android { namespace com.example.ftpdemo compileSdk 34 defaultConfig { minSdk 21 targetSdk 34 multiDexEnabled true // 方法数超 64K 时打开ftpserver 的传递依赖不算小 } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled true // 库里有 Java 8 时间 API 的调用 } packaging { // 这几个文件在多个依赖里重复出现不排掉会报 Duplicate class resources.excludes setOf( META-INF/DEPENDENCIES, META-INF/LICENSE, META-INF/LICENSE.txt, META-INF/NOTICE, META-INF/NOTICE.txt ) } } dependencies { implementation(org.apache.ftpserver:ftpserver-core:1.2.0) { // 关键一步排掉自带的 slf4j-api避免和下面显式引入的版本打架 exclude(group org.slf4j, module slf4j-api) } implementation(org.slf4j:slf4j-api:1.7.36) implementation(org.slf4j:slf4j-android:1.7.36) // 把日志导到 Logcat coreLibraryDesugaring(com.android.tools:desugar_jdk_libs:2.0.4) }这段配置里有三处逻辑要说清楚。第一处是excludeftpserver-core传递进来的slf4j-api版本可能和你想用的不一致不排掉的话 Gradle 会做版本仲裁仲裁结果未必是你想要的尤其是多模块工程里仲裁后slf4j-android可能被降级到不兼容的版本。第二处是packaging.resources.excludesLICENSE、NOTICE 这类文件在mina-core、ftpserver-core、slf4j里各有一份AGP 8 默认开启资源重复检查不排就直接构建失败。第三处是 desugaringftpserver-core内部用到了 Java 8 的java.time相关调用minSdk 21 的设备上没有这些类必须靠脱糖补上。提示排掉slf4j-api之后一定要显式引入别指望slf4j-android自己带进来它只提供绑定实现不提供接口。2.3 AndroidManifest 权限与前台服务按 API 级别分档声明清单文件这块很多 demo 只写了INTERNET装上之后发现客户端连上来一片空白或者跑几分钟就自动断开问题都出在这里。FTP 服务要读外部存储、要在后台常驻、要在 Android 13 以上弹通知这三件事对应三组不同 API 级别的权限。!-- AndroidManifest.xml -- manifest xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:toolshttp://schemas.android.com/tools !-- 本地网络通信虽然是局域网INTERNET 权限仍然必须声明 -- uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / uses-permission android:nameandroid.permission.ACCESS_WIFI_STATE / !-- 前台服务Android 9 起必须有14 起还要求声明类型 -- uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_DATA_SYNC / !-- Android 13 起通知需要运行时授权不申请则前台服务通知不显示 -- uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / !-- Android 12 及以下走传统存储权限 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion29 / !-- Android 11 起要访问共享目录只能走「所有文件访问」上架需说明用途 -- uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE tools:ignoreScopedStorage / application android:labelFTP Demo android:requestLegacyExternalStoragetrue service android:name.FtpForegroundService android:exportedfalse android:foregroundServiceTypedataSync / /application /manifestforegroundServiceType这一项是 Android 14 的硬性要求不写会在启动前台服务时抛MissingForegroundServiceTypeException而且异常信息不会直接告诉你是这个原因。选dataSync是因为 FTP 传输本质是数据同步如果你的场景是短时间传文件也有人在用shortService但那个类型有总时长限制传大文件会被系统掐断不建议。requestLegacyExternalStorage只对 Android 10 有效Android 11 之后系统直接忽略它所以不能指望这一行解决所有存储问题。API 级别额外要处理的事21–22运行时申请存储权限即可23–28运行时权限 前台服务29加requestLegacyExternalStorage30–32改申请MANAGE_EXTERNAL_STORAGE或改用应用私有目录33运行时申请POST_NOTIFICATIONS34前台服务必须声明foregroundServiceType3. 把服务跑起来FtpServerFactory 的最小可用配置环境齐了接下来是把它真正跑起来。这一段代码不长但每一行都对应一个后面会出问题的参数我习惯一次性写全省得回头补。3.1 十分钟能跑通的一段初始化代码public final class FtpServerHolder { private static FtpServer server; public static synchronized boolean start(Context ctx, String userName, String password, File rootDir) throws FtpException { // 已经在跑就不重复启动否则第二次绑定端口会抛 BindException if (server ! null !server.isStopped()) { return true; } FtpServerFactory factory new FtpServerFactory(); // ---------- 1. 监听器 ---------- ListenerFactory listenerFactory new ListenerFactory(); listenerFactory.setPort(2121); // 不用 21原因见 4.1 listenerFactory.setServerAddress(0.0.0.0); listenerFactory.setIdleTimeout(300); // 单位秒空闲 5 分钟断开 // ---------- 2. 被动模式端口池 ---------- DataConnectionConfigurationFactory dcc new DataConnectionConfigurationFactory(); dcc.setPassivePorts(50000-50010); // 范围要 最大并发数 dcc.setPassiveAddress(getLocalIp(ctx)); // 填手机在局域网里的 IP listenerFactory.setDataConnectionConfiguration(dcc.createDataConnectionConfiguration()); factory.addListener(default, listenerFactory.createListener()); // ---------- 3. 用户 ---------- PropertiesUserManagerFactory umf new PropertiesUserManagerFactory(); umf.setFile(new File(ctx.getFilesDir(), users.properties)); UserManager userManager umf.createUserManager(); BaseUser user new BaseUser(); user.setName(userName); user.setPassword(password); user.setHomeDirectory(rootDir.getAbsolutePath()); user.setEnabled(true); user.setMaxIdleTime(300); user.setAuthorities(Arrays.asList( new WritePermission(), // 允许上传、删除、重命名 new ConcurrentLoginPermission(3, 3) // 最多 3 个并发每个账号 3 个 )); userManager.save(user); factory.setUserManager(userManager); // ---------- 4. 文件系统 ---------- NativeFileSystemFactory fsFactory new NativeFileSystemFactory(); fsFactory.setCreateHomeDirectory(true); // 目录不存在时自动建 factory.setFileSystem(fsFactory); server factory.createServer(); server.start(); return true; } public static synchronized void stop() { if (server ! null !server.isStopped()) { server.stop(); // 必须调用否则端口不会释放 } server null; } private static String getLocalIp(Context ctx) { // 从当前活动网络取链路地址比 WifiManager 更可靠 ConnectivityManager cm (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); LinkProperties lp cm.getLinkProperties(cm.getActiveNetwork()); if (lp null) return 127.0.0.1; for (LinkAddress addr : lp.getLinkAddresses()) { InetAddress ip addr.getAddress(); if (ip instanceof Inet4Address !ip.isLoopbackAddress()) { return ip.getHostAddress(); } } return 127.0.0.1; } }逐段说明。setPort(2121)是监听端口客户端连接时填的就是这个数字改成别的值客户端也得跟着改不是内部参数。setServerAddress(0.0.0.0)表示监听全部网卡包括 Wi-Fi 和蜂窝如果想只在 Wi-Fi 上暴露可以换成getLocalIp()的返回值但 Wi-Fi 一旦重连 IP 会变监听器得重建实际项目里更常见的是绑0.0.0.0然后用系统防火墙或网络策略控制。setIdleTimeout(300)和user.setMaxIdleTime(300)是两个不同的超时前者是连接级空闲超过 5 分钟没有任何命令就断开后者是用户级同一个账号闲置太久会被踢掉。两个都设成 300 是保守取值传大文件时客户端会持续发命令不会误断。setPassivePorts(50000-50010)是最容易被忽略的一行。FTP 的数据连接方向和命令连接方向是反的客户端连服务端的 2121 建控制通道服务端再回头连客户端的一个端口建数据通道。如果手机在 NAT 后面被动模式需要服务端告诉客户端“你来连我这个端口”这个端口就是 50000–50010 范围内随机挑的。不配也行库会随机挑一个高位端口但随机端口没法在路由器上预放行跨网段就必然失败。getLocalIp()用ConnectivityManager而不是WifiManager.getConnectionInfo().getIpAddress()后者在 Android 10 之后返回的是个不可用的值而且不支持多网络。LinkProperties里取第一个非回环的 IPv4 地址这是目前最稳的写法。3.2 用户、目录与权限PropertiesUserManager 逐项拆解BaseUser上有十几个 setter常用的其实就五个另外几个配错了会有反效果。参数取值示例作用与坑点setNamedemo登录用户名区分大小写setPassworddemo123明文存储users.properties里能看到别用真实密码setHomeDirectory/sdcard/ftproot用户登录后的根目录必须是可读可写且已授权的路径setEnabledtrue设为false后账号仍在但登录直接被拒适合临时停用setMaxIdleTime300单位秒设 0 表示不限不建议setAuthorities见下权限集合缺WritePermission就只能读setAuthorities里可以塞的权限类型有WritePermission写、删、改名、ConcurrentLoginPermission(max, perUser)、TransferRatePermission(upload, download)等。只给WritePermission不给ConcurrentLoginPermission的话默认并发上限很低多个客户端同时连会互相挤掉。TransferRatePermission的单位是字节每秒想限速到 1MB/s 就传1024 * 1024传0表示不限。PropertiesUserManagerFactory.setFile()指定的是用户配置文件的落盘位置。放在ctx.getFilesDir()下比较稳妥卸载应用会一起清掉放在外部存储目录的话用户可以手动改文件绕开密码demo 场景无所谓真要给别人用建议换个实现。注意homeDirectory指向/sdcard根目录时Android 11 以上即使给了MANAGE_EXTERNAL_STORAGEFile.listFiles()也可能返回空数组。稳妥做法是把根目录设成getExternalFilesDir(null)这个路径不需要任何额外权限。3.3 前台服务托管让 FTP 进程不被系统回收服务在 Activity 里起切到后台几分钟就被系统杀掉这是 Android 上跑常驻服务的通病。解决办法是把它托管到前台服务里配一个常驻通知系统就不会因为内存压力优先回收它。public class FtpForegroundService extends Service { private static final String CHANNEL_ID ftp_server; private static final int NOTIFY_ID 1001; Override public void onCreate() { super.onCreate(); // Android 8 起通知必须挂在渠道上否则不显示 if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { NotificationChannel channel new NotificationChannel( CHANNEL_ID, FTP 服务, NotificationManager.IMPORTANCE_LOW); channel.setShowBadge(false); getSystemService(NotificationManager.class).createNotificationChannel(channel); } } Override public int onStartCommand(Intent intent, int flags, int startId) { Notification notification new Notification.Builder(this, CHANNEL_ID) .setContentTitle(FTP 服务运行中) .setContentText(端口 2121局域网可访问) .setSmallIcon(android.R.drawable.stat_sys_upload) .setOngoing(true) .build(); // Android 10 起 startForeground 要带类型否则会抛异常 if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { startForeground(NOTIFY_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); } else { startForeground(NOTIFY_ID, notification); } try { FtpServerHolder.start(this, demo, demo123, new File(getExternalFilesDir(null), ftproot)); } catch (FtpException e) { // 启动失败要显式 stopForeground否则通知会一直挂着 stopForeground(true); stopSelf(); } return START_STICKY; // 被系统杀掉后尝试重建 } Override public void onDestroy() { FtpServerHolder.stop(); // 服务销毁必须释放端口 super.onDestroy(); } Override public IBinder onBind(Intent intent) { return null; } }START_STICKY值得单独说一句。它表示服务被系统杀掉后会自动重建但重建时Intent是null所以onStartCommand里不能直接读intent.getExtras()否则空指针。很多人在这里翻车表现为“用着用着服务起来了但立刻崩”。onDestroy里的stop()不能省。前台服务被用户在设置里强制停止时onDestroy会调用如果不释放FtpServer端口会一直被占着下次启动直接BindException: Address already in use。这个坑在小范围测试时特别难发现因为卸载重装才能恢复。4. 参数取舍端口、编码、超时这三处最容易配错代码能跑通只是第一步真正决定这套 FTP 服务器好不好用的是几个参数怎么定。这几个参数没有绝对正确的值只有适配场景的值。4.1 端口与绑定地址为什么我固定用 2121FTP 的标准控制端口是 21但 Android 上普通应用没有权限绑定 1024 以下的端口直接设 21 会在server.start()时抛Permission denied。有人会想到用 root 提权这是得不偿失的做法因为一旦要 root这个 demo 就失去了通用性。2121 是社区里最常用的替代端口原因是它在很多 FTP 客户端的默认端口列表里排第二输入 IP 之后客户端有时会自动尝试。除此之外改端口的另一个隐藏收益是避开运营商的端口扫描某些公共 Wi-Fi 对 21 端口有特殊处理2121 一般不会触发。绑定地址的选择分两种情况。绑0.0.0.0最省事Wi-Fi、热点、USB 网络共享全部能连代价是同一台设备上的其他应用也能访问。绑具体 IP 更安全但必须监听网络变化在ConnectivityManager.NetworkCallback的onLinkPropertiesChanged里重建监听器代码量翻倍。我的做法是默认绑0.0.0.0要更严格时在应用层加一个 IP 白名单只允许指定网段连接比反复重建监听器稳定得多。还有一个细节如果你同时开着 Wi-Fi 和蜂窝getLinkProperties(getActiveNetwork())返回的可能是蜂窝网络的地址客户端从局域网根本连不上。稳妥写法是遍历cm.getAllNetworks()挑TRANSPORT_WIFI的那一个。4.2 传输模式与中文文件名二进制模式别乱改FTP 协议有两种传输模式ASCII 和二进制。ASCII 模式会对文件内容做换行符转换把\n转成\r\n。传文本文件没问题传 APK、图片、压缩包会直接损坏——文件大小对不上装上去提示“解析包错误”。服务端一般不用管客户端连接时默认就是二进制但有些老客户端或者脚本化的ftp命令默认走 ASCII需要手动敲binary。中文文件名是另一个高频问题。Android 的文件系统本身是 UTF-8NativeFileSystemFactory直接用java.io.File读目录服务端这一侧不存在编码问题。乱码基本都出在客户端Windows 资源管理器自带的 FTP 客户端默认按 GBK 解析看到 UTF-8 的中文名就是一堆问号。解决办法不是改服务端而是换客户端——FileZilla 在站点管理器里把字符集设成“强制 UTF-8”就正常了。服务端能做的是主动发一条OPTS UTF8 ON的响应告诉客户端“我支持 UTF-8”。这一条在ftpserver-core里默认是开的不需要额外配置。真遇到某些客户端不认也不要试图去改服务端的默认编码改了之后反而会让原本正常的客户端乱码得不偿失。4.3 空闲超时、并发数与被动端口范围这三个参数是一组配的时候要一起看。空闲超时setIdleTimeout(300)设太小用户浏览目录慢一点就被断开体验很差设太大连接一直挂着占资源。300 秒是经验值传大文件时客户端会持续发命令刷新计时不会误断。并发数由ConcurrentLoginPermission(max, perUser)控制第一个参数是全局最大并发登录数第二个是单账号最大并发。这两个数字直接影响被动端口池的大小——如果全局并发设成 10被动端口范围只给 3 个比如 50000-50002第 4 个客户端连接时就会失败报425。常见做法是让端口池大小至少等于全局并发数宽裕一点给到 1.5 倍。被动端口范围还受路由器 NAT 表影响。范围太大会占用过多 NAT 条目太小又不够用50000-50010 这 11 个端口覆盖 10 个并发是够的。如果手机是通过热点共享网络热点的 NAT 表容量通常比家用路由器小并发数建议压到 5 以内。限速用TransferRatePermission(uploadBytesPerSec, downloadBytesPerSec)。传 0 表示不限速。局域网场景一般不需要限速但在共享带宽的环境里给个 2MB/s 的上限能避免一个客户端把整条链路吃满。5. 排查现场连不上、连上读不出、读出乱码的五种情况下面这五条是我在实际部署里反复遇到的每一条都按“现象—原因—解决”记录顺序按出现频率排。5.1 客户端报 425日志里能看到登录成功现象FileZilla 输入 IP 和账号密码能连上、能通过认证一到列目录就卡住最后报425 Cant open data connection。服务端日志里能看到USER、PASS、PWD都正常返回。原因被动模式的数据连接没有建立起来。要么是setPassiveAddress填错了填成了127.0.0.1或者蜂窝网络的地址要么是手机和 PC 不在同一个网段要么是路由器开启了 AP 隔离同一 Wi-Fi 下的设备互相不可见。解决先确认getLocalIp()返回的是不是手机在 Wi-Fi 下的真实地址用ipconfig或ifconfig对一下网段是否一致。如果地址没问题去路由器后台关掉“AP 隔离”或“无线隔离”。第三检查手机是不是连着蜂窝同时开着 Wi-Fi某些机型会优先走蜂窝导致被动地址取错——这种情况改成遍历getAllNetworks()挑 Wi-Fi 网络即可。5.2 登录成功但目录是空的现象认证通过LIST命令返回 200但客户端里一个文件都看不到服务端日志没有任何异常。原因homeDirectory指向的路径应用没有读权限。Android 11 之后即使声明了MANAGE_EXTERNAL_STORAGE也要在系统设置里手动授予“所有文件访问”权限仅靠运行时申请是不够的。另一种可能是homeDirectory指向了一个不存在的目录NativeFileSystemFactory在setCreateHomeDirectory(false)时不会自动创建listFiles()返回null被当成空目录处理。解决把根目录换成getExternalFilesDir(null)这个路径属于应用私有目录不需要任何额外权限是所有方案里最省事的。如果必须要访问共享目录引导用户去“设置 → 应用 → 特殊权限 → 所有文件访问”手动打开并在代码里用Environment.isExternalStorageManager()判断状态。5.3 启动即崩溃日志里有 StaticLoggerBinder现象点“启动服务”按钮应用直接闪退Logcat 里能看到Failed to load class org.slf4j.impl.StaticLoggerBinder或者NoClassDefFoundError: org/slf4j/LoggerFactory。原因slf4j-api在 classpath 里但没有绑定实现或者绑定实现被前面的exclude一起排掉了。这个错误不会在编译期暴露只在运行期第一次调用LoggerFactory.getLogger()时抛。解决检查build.gradle的依赖树执行./gradlew :app:dependencies --configuration debugRuntimeClasspath确认slf4j-android确实在最终产物里。如果被别的库的exclude规则连带排掉了在dependencies块末尾再显式加一次。5.4 锁屏几分钟后连接断开现象白天用得好好的手机锁屏放一会儿再连就提示连接超时解锁后重新连又正常。原因没有前台服务或者前台服务启动了但电池优化没关。Android 的 Doze 模式在设备静止时会挂起后台网络和 CPUFTP 监听线程跟着一起被冻结。解决先把服务托管到前台服务见 3.3再引导用户把应用加入电池优化白名单代码里用PowerManager.isIgnoringBatteryOptimizations()判断没加的话跳转到ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS。这一步在很多国产 ROM 上还要额外在“自启动管理”里放行这部分没有统一 API只能在启动时给个引导提示。5.5 中文文件名乱码或下载后打不开现象目录能列出来但中文文件名显示成问号或方块或者 APK 下载下来提示“解析包错误”。原因前者是客户端字符集问题后者是传输模式走了 ASCII。解决乱码换 FileZilla 并把字符集强制设为 UTF-8不要在服务端改编码。文件损坏在客户端执行一次binary命令切换到二进制模式或者在 FileZilla 的传输设置里把“传输类型”改成“二进制”。这两条看起来简单但因为现象跟“服务端配置错误”高度相似实际排查时经常绕远路。6. 装完怎么验证三种客户端交叉确认才算跑通服务起来、客户端能连上还不能算装完。FTP 这个协议在不同客户端上的行为差异很大单一客户端验证通过换一台机器可能就挂。我习惯用三种客户端交叉确认三种都过才算真的跑通。第一层用命令行排除 GUI 客户端的干扰。在 PC 上开终端# 只看认证和目录-v 打详细过程方便定位卡在哪一步 curl -v ftp://192.168.1.20:2121/ --user demo:demo123 # 上传一个 1MB 的测试文件验证写权限和二进制模式 curl -T ./test-1mb.bin ftp://192.168.1.20:2121/ --user demo:demo123 # 下载回来和原文件比对 md5验证内容没有被换行转换污染 curl -o ./test-back.bin ftp://192.168.1.20:2121/test-1mb.bin --user demo:demo123 md5sum ./test-1mb.bin ./test-back.bin-v会打印出227 Entering Passive Mode这一行后面括号里的六个数字就是被动模式的 IP 和端口把它换算出来和setPassiveAddress、setPassivePorts对一下能立刻发现端口池没生效或者 IP 填错的问题。md5 一致说明传输模式是二进制不一致基本可以断定走了 ASCII。第二层用 GUI 客户端模拟真实使用。FileZilla 连上去之后重点看三件事中文文件名显示是否正常、拖拽上传是否成功、断线重连能不能自动恢复。GUI 客户端会自动处理很多协议细节它跑通了说明普通用户能用。第三层用手机上的文件管理器验证本机回环。很多手机自带的文件管理器支持添加 FTP 站点填127.0.0.1:2121如果本机能连上、PC 连不上问题一定在网络层而不是服务端代码。验证项通过标准不通过的典型原因认证230 Login successful账号密码错、users.properties未落盘列目录返回文件列表且中文正常存储权限缺失、客户端字符集上传返回226 Transfer complete缺WritePermission、目录只读下载校验md5 与原文件一致走了 ASCII 模式断线重连30 秒内能重新连上端口未释放、服务被系统回收验证顺序也有讲究我一般先跑 md5 那条因为它是唯一一个能同时暴露“权限、端口、传输模式”三个问题的检查。上传下载都通了再去调中文文件名和断线重连这些体验层面的问题效率高很多。从那以后我每次起 FTP 服务都强制走一遍“列目录 → 传 1MB 文件 → 下载回来比 md5”这条链路再去看别的现象。这条链路十分钟能跑完但能挡掉八成的返工。希望帮到你。本文还有配套的精品资源点击获取