WinForm 上传共享文件夹:SMB 凭据、流式复制与 1219 排错
简介这份资料是一套基于VS2010与C# Winform的文件上传示例面向需要把本地文件写入局域网共享目录的开发者也可用于企业内部数据传输、共享盘上传等场景。压缩包共23个文件约36KB以6个cs源码为核心配有sln解决方案与csproj项目配置并包含exe、pdb、resources/resx、settings等编译调试与界面资源文件结构适合直接打开对照学习。已有1429人学习。代码围绕文件选择、UNC路径与凭据连接、FileStream/NetworkStream读写、BackgroundWorker进度反馈、异常处理和文件类型大小校验展开可帮助读者理解从界面交互到网络共享写入的完整链路并据此排查权限不足、连接失败等常见问题快速迁移到自己的上传模块中。1. 从「复制粘贴能用」到线上必翻车WinForm 上传共享文件夹的真实边界某个做设备巡检的桌面项目客户要求把每天生成的报表直接写进机房的共享文件夹理由很朴素那边已经有备份策略不想再维护额外的传输服务。听起来File.Copy一行就能收工真上线第一周就翻车了开发机上跑得好好的装到车间电脑上弹「拒绝访问」换一台机器又变成「找不到网络路径」还有一台传到 60% 卡死不动。问题不在 C# 语法而在「文件上传到共享文件夹」这件事本身牵扯三样东西SMB 会话、进程身份、Windows 凭据缓存。WinForm 进程默认一样都没有。这篇记录按「先看清链路 → 再写上传骨架 → 再解决凭据 → 最后排坑」的顺序走一遍代码可以直接抄坑我尽量标在你会踩的位置上。2. 拆开 SMB 上传链路UNC 路径、身份上下文与三种凭据方案2.1 一次写\\server\share到底经过了什么当你写下\\fileserver\share\report.xlsx这个路径时Win32 的CreateFile并不会自己去连网络它把活交给 mpr多重提供程序路由器重定向器重定向器再落到 SMB 客户端驱动上。整个过程拆开是四步解析主机名 → 建立到 445 端口的 TCP 连接 → 协商 SMB 版本并完成身份认证得到一个会话Session→ 对这个共享做树连接Tree Connect此后才是 CREATE / WRITE / CLOSE 这些文件级操作。真正决定成败的是第三步。认证用的凭据来自当前进程令牌域环境里走 Kerberos工作组或本地账户走 NTLM。关键在于整条链路上没有任何一个参数能让你「这次写入用 A 账号下次用 B 账号」。凭据只在建会话时用一次之后缓存在内核的会话表里按「服务器 用户名」索引后续所有 IO 直接复用。这解释了两个最常见的困惑。第一为什么开发机上一切正常——你在资源管理器里手输过一次密码会话已经被建好了CtrlF5 调试的进程正好蹭上了这个会话。第二为什么两个不同账号往同一台服务器传文件会报 1219因为内核发现「这台服务器已经有一条属于别人的会话了」它拒绝在同一条会话上叠加第二套凭据。提示判断当前有没有活着的会话用net use看列表或者Get-SmbConnection看客户端侧连接明细比猜快得多。2.2 三种身份方案先选型再写代码WinForm 上传到共享文件夹本质上是在回答「以谁的身份写」这个问题只有三条路方案实现方式适用场景主要代价当前登录用户不写任何代码直接写 UNC 路径域环境且登录账号已被授予共享写权限换机器、换账号就失效运维无法自证显式凭据建会话WNetAddConnection2mpr.dll工作组环境、NAS、客户只给一个专用上传账号需要处理多重连接冲突与连接释放登录令牌模拟LogonUserWindowsIdentity.Impersonate一次进程内要访问多台不同凭据的服务器令牌生命周期管理更绕容易漏掉 Undo绝大多数现场我推荐第二条。原因是它足够「局部」连接是进程级的、可显式释放的出问题只影响这一台上传功能不会污染整个应用的执行上下文。第三条更适合「一台客户端要同时往三台不同 NAS 写」的极端场景如果只是往一台文件服务器写用WNetAddConnection2就够别把简单问题复杂化。有一个必须提前说清的边界WNetAddConnection2建立的连接属于当前登录会话不是属于进程。进程退出连接可能还在反过来如果同会话里别的程序已经用另一个账号连过这台服务器你这边就会撞 1219。所以代码里必须配对写「建立」和「断开」不能只建不断。2.3 先用手把通道验证通再动 C#写代码之前先用 PowerShell 把「网络通、认证通、能写」这三件事分开验证。这一步能省掉后面 80% 的瞎猜# 1) 只验端口不涉及任何凭据用来区分「网络问题」和「权限问题」 Test-NetConnection -ComputerName fileserver -Port 445 # 2) 用目标上传账号显式建一次会话 # 用户名格式三选一DOMAIN\user、fileserver\user本地账户、.\user net use \\fileserver\share /user:DOMAIN\uploader Pssw0rd # 3) 写一个探针文件确认共享目录真的有写权限不只是读 Set-Content -Path \\fileserver\share\_probe.txt -Value ok -Encoding UTF8 # 4) 用完立刻删掉避免它和你后面调试的凭据打架 net use \\fileserver\share /delete第 1 步失败说明是网络或防火墙445 被拦、主机名解析不到都会在这里暴露第 2 步失败看返回码1326 是账号密码不对1311 是找不到登录服务器域控不可达第 3 步失败但第 2 步成功说明账号能连上但共享 ACL 或 NTFS 权限没给写这是纯运维问题代码怎么写都没用。第 4 步经常被跳过然后调试时莫名其妙报 1219。养成习惯手动验证完就删别留在会话表里。另外补一句关于映射盘不要用Z:\这种盘符。映射盘只在建立它的那个登录会话内可见服务、计划任务、另一个用户跑的进程统统看不到。共享路径永远写 UNC。3. WinForm 端的上传骨架选文件、跑后台、报进度3.1 界面线程和上传线程的分工WinForm 里最容易犯的错不是上传失败而是上传成功但窗口卡成白板。File.Copy和同步的FileStream.Write都是阻塞调用放在按钮事件里UI 线程一堵进度条不动、取消按钮点不动用户直接判定「程序死了」。正确写法是把上传整体丢进Task.Run进度用IProgressT回报。ProgressT在构造时捕获当前的SynchronizationContextReport会自动BeginInvoke回 UI 线程所以你在回调里直接改控件属性是安全的不用手写Invoke。private CancellationTokenSource _cts; private async void btnUpload_Click(object sender, EventArgs e) { if (openFileDialog1.ShowDialog() ! DialogResult.OK) return; _cts new CancellationTokenSource(); btnUpload.Enabled false; progressBar1.Value 0; // ProgressT 在 UI 线程构造回调自动回到 UI 线程 var progress new ProgressUploadProgress(p { progressBar1.Value (int)(p.Percent * 100); lblStatus.Text ${p.CurrentFile} {p.Sent / 1024 / 1024}MB / {p.Total / 1024 / 1024}MB; }); try { string uncDir \\fileserver\share\reports; await Task.Run(() FileUploader.UploadAsync( openFileDialog1.FileName, uncDir, progress, _cts.Token)); lblStatus.Text 上传完成; } catch (OperationCanceledException) { lblStatus.Text 已取消; } catch (Exception ex) { // 不要只弹 ex.MessageWin32Exception 的 NativeErrorCode 才是关键 lblStatus.Text 上传失败 ex.Message; } finally { btnUpload.Enabled true; _cts.Dispose(); } }async void只允许出现在事件处理器上这是唯一合法场景await Task.Run把阻塞的 IO 移到线程池UI 线程立刻返回消息循环。finally里恢复按钮状态否则一次异常之后按钮永远是灰的。3.2 流式复制别再用File.Copy一把梭File.Copy在局域网小文件上没问题但它有三个硬伤没有进度、不能取消、失败后目标端留下半截文件。自己写流式复制三个问题一起解决。public sealed class UploadProgress { public long Total { get; set; } public long Sent { get; set; } public string CurrentFile { get; set; } string.Empty; public double Percent Total 0 ? 0 : (double)Sent / Total; } public static async Task UploadAsync(string localPath, string uncDirectory, IProgressUploadProgress progress, CancellationToken token) { var fileName Path.GetFileName(localPath); var targetPath Path.Combine(uncDirectory, fileName); var tempPath targetPath .part; // 先写临时名成功后改名 const int BufferSize 1024 * 1024; // 1MBSMB2 下这个块大小吞吐比较稳 var buffer new byte[BufferSize]; // 会进 LOH但只分配一次可接受 using (var source new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, FileOptions.SequentialScan)) using (var target new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, FileOptions.WriteThrough)) { long total source.Length, sent 0; int read; while ((read await source.ReadAsync(buffer, 0, buffer.Length, token)) 0) { token.ThrowIfCancellationRequested(); await target.WriteAsync(buffer, 0, read, token); sent read; progress?.Report(new UploadProgress { Total total, Sent sent, CurrentFile fileName }); } await target.FlushAsync(token); } // 同卷 Move 在 SMB 上是 rename 操作读端不会看到半个文件 if (File.Exists(targetPath)) File.Delete(targetPath); File.Move(tempPath, targetPath); }参数逐个说。BufferSize取 1MB小于 64KB 时单次 WRITE 太小、往返次数多吞吐上不去大于 4MB 在千兆网卡上收益已经不明显反而放大单次失败的重传代价1MB 是常见的折中值。FileOptions.SequentialScan告诉系统这是顺序读它会提前预读机械盘上提升明显。FileShare.Read允许别人同时读源文件避免「文件正被占用」目标端用FileShare.None因为传了一半的文件不该被任何人打开。FileOptions.WriteThrough是关掉写缓存直写安全但慢。如果共享盘本身有 UPS、链路稳定把它去掉吞吐会好一截如果是跨机房、链路抖保留它更稳。这是取舍不是对错。最后两行是「原子改名」代码里用的是 .NET Framework 写法因为File.Move的 overwrite 重载要 .NET Core 3.0 才有。.NET 6 直接File.Move(tempPath, targetPath, true)。3.3 取消、超时与「看起来卡住」CancellationToken的检查点要放在ReadAsync之后、WriteAsync之前。放在循环开头也行但那样用户点了取消要等当前这 1MB 写完才响应体感仍然卡。放中间是让取消在「读完一块、还没写」的空隙生效最大延迟就是一次块写入的时间。超时不是CancellationToken能解决的。SMB 的 IO 没有官方的 per-operation 超时 API网络断了之后WriteAsync可能挂几十秒才抛IOException。稳妥做法是在外层再套一层Task.WhenAny(uploadTask, Task.Delay(TimeSpan.FromMinutes(10), token))超时了就取消并提示「网络异常请重试」同时删掉目标端的.part文件。这个兜底逻辑不优雅但比让用户对着卡死的进度条干等强。另外进度回调的频率要控制。1MB 一块一个 500MB 的文件就是 500 次Report每次都要BeginInvoke到 UI 线程消息队列会被刷屏。常见做法是加个时间戳两次回调间隔小于 100ms 就跳过只更新字节数不刷新界面。4. 带凭据访问WNetAddConnection2封装与配置落地4.1 为什么net use能通、程序里却拒绝访问这是现场最高频的误解。运维在服务器上net use测试通过就认为「权限没问题」结果客户端一跑就报拒绝访问。原因在于net use是在你的交互式登录会话里建的连接而 WinForm 在客户机上可能以另一个本地账号运行甚至以计划任务的服务账户运行那是完全独立的登录会话会话表是空的。同一个登录会话内的进程共享内核里的凭据缓存所以「我先在资源管理器里输过密码然后程序就能跑了」这种现象是真的但它不可交付。客户换台机器、换个账号、重启一次连接就没了。结论很硬上传功能自己负责建连接不能依赖任何外部预先建立的会话。4.2 P/Invoke 封装与生命周期管理WNetAddConnection2在 mpr.dll 里签名不复杂麻烦的是结构体和标志位。[StructLayout(LayoutKind.Sequential)] internal struct NETRESOURCE { public int dwScope; public int dwType; // 1 RESOURCETYPE_DISK public int dwDisplayType; public int dwUsage; public string lpLocalName; // 留 null 表示不做盘符映射 public string lpRemoteName; // \\fileserver\share public string lpComment; public string lpProvider; } internal static class NativeNet { [DllImport(mpr.dll, CharSet CharSet.Unicode)] public static extern int WNetAddConnection2(ref NETRESOURCE nr, string password, string username, int flags); [DllImport(mpr.dll, CharSet CharSet.Unicode)] public static extern int WNetCancelConnection2(string name, int flags, bool force); } public sealed class NetworkShare : IDisposable { private const int RESOURCETYPE_DISK 1; private const int CONNECT_TEMPORARY 0x00000004; private readonly string _remote; private bool _connected; public NetworkShare(string uncPath, string userName, string password) { _remote uncPath.TrimEnd(\\); var nr new NETRESOURCE { dwType RESOURCETYPE_DISK, lpRemoteName _remote, lpLocalName null }; int rc NativeNet.WNetAddConnection2(ref nr, password, userName, CONNECT_TEMPORARY); if (rc ! 0) { // 1219 单独提示运维一看就懂 string hint rc 1219 ? 该服务器已有其他账号的连接请先断开或重启客户端 : 连接共享失败; throw new IOException(${hint}错误码 {rc}); } _connected true; } public void Dispose() { if (!_connected) return; NativeNet.WNetCancelConnection2(_remote, 0, true); _connected false; } }几个参数值得单独讲。dwType 1是磁盘资源写错成 0 会返回 87参数错误。lpLocalName null表示不映射盘符——一旦映射了盘符这个盘符只对当前登录会话有效其他进程照样看不到反而增加清理负担。flags用CONNECT_TEMPORARY0x4而不是 00 表示持久连接会在用户配置文件里留下重连记录客户机重启后自动重连凭据过期了就是一个莫名其妙的失败源。临时连接在会话结束或进程退出时自动清理更干净。Dispose里用force true是为了在还有句柄打开的情况下也能断开代价是可能中断别人的操作。如果上传是在独立进程里跑安全如果是共享进程谨慎。我一般在上传方法的finally里释放不给它泄漏的机会。userName的三种格式必须区分清楚DOMAIN\user走域认证fileserver\user走那台机器上的本地账户.\user走本机账户。写错了不会报「格式错误」只会返回 1326让你误以为密码打错了。4.3 把凭据放进配置DPAPI 加密与启动预检明文密码写在app.config里是能过编译、过不了验收的写法。常见做法是用 DPAPI 的ProtectedData作用域选CurrentUser——同一台机器上换个账号都解不开比明文强得多。// 写入安装或首次配置时执行一次 var cipher ProtectedData.Protect( Encoding.UTF8.GetBytes(txtPassword.Text), null, DataProtectionScope.CurrentUser); File.WriteAllText(C:\ProgramData\App\upload.dat, Convert.ToBase64String(cipher)); // 读取 var raw Convert.FromBase64String(File.ReadAllText(C:\ProgramData\App\upload.dat)); var password Encoding.UTF8.GetString( ProtectedData.Protect(raw, null, DataProtectionScope.CurrentUser) is var _ ? raw : raw);上面读取那段是我故意写残的示意正确的是ProtectedData.Unprotect(raw, null, DataProtectionScope.CurrentUser)。参数里的null是可选熵值加上它可以让别的程序即使同账号也解不开一般填一个固定的字节数组常量。配置项至少要有四个RemoteRootUNC 根、UserName、加密后的密码文件路径、ProbeOnStartup。然后做一个启动预检应用启动时在后台线程建一次连接、写一个_probe_机器名.tmp文件、删掉、断开。失败就把主界面上的上传按钮置灰并给出错误码。这个动作 200 毫秒但能把「用户点了上传才报错」变成「一进来就知道环境不通」省掉大量沟通成本。5. 避坑清单上线后最容易撞的六类现场5.1 拒绝访问、多重连接与「目录不存在」的假象现象一Directory.Exists(\\fileserver\share\reports)返回 false但目录明明存在。原因很反直觉当进程对共享没有访问权限时Directory.Exists不会抛异常它直接返回 false把「没权限」和「不存在」糊成一个结果。你按「目录不存在」去建目录再收到一个拒绝访问就彻底糊涂了。解决方式是永远不要用Directory.Exists做权限判断改成显式尝试写探针文件把UnauthorizedAccessException单独捕获并提示「共享无写权限」。现象二报 1219提示「不允许一个用户使用一个以上用户名与一个服务器或共享资源的多重连接」。原因是当前登录会话里已经有一条到这台服务器的会话属于另一个账号。常见触发顺序是运维先用管理员账号net use测过没删你的程序再用上传账号连。解决办法有两条一是上线前无条件执行一次WNetCancelConnection2清场注意可能影响同会话的其他程序二是承认这个限制、在错误提示里直接告诉用户「请重启客户端或断开已有连接」。我的选择是先清场再连失败再给提示。现象三错误码 1326 与 86 混着出现。1326 是「登录失败用户名或密码不正确」86 是「指定的网络密码不正确」。两个都指向凭据但前者多半是用户名格式写错少写了DOMAIN\后者多半是密码里含有特殊字符在配置文件里被转义吃掉了。密码里带、、%时建议直接改用文件存 DPAPI 密文不要走 XML 属性。5.2 大文件中断、超时与共享盘「休眠」现象四小文件正常超过 500MB 的文件传到一半报 64「指定的网络名不再可用」重试又好了。这通常不是代码问题。一是网络设备或防火墙对长连接有会话老化时间空闲几十秒就掐二是目标端是做了休眠策略的 NAS磁盘休眠唤醒期间 IO 挂起客户端等不及就断了。解决上分两层应用层做重试捕获IOException后最多重试 3 次每次间隔递增2s、4s、8s并且从断点续传而不是从头传否则 1GB 文件重试三次等于传三遍环境层推动运维把共享端休眠关掉或者把心跳包间隔压到小于老化时间。现象五进度条走到 100%但文件大小是 0 或者不完整。这是没做 flush 就关流的典型症状。FileStream的Close/Dispose通常会 flush但如果用了BufferedStream且外层提前异常缓冲区里的数据就丢了。解决就是显式await target.FlushAsync(token)再进using的释放流程顺序不能反。同时保留.part后缀让任何人都能一眼看出这是半成品。5.3 文件名、长路径与临时占用现象六路径总长超过 260 字符就抛PathTooLongException或者中文文件名在共享端变成乱码。长度问题在 .NET Framework 上尤其顽固光加\\?\UNC\前缀不够还要在app.config里开长路径开关并且在应用清单里声明longPathAware三件事缺一不可configuration runtime AppContextSwitchOverrides valueSwitch.System.IO.UseLegacyPathHandlingfalse;Switch.System.IO.BlockLongPathsfalse / /runtime /configuration乱码则基本不是编码问题而是文件名里带了 Windows 不允许的字符\ / : * ? |或者尾部是空格、点号这类名字在本地能建写到 SMB 上被规范化后就成了另一个名字。上传前统一做一次文件名清洗并且显式拒绝CON、PRN、AUX、NUL这些保留名——它们即便加了扩展名在部分共享端仍然建不出来。还有一类不在代码里目标端的杀毒软件或同步客户端会短暂锁住刚写完的文件导致File.Move抛IOException「文件正被另一进程使用」。解决办法是给改名动作加一个小重试3 次每次 200ms比一失败就报错体面得多。6. 进阶把「传过去」变成可验证的上传6.1 边写边算哈希落地后再核一次文件「传过去了」和「传对了」是两件事。千兆内网也有静默损坏的概率跨公网链路更高。稳妥做法是在写入的同时用CryptoStream或增量HashAlgorithm算一遍 SHA256传完再从共享端读回来算一遍对比。注意读回校验会把 IO 翻倍大文件建议只在前 N 次上传或抽样时做。// 写的同时算哈希避免二次读源文件 using var sha SHA256.Create(); using var crypto new CryptoStream(Stream.Null, sha, CryptoStreamMode.Write); int read; while ((read await source.ReadAsync(buffer, 0, buffer.Length, token)) 0) { await target.WriteAsync(buffer, 0, read, token); crypto.Write(buffer, 0, read); // 同一份字节喂给哈希 sent read; } crypto.FlushFinalBlock(); string localHash Convert.ToHexString(sha.Hash); // .NET Framework 用 BitConverter.ToStringCryptoStream挂到Stream.Null上是刻意的它只负责吞字节算摘要不落盘、不额外占空间。FlushFinalBlock必须调用否则最后一块不进哈希算出来的值永远对不上——这个坑我踩过一次查了两个小时才发现是少了一行。6.2 一个能自证的日志格式上传失败最难查的是「到底卡在哪一步」。日志里至少要有五个字段时间、源文件全路径、目标 UNC 全路径、当前阶段连接 / 写入 / 改名 / 校验、错误码。阶段字段是关键它把「拒绝访问」定位到「连接阶段」还是「改名阶段」指向的排查方向完全不同。阶段典型错误码该找谁连接53 / 64 / 67 / 1219 / 1326网络或账号运维写入5 / 39 / 112共享 ACL、磁盘满改名32 / 5目标端占用、权限继承校验无错误码哈希不一致链路质量、中间设备6.3 上线前我会强制跑的三条检查第一条断网重连。上传到一半拔网线确认程序在 10 分钟内给出明确失败提示.part文件被清理且重试能从头再来不报 1219。第二条换账号跑。用另一个本地账号登录同一台机器跑一遍上传验证程序不依赖当前用户的任何会话缓存。第三条满盘与只读。把共享目录临时设成只读确认提示是「无写权限」而不是「目录不存在」。这三条每次发版前跑一遍五分钟能挡掉绝大多数的现场工单。从那以后我每次交付这类上传功能都会在启动时强制走一遍「建连 → 写探针 → 删探针 → 断开」的预检宁可让用户一进来就看到红字也不让他在点了上传之后对着进度条猜。希望帮到你。本文还有配套的精品资源点击获取