从「上传失败」到「链路打通」:用 NasFtpUploadTester 验证 NAS FTP 上传
面向场景:业务站点要把资源文件存到 NAS,再用独立 IIS 资源站对外提供访问。
工具位置:NasFtpUploadTester
技术栈:.NET 8 控制台 + FluentFTP +Microsoft.Extensions.Configuration
一、为什么要单独做一个控制台测试程序?
在资源中心项目里,文件最终要落到 NAS 上。线上配置里常见两种「路径」:
| 类型 | 示例 | 用途 |
|---|---|---|
| UNC(Windows 共享) | \\Z423-4UWJ\共享名\IIS130 | IIS / PhysicalFileProvider 读文件 |
| FTP | ftp://Z423-4UWJ/.../IIS130/Files0/... | 程序写文件(跨网段、凭据清晰) |
问题在于:
- UNC 和 FTP 不是一回事——FTP 通,不代表
\\服务器\共享一定能写。 - 业务站点体量大,一旦上传失败,要在完整 Web 链路里排查(IIS、应用池、静态目录、业务代码)成本很高。
- 需要一份 可拷贝到部署机、双击就能跑 的探针,把「网络 / 登录 / 路径 / 权限 / 传输模式」逐步拆开验证。
于是有了 NasFtpUploadTester:一个独立 Console,只做一件事——证明「从这台机器,用这组账号,能不能把文件 FTP 到 NAS 指定目录」。
二、工具做了什么(v2.2)
程序按固定步骤输出彩色日志,失败时给出排查建议:
[1/6] 检测网络连通性 Ping + TCP:21
[2/6] 对照测试:UNC 写入 与生产 PhysicalPath 对照(可失败,不影响 FTP)
[FTP] 尝试模式 / 多基路径 被动明文优先;自动探测可写目录
[目录] 列出 FTP 根目录 核对 RemoteBasePath 是否填错
[上传] STOR 到目标路径 自动建目录
[6/6] 验证远端文件 FileExists + 大小比对
关键能力:
- 配置驱动:
appsettings.json,可用--config/--file覆盖。 - 未指定本地文件时自动生成约 64KB 测试文件。
- 开启 FluentFTP 控制台协议日志,便于对照
PASV/STOR/553/501。 - 自动重试:被动模式优先(该 NAS 主动模式常返回
501 security requirements)。 - 多路径候选:根目录无写权限时,会尝试
sata11-xxx/IIS130等基路径。 - UNC 对照:提醒「生产若配 UNC,当前机器是否真能访问共享」。
三、目录与发布
NasFtpUploadTester/
├── NasFtpUploadTester.csproj
├── Program.cs # 主流程
├── appsettings.json # 连接与路径配置
├── README.md # 简要使用说明
└── publish/ # dotnet publish 输出
发布到部署机:
cd NasFtpUploadTester
dotnet publish .\NasFtpUploadTester.csproj -c Release -o .\publish
把 publish 整夹拷到目标机器(例如 C:\Sites\public),执行:
.\NasFtpUploadTester.exe
可选参数:
.\NasFtpUploadTester.exe --config appsettings.json --file D:\demo\test.jpg
四、配置项说明
appsettings.json 核心节点:
{
"FileServer": {
"UploadPath": "Files0"
},
"NasFtpTester": {
"Host": "Z423-4UWJ",
"Port": 21,
"UserName": "你的FTP账号",
"Password": "你的FTP密码",
"UseSsl": false,
"PassiveMode": true,
"UploadPathKey": "Files0",
"RemoteBasePath": "sata11-18914015405/IIS130",
"AutoRetryModes": true,
"UncTestPath": "\\\\Z423-4UWJ\\真实SMB共享名\\IIS130"
}
}
| 字段 | 含义 |
|---|---|
Host | 优先用 主机名,不要死写可能漂移的内网 IP |
PassiveMode | 建议 true;主动模式易被 NAS 安全策略拒绝 |
RemoteBasePath | FTP 侧基目录(不等于 SMB 共享名) |
UploadPathKey | 与业务一致,一般为 Files0 |
UncTestPath | 仅作对照;失败只说明 SMB 不通,不代表 FTP 失败 |
最终远端路径:
/{RemoteBasePath}/{UploadPathKey}/{yyyyMMdd}/{HHmmss_文件名}
成功示例:
/sata11-18914015405/IIS130/Files0/20260819/172018_nas_ftp_test_....txt
对应 IIS 资源站访问(虚拟目录 Files0 → UNC ...\IIS130\Files0):
http://部署机IP:8081/Files0/20260819/172018_nas_ftp_test_....txt
五、实战踩坑(真实排查记录)
下面这些问题,都是在部署机上用本工具一点点打出来的,值得单独记一笔。
1. IP 不通,主机名通
Host = 192.168.112.20 → Ping/TCP:21 失败
Host = Z423-4UWJ → TCP:21 成功(实际解析到 192.168.113.3)
结论: FTP Host 用 NAS 主机名;IP 可能已变更或跨网段策略不同。
2. 501 Your request does not meet the configured security requirements
出现在 主动模式 PORT:
Command: PORT 192,168,112,100,...
Response: 501 Your request does not meet the configured security requirements
结论: 该 NAS(日志里是 ZFileShare)禁止主动模式;固定被动 PASV。
3. 登录成功,但上传 553 no upload permission
根因是 路径写错。FTP 根目录实际是:
/public
/sata11-18914015405
/ftp
/share
往 /Files0/... 写会被拒绝;正确基路径是:
/sata11-18914015405/IIS130/Files0/...
工具会在登录后 MLSD,并自动尝试多个基路径——这是它比「盲传一次」更有价值的地方。
4. FTPS 不可用
AUTH TLS → 550 Cannot get a TLS config: no TLS certificate configured
结论: 明文 FTP + 被动模式即可,不必强开 SSL。
5. FTP 目录名 ≠ SMB 共享名
这是后续配 IIS 时最容易踩的坑:
| 通道 | 名称 | 说明 |
|---|---|---|
| FTP | sata11-18914015405 | 上传路径用这个 |
| SMB | net view 里看到的共享名(可能不同) | IIS 虚拟目录物理路径用这个 |
net use \\服务器\FTP目录名 可能报「找不到网络路径」,换 真实共享名 后成功——两边名字不要混用。
6. 上传成功的标志
日志出现类似内容即可认为探针通过:
✓ 上传成功!耗时 xx ms
✓ 成功路径: /sata11-.../IIS130/Files0/日期/文件
✓ 文件大小一致,FTP 上传验证通过!
六、和业务系统怎么衔接
探针通过之后,业务侧可以按同一约定落地:
- 写:API 通过 FTP 上传到
/{FTP账号可见目录}/IIS130/Files0/{日期}/{文件} - 读:独立 IIS 资源站(如
:8081)用 UNC 挂载
\\NAS\SMB共享名\IIS130\Files0 - 库里存的相对路径仍是
/Files0/...,对外拼资源站域名:
http://资源站:8081/Files0/...
本仓库后续已在 FileServer 中增加 Ftp + PublicBaseUrl 配置,与本测试程序验证过的参数对齐。
七、推荐使用流程(Checklist)
- 在开发机
dotnet publish,拷到 真正跑业务的那台部署机。 - 改
appsettings.json:Host用主机名,RemoteBasePath先可留空,看根目录列表。 - 跑一遍,根据目录列表把
RemoteBasePath改成可写路径(通常含IIS130)。 - 确认上传成功 + 大小一致。
- 用
net view \\主机名确认 SMB 共享名,再配 IIS 虚拟目录。 - 浏览器访问
http://IP:8081/Files0/日期/文件,与 FTP 路径交叉验证。 - 再把同一套 FTP 参数写进业务
appsettings.Production.json。
八、小结
NasFtpUploadTester 解决的不是「写业务功能」,而是把 NAS 文件写入链路 从黑盒变成白盒:
- 网络是否通
- 账号能否登
- 被动/主动是否被拒
- 到底该写哪个 FTP 目录
- UNC 是否另有一套名字与权限
当你在部署环境看到 553、501、错误 53、IIS「用户名不存在」时,先跑这个控制台,往往比改业务代码更快定位问题。
附录:常用排查命令
# FTP 端口
Test-NetConnection Z423-4UWJ -Port 21
# SMB 端口与共享枚举
Test-NetConnection Z423-4UWJ -Port 445
net view \\Z423-4UWJ
# 挂载真实共享名(以 net view 为准)
net use \\Z423-4UWJ\真实共享名 /user:账号 密码
dir \\Z423-4UWJ\真实共享名\IIS130\Files0
本地开发运行:
dotnet run --project .\NasFtpUploadTester.csproj -- --file D:\test\demo.txt
适用环境:Windows 部署机 + ZFileShare / 同类 NAS FTP
源码:
using FluentFTP;
using Microsoft.Extensions.Configuration;
using System.Diagnostics;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
var configPath = GetArgValue(args, "--config") ?? "appsettings.json";
if (!Path.IsPathRooted(configPath))
configPath = Path.Combine(AppContext.BaseDirectory, configPath);
if (!File.Exists(configPath))
{
WriteError($"配置文件不存在: {configPath}");
return;
}
var configuration = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var tester = configuration.GetSection("NasFtpTester").Get<NasFtpTesterConfig>() ?? new NasFtpTesterConfig();
var fileServer = configuration.GetSection("FileServer").Get<FileServerConfig>() ?? new FileServerConfig();
if (string.IsNullOrWhiteSpace(tester.Host) || string.IsNullOrWhiteSpace(tester.UserName))
{
WriteError("请在 NasFtpTester 节点配置 Host / UserName / Password。");
return;
}
var uploadPathKey = string.IsNullOrWhiteSpace(tester.UploadPathKey)
? (string.IsNullOrWhiteSpace(fileServer.UploadPath) ? "Files0" : fileServer.UploadPath)
: tester.UploadPathKey;
var sourceFile = GetArgValue(args, "--file") ?? tester.LocalFilePath;
if (string.IsNullOrWhiteSpace(sourceFile) || !File.Exists(sourceFile))
{
sourceFile = CreateTempFile(tester.GenerateFileSizeKb);
WriteInfo($"自动生成测试文件: {sourceFile} ({new FileInfo(sourceFile).Length / 1024}KB)");
}
var fileName = $"{DateTime.Now:HHmmss}_{Path.GetFileName(sourceFile)}";
var relativeTarget = $"{uploadPathKey}/{DateTime.Now:yyyyMMdd}/{fileName}".Replace("\\", "/");
var remotePath = CombineRemotePath(tester.RemoteBasePath, relativeTarget);
Console.WriteLine();
Console.WriteLine("╔══════════════════════════════════════╗");
Console.WriteLine("║ NAS FTP Upload Tester v2.2 ║");
Console.WriteLine("╚══════════════════════════════════════╝");
Console.WriteLine();
Console.WriteLine($" 配置文件 : {configPath}");
Console.WriteLine($" FTP 地址 : ftp://{tester.Host}:{tester.Port}");
Console.WriteLine($" 用户名 : {tester.UserName}");
Console.WriteLine($" 被动模式 : {tester.PassiveMode}");
Console.WriteLine($" SSL : {tester.UseSsl}");
Console.WriteLine($" 自动重试模式 : {tester.AutoRetryModes}");
Console.WriteLine($" 上传根Key : {uploadPathKey}");
Console.WriteLine($" 远端基路径 : {(string.IsNullOrWhiteSpace(tester.RemoteBasePath) ? "(根目录)" : tester.RemoteBasePath)}");
Console.WriteLine($" 本地文件 : {sourceFile}");
Console.WriteLine($" 目标远端路径 : {remotePath}");
Console.WriteLine($" UNC对照路径 : {(string.IsNullOrWhiteSpace(tester.UncTestPath) ? "(未配置)" : tester.UncTestPath)}");
Console.WriteLine();
WriteStep("1/6", "检测网络连通性");
if (!TestPing(tester.Host))
WriteWarn($"Ping {tester.Host} 失败(可能 ICMP 被禁,继续尝试 TCP)");
else
WriteOk($"Ping {tester.Host} 成功");
if (!TestTcpConnect(tester.Host, tester.Port, tester.ConnectTimeoutMs))
{
WriteError($"TCP 连接 {tester.Host}:{tester.Port} 失败!");
WriteError(" 请用主机名(如 Z423-4UWJ),不要用可能已失效的 IP");
PressAnyKeyToExit();
return;
}
WriteOk($"TCP 连接 {tester.Host}:{tester.Port} 成功");
Console.WriteLine();
WriteStep("2/6", "对照测试:生产同路径 UNC 写入");
TryUncWriteCandidates(tester.Host, tester.UncTestPath, uploadPathKey, sourceFile, fileName);
Console.WriteLine();
var modes = BuildModes(tester);
Exception? lastError = null;
FtpClient? successClient = null;
string? successMode = null;
string? successRemotePath = null;
var remoteListed = false;
string[] pathCandidates = [tester.RemoteBasePath ?? ""];
foreach (var mode in modes)
{
WriteStep("FTP", $"尝试模式: {mode.Name}");
FtpClient? client = null;
try
{
client = CreateClient(tester, mode);
client.Connect();
WriteOk($"登录成功 ({client.SystemType}),模式: {mode.Name}");
if (!remoteListed)
{
pathCandidates = BuildPathCandidates(client, tester.RemoteBasePath, uploadPathKey);
remoteListed = true;
WriteInfo("将按以下基路径依次尝试上传:");
foreach (var p in pathCandidates)
Console.WriteLine($" - /{(string.IsNullOrWhiteSpace(p) ? "(根目录)" : p.Trim('/'))}");
}
var uploaded = false;
foreach (var basePath in pathCandidates)
{
var tryRemote = CombineRemotePath(basePath, relativeTarget);
WriteInfo($"上传到: {tryRemote}");
try
{
var sw = Stopwatch.StartNew();
var status = client.UploadFile(sourceFile, tryRemote, FtpRemoteExists.Overwrite, true, FtpVerify.None);
sw.Stop();
if (status == FtpStatus.Success)
{
WriteOk($"上传成功!耗时 {sw.ElapsedMilliseconds}ms");
WriteOk($"成功路径: {tryRemote}");
WriteOk($"成功模式: {mode.Name}");
successClient = client;
successMode = mode.Name;
successRemotePath = tryRemote;
lastError = null;
uploaded = true;
break;
}
WriteWarn($"上传返回 {status},耗时 {sw.ElapsedMilliseconds}ms");
}
catch (Exception pathEx)
{
lastError = pathEx;
WriteWarn($"路径失败: {pathEx.InnerException?.Message ?? pathEx.Message}");
}
}
if (uploaded) break;
client.Dispose();
}
catch (Exception ex)
{
lastError = ex;
WriteWarn($"失败: {ex.Message}");
if (ex.InnerException != null)
WriteWarn($" 内部: {ex.InnerException.Message}");
try { client?.Dispose(); } catch { /* ignore */ }
var msg = (ex.InnerException?.Message ?? ex.Message) ?? "";
if (msg.Contains("no TLS certificate", StringComparison.OrdinalIgnoreCase))
WriteInfo("NAS 未配置 TLS,跳过 FTPS 模式");
else if (msg.Contains("501", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("security requirements", StringComparison.OrdinalIgnoreCase))
WriteInfo("主动模式被 NAS 安全策略拒绝,继续下一模式...");
}
Console.WriteLine();
}
if (successClient == null || string.IsNullOrWhiteSpace(successRemotePath))
{
WriteError("所有 FTP 路径/模式均上传失败");
if (lastError != null)
{
WriteError($"最后异常: {lastError.GetType().Name}");
WriteError($" {lastError.Message}");
if (lastError.InnerException != null)
WriteError($" 内部: {lastError.InnerException.Message}");
}
WriteError("");
WriteError("根据日志,当前最可能原因是路径不对:");
WriteError(" FTP 根目录只有 /public /sata11-18914015405 /ftp /share");
WriteError(" 不能上传到 /Files0,应上传到 /sata11-18914015405/IIS130/Files0/...");
WriteError(" 请把 RemoteBasePath 设为: sata11-18914015405/IIS130");
PressAnyKeyToExit();
return;
}
remotePath = successRemotePath;
Console.WriteLine();
WriteStep("6/6", $"验证远端文件(成功模式: {successMode})");
try
{
using (successClient)
{
var exists = successClient.FileExists(remotePath);
Console.WriteLine($" FileExists: {exists}");
if (exists)
{
var size = successClient.GetFileSize(remotePath);
var localSize = new FileInfo(sourceFile).Length;
Console.WriteLine($" 远端大小: {size} / 本地大小: {localSize}");
if (size == localSize)
WriteOk("文件大小一致,FTP 上传验证通过!");
else
WriteWarn("文件大小不一致");
}
else
{
WriteWarn("FileExists=false,但 Upload 返回 Success");
}
}
}
catch (Exception ex)
{
WriteWarn($"验证失败: {ex.Message}");
}
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("══════ 测试完成 ══════");
Console.ResetColor();
PressAnyKeyToExit();
static List<ConnMode> BuildModes(NasFtpTesterConfig tester)
{
var preferred = new ConnMode(
$"配置首选(Passive={tester.PassiveMode}, SSL={tester.UseSsl})",
tester.PassiveMode,
tester.UseSsl);
if (!tester.AutoRetryModes)
return [preferred];
// 该 NAS(ZFileShare) 未配置 TLS,主动模式会被 501 拒绝,只重试被动明文
var list = new List<ConnMode>
{
preferred,
new("被动模式 + 明文", true, false)
};
return list
.GroupBy(m => $"{m.Passive}|{m.UseSsl}")
.Select(g => g.First())
.ToList();
}
static string[] BuildPathCandidates(FtpClient client, string? configuredBase, string uploadPathKey)
{
ListRemoteRoots(client, configuredBase);
var candidates = new List<string>();
void Add(string? p)
{
var v = (p ?? "").Trim().Trim('/');
if (!candidates.Any(x => string.Equals(x, v, StringComparison.OrdinalIgnoreCase)))
candidates.Add(v);
}
Add(configuredBase);
Add("sata11-18914015405/IIS130");
Add("sata11-18914015405");
Add("ftp");
Add("share");
Add("public");
Add("");
// 探测配置基路径是否存在,并列出子目录帮助定位 IIS130
try
{
var share = "/sata11-18914015405";
if (client.DirectoryExists(share))
{
WriteInfo($"列出 {share}:");
foreach (var item in client.GetListing(share))
{
var typeTag = item.Type == FtpObjectType.Directory ? "[DIR]" : "[FILE]";
Console.WriteLine($" {typeTag} {item.FullName}");
if (item.Type == FtpObjectType.Directory)
{
var name = item.Name?.Trim('/') ?? "";
if (name.Equals("IIS130", StringComparison.OrdinalIgnoreCase) ||
name.Contains("IIS", StringComparison.OrdinalIgnoreCase))
Add($"sata11-18914015405/{name}");
}
}
}
}
catch (Exception ex)
{
WriteWarn($"探测 sata11-18914015405 失败: {ex.Message}");
}
_ = uploadPathKey;
return candidates.ToArray();
}
static FtpClient CreateClient(NasFtpTesterConfig tester, ConnMode mode)
{
var client = new FtpClient(tester.Host)
{
Port = tester.Port,
Credentials = new NetworkCredential(tester.UserName, tester.Password)
};
client.Config.ConnectTimeout = tester.ConnectTimeoutMs;
client.Config.ReadTimeout = tester.ReadWriteTimeoutMs;
client.Config.DataConnectionConnectTimeout = tester.ConnectTimeoutMs;
client.Config.DataConnectionReadTimeout = tester.ReadWriteTimeoutMs;
client.Config.DataConnectionType = mode.Passive
? FtpDataConnectionType.PASV
: FtpDataConnectionType.PORT;
client.Config.EncryptionMode = mode.UseSsl ? FtpEncryptionMode.Explicit : FtpEncryptionMode.None;
if (mode.UseSsl)
client.Config.ValidateAnyCertificate = true;
client.Config.LogToConsole = true;
return client;
}
static void ListRemoteRoots(FtpClient client, string? remoteBasePath)
{
WriteStep("目录", "列出 FTP 根目录(用于核对 RemoteBasePath)");
try
{
var rootItems = client.GetListing("/");
if (rootItems == null || rootItems.Length == 0)
WriteWarn("根目录为空");
else
{
foreach (var item in rootItems)
{
var typeTag = item.Type == FtpObjectType.Directory ? "[DIR]" : "[FILE]";
Console.WriteLine($" {typeTag} {item.FullName}");
}
}
}
catch (Exception ex)
{
WriteWarn($"列出根目录失败: {ex.Message}");
}
if (string.IsNullOrWhiteSpace(remoteBasePath)) return;
var basePath = "/" + remoteBasePath.Trim('/');
Console.WriteLine($" 列出 {basePath}:");
try
{
if (!client.DirectoryExists(basePath))
{
WriteWarn($"目录不存在: {basePath}");
return;
}
foreach (var item in client.GetListing(basePath))
{
var typeTag = item.Type == FtpObjectType.Directory ? "[DIR]" : "[FILE]";
Console.WriteLine($" {typeTag} {item.FullName}");
}
}
catch (Exception ex)
{
WriteWarn($"列出 {basePath} 失败: {ex.Message}");
}
}
static void TryUncWriteCandidates(string host, string? configuredUnc, string uploadPathKey, string sourceFile, string fileName)
{
var candidates = new List<string>();
void Add(string? p)
{
if (string.IsNullOrWhiteSpace(p)) return;
var v = p.TrimEnd('\\');
if (!candidates.Any(x => string.Equals(x, v, StringComparison.OrdinalIgnoreCase)))
candidates.Add(v);
}
Add(configuredUnc);
Add($@"\\{host}\sata11-18914015405\IIS130");
Add($@"\\{host}\sata11-18914015405");
Add($@"\\{host}\IIS130");
Add($@"\\{host}\share");
Add($@"\\{host}\ftp");
Add($@"\\{host}\public");
foreach (var uncRoot in candidates)
{
WriteInfo($"尝试 UNC: {uncRoot}");
try
{
if (!Directory.Exists(uncRoot))
{
WriteWarn(" 目录/共享不存在");
continue;
}
var targetDir = Path.Combine(uncRoot, uploadPathKey, DateTime.Now.ToString("yyyyMMdd"));
Directory.CreateDirectory(targetDir);
var targetFile = Path.Combine(targetDir, "unc_" + fileName);
File.Copy(sourceFile, targetFile, true);
if (File.Exists(targetFile))
{
WriteOk($"UNC 写入成功: {targetFile}");
return;
}
WriteWarn($" 复制后文件不存在: {targetFile}");
}
catch (Exception ex)
{
WriteWarn($" 失败: {ex.Message}");
}
}
WriteWarn("所有 UNC 候选路径均失败(共享名可能不是 sata11-18914015405,或当前机器无 SMB 访问权限)");
}
static string? GetArgValue(string[] args, string key)
{
var idx = Array.IndexOf(args, key);
return (idx >= 0 && idx + 1 < args.Length) ? args[idx + 1] : null;
}
static string CombineRemotePath(string? root, string relative)
{
var cleanRoot = (root ?? "").Trim().Trim('/');
var cleanRelative = relative.Trim().Trim('/');
return "/" + (string.IsNullOrWhiteSpace(cleanRoot) ? cleanRelative : $"{cleanRoot}/{cleanRelative}");
}
static string CreateTempFile(int sizeKb)
{
var safeSize = sizeKb <= 0 ? 64 : sizeKb;
var path = Path.Combine(Path.GetTempPath(), $"nas_ftp_test_{DateTime.Now:yyyyMMdd_HHmmss}.txt");
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
using var writer = new StreamWriter(fs);
writer.WriteLine($"NAS FTP test file generated at {DateTime.Now:O}");
writer.WriteLine($"Machine: {Environment.MachineName}");
writer.WriteLine(new string('=', 40));
var line = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz";
while (fs.Length < safeSize * 1024L)
{
writer.WriteLine(line);
writer.Flush();
}
return path;
}
static bool TestPing(string host)
{
try
{
using var ping = new Ping();
var reply = ping.Send(host, 3000);
return reply.Status == IPStatus.Success;
}
catch { return false; }
}
static bool TestTcpConnect(string host, int port, int timeoutMs)
{
try
{
using var tcp = new TcpClient();
var task = tcp.ConnectAsync(host, port);
return task.Wait(timeoutMs) && tcp.Connected;
}
catch { return false; }
}
static void WriteStep(string step, string msg)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write($"[{step}] ");
Console.ResetColor();
Console.WriteLine(msg);
}
static void WriteOk(string msg)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($" ✓ {msg}");
Console.ResetColor();
}
static void WriteWarn(string msg)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" ⚠ {msg}");
Console.ResetColor();
}
static void WriteError(string msg)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" ✗ {msg}");
Console.ResetColor();
}
static void WriteInfo(string msg)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" ℹ {msg}");
Console.ResetColor();
}
static void PressAnyKeyToExit()
{
Console.WriteLine();
Console.WriteLine("按任意键退出...");
try { Console.ReadKey(true); } catch { }
}
internal sealed record ConnMode(string Name, bool Passive, bool UseSsl);
internal sealed class NasFtpTesterConfig
{
public string Host { get; set; } = "";
public int Port { get; set; } = 21;
public string UserName { get; set; } = "";
public string Password { get; set; } = "";
public bool UseSsl { get; set; }
public bool PassiveMode { get; set; } = true;
public int ConnectTimeoutMs { get; set; } = 15000;
public int ReadWriteTimeoutMs { get; set; } = 30000;
public string UploadPathKey { get; set; } = "";
public string RemoteBasePath { get; set; } = "";
public string LocalFilePath { get; set; } = "";
public int GenerateFileSizeKb { get; set; } = 64;
public bool AutoRetryModes { get; set; } = true;
public string UncTestPath { get; set; } = "";
}
internal sealed class FileServerConfig
{
public string UploadPath { get; set; } = "";
}

131

被折叠的 条评论
为什么被折叠?



