简介:面向初、中级C#开发者、WinForm入门者以及需要搭建企业内网文件交换工具的技术人员,这份示例项目围绕“把本地文件上传到指定共享文件夹”这一常见需求展开,基于VS2010提供完整工程,适合用作局域网文件分发、数据归集类小工具的参考实现。压缩包共23个文件,约36KB,主要包含6个C#源码文件、3个可执行程序、界面资源、项目配置及调试缓存与说明文本等,工程结构较为完整,便于对照理解各模块职责。已有1431人学习下载,在该类WinForm文件上传场景中具备一定参考热度。读者可从中梳理文件选择、共享路径访问与凭据配置、文件流和网络流读写、后台线程异步上传及进度反馈等关键实现,也能看到异常处理、资源释放、文件类型与大小校验等工程化细节,便于按自身业务裁剪复用。项目以单窗体交互为主线,将本地文件读取、远程共享写入、线程进度更新和错误提示串联成可运行的业务链路,并保留调试输出与配置,方便替换共享路径、账号密码等参数进行二次开发;界面布局、事件响应与后台任务调度的配合关系也在源码中有所体现,对共享权限和网络异常的处理思路较适合初学者借鉴,也可作为课堂示例或课程设计原型,对文件共享传输的入门与实践有较好帮助。
1. 共享文件夹上传,卡住的从来不是那行 File.Copy
winform上传文件到共享文件夹这个需求,代码量小到很多人以为半小时就能交付:选个文件,拼一个\\server\share路径,调一次File.Copy,进度条随便动一动。真正到客户现场才发现,翻车的点全在代码之外——开发机上跑得通,是因为你的 Windows 账号恰好和目标共享在同一个域、恰好有写权限;换成工作组机器、换成网络存储设备、换成需要单独用户名密码的共享,同一行代码立刻抛「拒绝访问」或者「找不到网络路径」。这篇就把这条链路拆开:SMB 共享到底认谁的账号、路径该写 UNC 还是盘符、凭据放在哪、进度和取消怎么接、临时文件怎么保证不留半截垃圾,以及 445 端口、来宾登录、长路径这些一碰就炸的边界。适合正在写桌面端上传功能、或者被「内网共享盘」这个需求卡住的人,照着复现即可。
2. UNC 路径、SMB 会话与身份:共享到底认谁的账号
2.1 UNC 路径不是「网络版的文件夹路径」,它背后是一次 SMB 会话
很多人把\\fileserver01\docshare\2024\a.pdf理解成「另一台机器上的路径」,这个理解会直接把后面的判断带偏。它真实的语义是:把fileserver01解析成 IP,连目标机的 445 端口,协商 SMB 版本,用当前进程的身份令牌完成认证,然后在docshare这个共享上申请一个文件句柄。路径只是入口,能不能拿到句柄取决于认证和两层权限,跟字符串拼得对不对关系不大。
这里最关键的一点是「当前进程的身份令牌」。你的 WinForm 程序既没有自己的账号,也不会弹登录框,它用的是启动它的那个 Windows 登录用户的令牌。所以同一个 exe,在你的开发机上是域账号 A,在生产车间那台机器上是本地账号 B,跑出来的结果可以完全不同。血泪经验是:只要有人跟你说「这个功能我这边是好的」,第一句要问的不是报什么错,而是「你用什么账号登录的那台机器」。
盘符映射是最容易踩的一个坑。资源管理器里把\\fileserver01\docshare映射成Z:,代码里写Z:\a.pdf,在你自己的交互式会话里完全没问题。但盘符映射是登录会话级的东西,换个用户、换成一个计划任务、换成一个服务,Z:根本不存在。所以代码里一律写 UNC,配置项里存的也必须是 UNC,盘符只在给人手动排查的时候用。
还有一个容易被忽略的差异:用主机名访问和用 IP 访问,认证路径不一样。主机名能走到 Kerberos(前提是目标有对应的 SPN 且在域内),IP 访问基本只能退到 NTLM。有些环境把 NTLM 关掉了,你用 IP 拼路径就会直接认证失败,而换成主机名就好了。反过来,如果内网 DNS 不稳、主机名解析飘,那老老实实配 hosts 或者在配置里留一个可切换的地址字段,比在代码里硬编码强。
最后是权限的两层结构:共享权限(Share Permission)和 NTFS 权限,两者取交集。运维图省事把共享权限开成「完全控制」,就以为搞定了,结果 NTFS 层只给了读取,写入照样报 5。排查时这两层要分开验证,别混在一起猜。
2.2 三种带身份的方式:进程身份、net use、WNetAddConnection2
给共享传文件时,身份只有三种给法,选错一种,后面全是补丁。
| 方式 | 凭据来源 | 生效范围 | 适用场景 | 主要问题 |
|---|---|---|---|---|
| 进程身份(默认) | 当前 Windows 登录用户 | 整个进程 | 域内机器、账号已统一 | 工作组环境或需专用账号时失效,无法在代码里控制 |
net use映射 | 命令行显式传入 | 当前登录会话 | 临时验证、运维脚本 | 要起外部进程、要解析文本输出、错误码拿不到 |
WNetAddConnection2 | API 参数传入 | 当前登录会话 | 交付给客户的桌面程序 | 需要 P/Invoke,连接要手动释放,多凭据会冲突 |
默认的进程身份是最省事的,也是最推荐先试的。如果目标共享对「域用户」或「Authenticated Users」给了修改权限,那代码里什么都不用做,直接写 UNC 就行。这种情况下额外加一层账号密码,纯属给自己找麻烦。
net use的问题不在能不能用,而在工程上不可控。它要靠Process.Start拉一个 cmd 出来,密码得拼在命令行参数里(进程列表可见),成功失败只能靠退出码和标准输出文本判断,中文系统上还可能遇到编码问题。只建议在排查阶段用,不要写进产品代码。
WNetAddConnection2是桌面程序里比较靠谱的选择:用户名密码作为参数传进去,返回标准 Win32 错误码,不需要落盘也不需要起外部进程。它有个必须记住的特性——建立的是「登录会话级」的连接,一旦连上,后续所有对这个服务器的访问都会复用这个连接,直到你显式断开、注销或者重启。这个特性既省事(连一次之后FileStream直接用 UNC 就行,不用每写一个文件就认证一次),也是 1219 错误的根源,后面单独讲。
选型上我的建议很直接:能用进程身份就不要碰凭据;必须用专用账号,就上WNetAddConnection2;只在给人排查的文档里写net use。
2.3 先用 PowerShell 把通道验证通,再打开 Visual Studio
这一步很多人跳过,然后花两小时在 C# 里调试一个其实跟代码无关的问题。正确的顺序是先证明「这台机器、这个账号、这个路径、这个端口」四件事都成立,再去写上传逻辑。
# 1) 445 通不通,先排除防火墙和端口问题 Test-NetConnection -ComputerName fileserver01 -Port 445 # 2) 用显式凭据建一次连接,凭据只作用于当前登录会话 net use \\fileserver01\docshare /user:DOMAIN\uploader * # 3) 看连接状态和协商出来的 SMB 版本 net use Get-SmbConnection -ServerName fileserver01 # 4) 真正写一次,验证的是 NTFS 写权限,不只是读权限 "probe $(Get-Date -Format o)" | Out-File \\fileserver01\docshare\_probe.txt -Encoding utf8 # 5) 用完释放,避免把 1219 带进后面的调试 net use \\fileserver01\docshare /delete第 1 步的Test-NetConnection返回TcpTestSucceeded : True才算过。如果这里是 False,后面所有 C# 代码都不用看,先去查网络策略或者目标机共享服务有没有起来。
第 2 步的*表示交互式输入密码,这样密码不会留在命令历史里。DOMAIN\uploader里的域名部分,工作组环境要写成目标机名或者.\uploader的形式,写成域名会直接 1326。
第 4 步是整个验证里最有价值的一步。只做dir只能证明你有读权限,写一个探针文件才能证明写权限通。很多「权限都给了啊」的争论,到这一步就结束了。
第 5 步别省。很多人调完不断开,然后切到另一个账号再试,直接撞上 1219,误以为是代码问题。
走到这里如果还不通,对着错误码看下表,基本能定位到八九成:
| Win32 码 | 常见提示 | 大概率原因 |
|---|---|---|
| 5 | 拒绝访问 | 共享权限或 NTFS 权限缺写;账号没匹配上 |
| 53 | 找不到网络路径 | 445 被拦、主机名解析失败、目标共享服务未启动 |
| 64 | 指定的网络名不再可用 | 传输中途断链、对方重启或网卡切换 |
| 67 | 找不到网络名 | 共享名拼错,或lpRemoteName写到了子目录层级 |
| 86 | 指定的网络密码不正确 | 密码错,域账号过期也报这个 |
| 1219 | 不允许同一用户用多个用户名连同一服务器 | 已有其他凭据连过这台机器 |
| 1326 | 登录失败:用户名未知或密码错误 | 账号或域名写法不对 |
| 1272 | 安全策略阻止来宾访问 | 匿名/来宾访问被默认策略禁用 |
3. WinForm 端的最小可用实现:分块写入、进度与取消
3.1 界面线程和上传线程:为什么不能直接调 File.Copy
WinForm 的 UI 是单线程的,消息循环跑在主线程上。你在按钮事件里同步调File.Copy往共享盘写一个 800MB 的文件,主线程就被占住了,窗口不重绘、拖动卡顿、点关闭没反应,用户第一反应是「程序死了」,然后强杀进程,共享盘上留下一个半截文件。这个场景在百兆内网里尤其明显,因为写入速度被网络带宽卡住,耗时远超本地磁盘。
正确的做法是用async/await把阻塞的 IO 挪到线程池,UI 线程只负责更新控件。这里有个细节:FileStream在 .NET Framework 上如果构造时没传FileOptions.Asynchronous,ReadAsync/WriteAsync其实是同步执行的,await会立刻完成,界面照样卡。真正要异步,要么用Task.Run包一层,要么在构造函数里显式带上FileOptions.Asynchronous。我一般用后者,语义更干净。
private CancellationTokenSource? _cts; private async void btnUpload_Click(object sender, EventArgs e) { var shareDir = txtShare.Text.Trim().TrimEnd('\\'); // \\fileserver01\docshare\sub if (string.IsNullOrWhiteSpace(shareDir) || !File.Exists(txtLocalFile.Text)) { lblStatus.Text = "请先选择文件和目标目录"; return; } btnUpload.Enabled = false; progressBar1.Value = 0; _cts = new CancellationTokenSource(); // Progress<T> 会把回调投递回创建它的同步上下文,也就是 UI 线程 var progress = new Progress<UploadProgress>(p => { progressBar1.Value = Math.Min(100, (int)p.Percent); lblStatus.Text = $"{p.Done / 1048576.0:F1} MB / {p.Total / 1048576.0:F1} MB"; }); try { var saved = await ShareUploader.UploadAsync( txtLocalFile.Text, shareDir, progress, _cts.Token); lblStatus.Text = "已上传:" + saved; } catch (OperationCanceledException) { lblStatus.Text = "已取消"; } catch (IOException ex) { // 共享盘写失败几乎都走 IOException,内部包一个 Win32Exception MessageBox.Show(ex.Message, "上传失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); } finally { btnUpload.Enabled = true; _cts.Dispose(); _cts = null; } }这段代码里有几个容易被改坏的点。Progress<T>必须在这里创建,因为它在构造时捕获当前的SynchronizationContext,在后台线程里Report时才会自动切回 UI 线程;如果在后台方法里new Progress<T>,回调就跑到线程池上去了,直接操作控件会抛跨线程异常。
btnUpload.Enabled = false不只是防重复点击。共享盘上同名文件并发写入会撞「另一个程序正在使用此文件」(错误 32),禁用按钮是最便宜的串行化。真要做并发上传,就得在上传层加信号量,那是另一个话题。
async void在这里是可以接受的,因为这是事件处理器,异常已经被 try/catch 兜住。如果在非事件方法里写async void,异常会直接打到Application.ThreadException上,行为很难预测。
3.2 分块写入加进度回调:核心的上传方法
真正写共享盘的只有这一个方法,剩下的都是它的封装。这里不用File.Copy,因为它给不了进度,也给不了取消,中途断了还留个残缺文件。
public sealed class UploadProgress { public long Total { get; init; } public long Done { get; init; } public double Percent => Total <= 0 ? 100 : (double)Done / Total * 100; } public static async Task<string> UploadAsync( string sourcePath, string remoteDir, IProgress<UploadProgress>? progress, CancellationToken token, int bufferSize = 1024 * 1024) { var fileName = Path.GetFileName(sourcePath); var total = new FileInfo(sourcePath).Length; var remotePath = Path.Combine(remoteDir, fileName); // 先写临时名,完成后改名,共享盘上不会出现半截文件 var tempPath = remotePath + "." + Guid.NewGuid().ToString("N") + ".part"; var buffer = new byte[bufferSize]; long done = 0; var sw = Stopwatch.StartNew(); try { using (var src = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan)) using (var dst = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan)) { int read; while ((read = await src.ReadAsync(buffer, 0, buffer.Length, token)) > 0) { token.ThrowIfCancellationRequested(); await dst.WriteAsync(buffer, 0, read, token); done += read; // 节流:1MB 一块,1GB 文件会有 1000 次回调,不节流 UI 会被刷爆 if (sw.ElapsedMilliseconds >= 100) { progress?.Report(new UploadProgress { Total = total, Done = done }); sw.Restart(); } } await dst.FlushAsync(token); } // 同一共享内的改名是元数据操作,比整文件再复制一遍便宜得多 if (File.Exists(remotePath)) { var bak = remotePath + ".bak." + DateTime.Now.ToString("yyyyMMddHHmmss"); File.Move(remotePath, bak); // 不直接删,留一次后悔药 } File.Move(tempPath, remotePath); progress?.Report(new UploadProgress { Total = total, Done = done }); return remotePath; } catch { TryDelete(tempPath); // 失败清理,别把 .part 堆在共享盘上 throw; } } private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { /* 清理失败不掩盖原始异常 */ } }参数怎么定,有几个实操结论。bufferSize默认给 1MB,不是越大越好也不是越小越好。小于 64KB 时,SMB 的往返次数明显上升,千兆内网下吞吐掉得厉害;超过 4MB 之后收益就被网络带宽吃平了,反而让取消的响应变迟钝——按 1MB 一块算,用户在 100MB/s 的链路上最多等 10 毫秒就能中断,体验刚好。如果链路质量差、丢包多,把它降到 256KB 会更稳。
FileShare.None在临时文件上加的是独占锁,能防止两个进程同时写同一个.part。但注意FileShare.Read那个参数在源文件上是必须的,否则用户开着这个文件(比如 Excel 占着)就传不了,报错误 32。
FileOptions.SequentialScan是个便宜的性能提示,告诉系统我们会顺序读/写,文件缓存可以少做预读推测。大文件上能省一点内存抖动。
File.Move在同一个共享内是重命名语义,只改目录项,不搬运数据,所以这一步几乎瞬时完成。这是整个方案里最划算的一个设计:用户在进度条走完之后才看到最终文件名,中间任何时刻中断,目录里只有一个.part,不会有人误以为传完了。
改名之前处理同名文件用了.bak后缀而不是直接删,是个习惯问题。共享盘上覆盖别人刚上传的文件,事后想找回原始版本,有这一个备份就够救命了。
3.3 断点续传要不要做:一个务实的取舍
很多人第一反应是加分块续传。在共享盘场景下,我的建议是默认不做,只在明确有大文件(500MB 以上)且链路不稳定时才做,理由是投入产出比不划算。
SMB 上的随机写比顺序写贵不少。断点续传需要记录已传偏移、用FileStream.Seek定位到.part文件的指定位置继续写。但一旦网络断开,原来的句柄就失效了,重连之后还得靠FileStream保存的状态或自己维护的进度文件来恢复,中间任何一步没对齐,拼出来的文件就是坏的——而且这种坏是静默的,长度对得上,内容错位,最坑。
更务实的组合是「整文件重传 + 有限次自动重试 + 长度校验」。网络抖动在局域网内通常是秒级的,重传一个 200MB 的文件也就几十秒,比实现一套正确的续传逻辑便宜得多。真正的瓶颈往往不是重传时间,而是没人知道传失败了。
如果确实要续传,最低成本的实现不是改FileStream的写入偏移,而是把源文件按固定大小(比如 64MB)切成多个分片,每片独立上传成xxx.part001、xxx.part002,全部成功后再在共享盘上合并成一个文件再删分片。合并这一步在共享盘上做是服务端内部的读写,比客户端跨网络拼要快,而且每一片的成败都是显式的,重试粒度清楚。代价是多占一份临时空间,以及合并期间要保证没有别人动这些分片。
选择哪种,看你的容错目标:只是不想让用户白等,用第一种;要保证在频繁断网的车间环境里最终一定传完,用第二种。
4. 凭据管理:密码放在哪,连接什么时候断
4.1 用 WNetAddConnection2 显式建立共享连接
需要专用账号时,这是最干净的一层封装。它把「用户名密码」这件事收进一个方法里,上层只负责调Connect,然后照常用 UNC 路径写文件。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct NETRESOURCE { public int dwScope; public int dwType; // RESOURCETYPE_DISK = 1 public int dwDisplayType; public int dwUsage; public string? lpLocalName; // 传 null 表示不映射盘符,只建 UNC 连接 public string? lpRemoteName;// 必须到共享层:\\server\share public string? lpComment; public string? lpProvider; } [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetAddConnection2( ref NETRESOURCE lpNetResource, string? lpPassword, string? lpUserName, int dwFlags); [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetCancelConnection2(string lpName, int dwFlags, bool fForce); public static void Connect(string uncShare, string userName, string password) { var nr = new NETRESOURCE { dwType = 1, // RESOURCETYPE_DISK lpRemoteName = uncShare, // \\fileserver01\docshare lpLocalName = null, lpProvider = null }; var rc = WNetAddConnection2(ref nr, password, userName, 0); if (rc != 0) throw new Win32Exception(rc, $"连接共享失败({rc}):{uncShare}"); } public static void Disconnect(string uncShare) { WNetCancelConnection2(uncShare, 0, true); }几个必须按字面理解的地方。lpRemoteName只能写到共享名这一层,多写一层子目录会返回 67(找不到网络名),哪怕这个目录真实存在。子目录交给后面的Path.Combine处理。
lpLocalName传 null 是有意的,不映射盘符。映射盘符会让连接出现在资源管理器的「网络驱动器」里,用户可能手动断开,程序这边完全不知情,下一次写入就是错误 64。只建 UNC 连接,用户碰不到,生命周期可控。
userName的写法取决于环境:域环境写DOMAIN\user,工作组写MACHINENAME\user或.\user。写成user@domain.com的 UPN 形式也可以,但对老一些的目标机兼容性差,我一般不用。
dwFlags传 0。传CONNECT_TEMPORARY的话,连接不做持久化记录,进程退出后自动清理,看起来更干净,但在某些场景下重连行为不符合预期,调试时会多一层不确定性。直接用 0,退出时手动Disconnect。
错误码不要自己重新定义,new Win32Exception(rc)就能拿到系统自带的中文描述,弹给用户或者写进日志都比一句「上传失败」有用得多。
4.2 密码放配置文件还是让用户输:三种方案的取舍
这个问题没有唯一正确答案,但有一个明确的底线:明文密码不能落在任何会被拷走的文件里。
| 方案 | 安全性 | 易用性 | 适用场景 |
|---|---|---|---|
| 用户每次输入 | 高 | 低 | 少数人偶尔使用的工具 |
| DPAPI 加密后存配置 | 中 | 高 | 单机部署、账号固定的客户端 |
| 由运维统一下发只写账号 | 高 | 高 | 批量部署、有 IT 管理的环境 |
如果选 DPAPI,ProtectedData用当前用户作用域,密文只有同一个 Windows 账号能解开。换机器、换用户、重装系统都会解不开,这既是限制也是保护——程序被整个目录拷走也没用。
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("share-upload-v1"); public static void SavePassword(string configPath, string password) { var cipher = ProtectedData.Protect( Encoding.UTF8.GetBytes(password), Entropy, DataProtectionScope.CurrentUser); File.WriteAllBytes(configPath, cipher); } public static string? LoadPassword(string configPath) { if (!File.Exists(configPath)) return null; try { var plain = ProtectedData.Unprotect( File.ReadAllBytes(configPath), Entropy, DataProtectionScope.CurrentUser); return Encoding.UTF8.GetString(plain); } catch (CryptographicException) { return null; // 换了用户或机器,解不开就当没配过,走重新输入流程 } }Entropy是附加熵,不是密钥,它的作用只是让同一台机器上另一个程序没法直接Unprotect你这段密文。它必须硬编码在程序里并且保持稳定,改了之后就解不开旧数据了,所以带个版本号后缀比较稳妥。
不要用DataProtectionScope.LocalMachine,那样同一台机器上任何账号都能解开,等于没加密。
比 DPAPI 更省心的其实是第三种:让运维在共享上建一个只有写权限、没有读和删权限的账号,密码随安装包一起下发。这样即使密码泄露,攻击者也只能往里丢文件,拿不走也删不掉已有数据。代价是这个账号不能用来覆盖同名文件,得配合前面的「临时名 + 改名」策略,让每次上传都落到一个新文件名上。
4.3 连接的释放时机与 1219 冲突
WNetAddConnection2建立的连接挂在当前登录会话上,不释放就一直存在。这在正常流程里是好事——一个上传任务连一次,之后所有文件复用。但有两个场景必须主动断。
第一个是切换账号。用户在设置里改了用户名密码,重新Connect,如果原来的连接还在,会直接返回 1219。系统不允许同一个用户对同一台服务器建立多个不同凭据的连接。处理办法很简单:Connect之前先Disconnect一次,并且忽略断开失败(本来就没连上)。
public static void Reconnect(string uncShare, string user, string password) { Disconnect(uncShare); // 忽略返回值,没连上也无所谓 Connect(uncShare, user, password); }第二个是程序退出。虽然注销会清掉,但用户可能几个小时不注销,中间换人操作,凭据就串了。在FormClosing或者应用的退出路径里显式断一次,成本很低。
真遇到 1219 又没法统一账号时,有个权宜之计:用同一台服务器的另一个名字访问。\\fileserver01\docshare和\\192.168.1.20\docshare在网络提供程序里算两台不同的服务器,可以各挂一套凭据。但这招会带来两个副作用:IP 访问走不了 Kerberos,某些环境直接失败;同一个共享出现两个入口,日志和排查都会乱。只建议当临时解,不要写进交付版本。
多线程下还有一个隐性问题:连接是会话级的,不是进程级的。两个线程同时调Connect传不同凭据,第二个必然 1219。所以连接管理必须串行,加个lock或者干脆在启动时连一次、全局复用。我的做法是把连接状态和已连的共享名放在一个静态字典里,带引用计数,第一次用的时候连,最后一个用户退出时断。多写二十行代码,省掉后面一堆时序问题。
5. 上传失败的 5 类现场:现象、原因、解决
5.1 现象:提示「拒绝访问」,但运维坚持权限已经全开了
这是出现频率最高的一条。表现是FileStream构造时抛UnauthorizedAccessException,或者内层Win32Exception的NativeErrorCode是 5。
原因几乎总是两层权限只开了一层。共享权限是 SMB 层的门禁,NTFS 权限是文件系统层的门禁,两者取交集。运维在「共享」选项卡里把Everyone设成「完全控制」,看着像是全开了,但底层目录的 NTFS 权限还是默认继承来的,只给了读取和执行。写入请求过了第一道门,在第二道被拦下来,报 5。
还有一种变体:账号本身对,但目录是别人创建的,新账号没有继承权限,需要显式加一条 ACE。这种情况在共享盘按项目分目录时特别常见。
解决办法是分层验证。先用net use连上,然后Out-File写一个探针文件,这一步失败就说明是权限问题,跟代码无关。再让运维在目标目录上右键看「安全」选项卡,确认你的账号在列表里且有「修改」权限,同时看「共享」选项卡里的权限条目。两边都对了再回来跑程序。别在代码里加各种重试去掩盖权限问题,5 这个错误码重试一百次还是 5。
5.2 现象:开发机正常,客户机报「找不到网络路径」
错误码 53 或 64,有时候是 67,界面上都是一句「找不到网络路径」,看不出区别。
原因有四种,按出现频率排:一是目标机 445 端口被防火墙拦了,尤其是跨网段、跨 VLAN 的场景;二是客户端和服务端协商不出共同的 SMB 版本,很多老存储设备只支持 SMB1,而新版 Windows 默认已经把 SMB1 客户端组件移除了;三是主机名解析不了,开发机上有 hosts 记录或者 DNS 能查到,客户机没有;四是共享名拼错,或者路径写到了子目录层(对应 67)。
排查顺序建议从上往下。先Test-NetConnection -ComputerName <server> -Port 445,不通就是网络或防火墙,找网络那边,别浪费时间。通了再看Get-SmbConnection -ServerName <server>,能看到协商出来的 Dialect,比如 3.1.1;如果这一步报错但端口是通的,多半是 SMB 版本或者认证问题。最后才是路径拼写,注意lpRemoteName只能到共享层。
如果确认是 SMB1 的问题,方向不是去客户端开 SMB1(那是在给自己留安全隐患),而是升级存储设备固件或者换一台支持 SMB2/3 的。实在改不了的存量设备,只能把上传程序部署在一台还允许 SMB1 的中间机上做中转,这个代价要提前跟需求方讲清楚。
5.3 现象:改完配置重启程序,报 1219 说同一服务器不允许重复连接
提示大意是「不允许一个用户使用一个以上用户名与一个服务器或共享资源的多重连接」。这个错误很有迷惑性,因为程序刚启动,看起来什么都没连。
原因是这台机器上已经有别的进程、别的会话用另一个账号连过这台服务器了。可能是用户之前用资源管理器手动映射过一个盘,可能是上一个版本的调试残留,也可能是 IT 装的某个备份工具。SMB 的连接是按「服务器」这个粒度缓存的,不管是谁建的,后来者只能复用,不能覆盖。
解决办法是先清干净再连。用net use列出所有连接,把指向这台服务器的条目全部/delete,再跑程序。在代码里则是前面说的:任何Connect之前先Disconnect一次,并在失败时给出明确提示,告诉用户「这台机器上已有其他账号连接了该共享,请先断开」,而不是抛一句系统原文让人猜。
长期方案是把账号统一,一台机器只对应一套凭据。真的需要多套,就用上一节说的分机器部署,别在一个进程里切来切去。
5.4 现象:匿名访问共享,提示「组织安全策略阻止未经身份验证的来宾访问」
错误码通常是 1272,或者0x8007052E这类登录失败变体。场景多为连网络存储设备,共享上没有设账号,纯匿名访问。
原因是新版本 Windows 默认关闭了不安全的来宾登录(Insecure Guest Logons)。这是有意的安全收敛,不是 bug。很多老设备的默认配置就是匿名共享,两边一撞就失败。
解决方向有两个,优先级从高到低:一是在存储设备上建一个正式账号,用WNetAddConnection2带凭据连,这是正路;二是如果设备实在不支持账号,只能在客户端放开来宾登录策略(组策略里的「启用不安全的来宾登录」),但要清楚这是降安全等级的操作,只应该在内网隔离环境、且明确知道风险的前提下做,而且要在交付文档里写清楚,不能悄悄改。
我不建议在安装程序里自动去改这个策略。这种改动影响整台机器,出问题的时候没人会想到是上传程序干的,是典型的黑匣子操作。
5.5 现象:路径一长就抛 PathTooLongException,或者中文文件名变成乱码
长度问题在 .NET Framework 上很典型,路径超过 260 个字符就炸。共享路径本身已经很长了(\\fileserver01\docshare\项目资料\2024\...),再叠几层子目录和长文件名,很容易越线。
解决有两个层次。浅层是把目标目录层级压平,上传时不再按日期/项目建深层子目录,而是用文件名前缀来承载这些信息。深层是启用长路径支持:应用程序配置文件里加上Switch.System.IO.UseLegacyPathHandling=false和Switch.System.IO.BlockLongPaths=false,同时在系统层面开启长路径策略;或者干脆在路径前拼\\?\UNC\前缀,把\\fileserver01\...变成\\?\UNC\fileserver01\...。
用\\?\前缀要注意两点:它会关掉路径规范化,路径必须是完全限定的绝对路径,..和.都不会被解析;而且拼接时必须用反斜杠,不能混用正斜杠。我的做法是封装一个ToExtendedPath方法,在长度超过 240 的时候自动加前缀,其余情况保持原样,避免不必要的行为差异。
中文乱码则是另一回事。C# 内部是 UTF-16,路径传给 Win32 API 时用CharSet.Unicode就不会乱。会乱的地方一般在两个边上:一是通过net use命令行传路径,控制台代码页不对;二是日志文件用默认编码写,用记事本打开看着像乱码。前者改成走 API,后者在写日志时显式指定 UTF-8。
6. 让上传可验证:长度校验、哈希对比与有限重试
传完了不等于传对了。共享盘写入过程中如果遇到网络抖动,FileStream有时不会立刻抛异常,而是把连接挂在那里,最后写出来的文件长度少了几个字节,用户以为成功了,下游系统读出来是坏文件。所以落地之后必须有一道校验。
最低成本的校验是对比长度,这一步几乎不花时间,能抓住绝大多数截断问题。要更严一点,就在写入的同时用IncrementalHash算源文件的 SHA256,落地后把远端文件读回来再算一遍对比。读回来意味着多一次全量 IO,200MB 以上的文件建议做成可选开关,默认关闭,只在关键业务上打开。
| 校验级别 | 开销 | 能发现的问题 |
|---|---|---|
| 只比长度 | 一次元数据查询 | 截断、写了一半 |
| 长度 + 首尾各 4KB 抽样 | 两次小读 | 大部分错位、部分覆盖 |
| 长度 + 全量 SHA256 | 一次全量读 | 内容级错误,最严格 |
重试也要分类,不能一视同仁。权限类错误(5、1326、86)重试一百次也没用,只会让用户多等;网络类错误(53、64、67)和共享冲突(32、33)值得退避重试,因为多数是瞬时的。
private static readonly int[] RetryableCodes = { 32, 33, 53, 59, 64, 67, 121 }; public static async Task<string> UploadWithRetryAsync( string sourcePath, string remoteDir, IProgress<UploadProgress>? progress, CancellationToken token, int maxAttempts = 3) { for (var attempt = 1; ; attempt++) { try { var saved = await UploadAsync(sourcePath, remoteDir, progress, token); var local = new FileInfo(sourcePath).Length; var remote = new FileInfo(saved).Length; if (local != remote) throw new IOException($"长度校验失败:本地 {local},远端 {remote}"); return saved; } catch (IOException ex) when (attempt < maxAttempts && IsRetryable(ex)) { // 2s、4s、8s 退避,给网络一个恢复窗口,别死循环打满 await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), token); } } } private static bool IsRetryable(IOException ex) { var code = (ex.InnerException as Win32Exception)?.NativeErrorCode ?? 0; return RetryableCodes.Contains(code); }IsRetryable依赖Win32Exception的NativeErrorCode,所以前面封装的时候一定要保留内层异常,别在中间层catch (Exception) { throw new IOException("上传失败"); }把它吃掉——那样这层判断永远拿不到真实错误码,重试就变成了盲试。
日志里我固定记四项:UNC 路径、使用的账号名(绝不记密码)、耗时和字节数、以及 Win32 错误码。这四项凑齐,任何一次失败都能在事后复盘出来是权限、网络还是代码问题,不用再让用户描述「就是传不上去」。
这套东西我现在的习惯是,任何涉及共享盘的功能,先在目标机器上用 PowerShell 把账号、路径、端口、权限四件事各验证一遍,把结论写进交付清单,然后才打开编辑器。之前吃过一次亏,为了绕过一个「拒绝访问」,在代码里加了三次重试和一堆提示文案,最后发现是运维漏给了一个目录的 NTFS 写权限,删掉那些补丁反而更干净。希望帮到你。
本文还有配套的精品资源,点击获取