ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WinForm 上传共享文件夹:SMB 凭据、流式复制与 1219 排错

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 直接复用。

这解释了两个最常见的困惑。第一,为什么开发机上一切正常——你在资源管理器里手输过一次密码,会话已经被建好了,Ctrl+F5 调试的进程正好蹭上了这个会话。第二,为什么两个不同账号往同一台服务器传文件会报 1219,因为内核发现「这台服务器已经有一条属于别人的会话了」,它拒绝在同一条会话上叠加第二套凭据。

提示:判断当前有没有活着的会话,用net use看列表,或者Get-SmbConnection看客户端侧连接明细,比猜快得多。

2.2 三种身份方案,先选型再写代码

WinForm 上传到共享文件夹,本质上是在回答「以谁的身份写」这个问题,只有三条路:

方案实现方式适用场景主要代价
当前登录用户不写任何代码,直接写 UNC 路径域环境,且登录账号已被授予共享写权限换机器、换账号就失效,运维无法自证
显式凭据建会话WNetAddConnection2(mpr.dll)工作组环境、NAS、客户只给一个专用上传账号需要处理多重连接冲突与连接释放
登录令牌模拟LogonUser+WindowsIdentity.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 "P@ssw0rd" # 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,进度用IProgress<T>回报。Progress<T>在构造时捕获当前的SynchronizationContext,Report会自动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; // Progress<T> 在 UI 线程构造,回调自动回到 UI 线程 var progress = new Progress<UploadProgress>(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.Message,Win32Exception 的 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, IProgress<UploadProgress> progress, CancellationToken token) { var fileName = Path.GetFileName(localPath); var targetPath = Path.Combine(uncDirectory, fileName); var tempPath = targetPath + ".part"; // 先写临时名,成功后改名 const int BufferSize = 1024 * 1024; // 1MB,SMB2 下这个块大小吞吐比较稳 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_TEMPORARY(0x4)而不是 0:0 表示持久连接,会在用户配置文件里留下重连记录,客户机重启后自动重连,凭据过期了就是一个莫名其妙的失败源。临时连接在会话结束或进程退出时自动清理,更干净。

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是可选熵值,加上它可以让别的程序即使同账号也解不开,一般填一个固定的字节数组常量。

配置项至少要有四个:RemoteRoot(UNC 根)、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 value="Switch.System.IO.UseLegacyPathHandling=false;Switch.System.IO.BlockLongPaths=false" /> </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.ToString

CryptoStream挂到Stream.Null上是刻意的:它只负责吞字节算摘要,不落盘、不额外占空间。FlushFinalBlock必须调用,否则最后一块不进哈希,算出来的值永远对不上——这个坑我踩过一次,查了两个小时才发现是少了一行。

6.2 一个能自证的日志格式

上传失败最难查的是「到底卡在哪一步」。日志里至少要有五个字段:时间、源文件全路径、目标 UNC 全路径、当前阶段(连接 / 写入 / 改名 / 校验)、错误码。阶段字段是关键,它把「拒绝访问」定位到「连接阶段」还是「改名阶段」,指向的排查方向完全不同。

阶段典型错误码该找谁
连接53 / 64 / 67 / 1219 / 1326网络或账号,运维
写入5 / 39 / 112共享 ACL、磁盘满
改名32 / 5目标端占用、权限继承
校验无错误码,哈希不一致链路质量、中间设备

6.3 上线前我会强制跑的三条检查

第一条:断网重连。上传到一半拔网线,确认程序在 10 分钟内给出明确失败提示,.part文件被清理,且重试能从头再来不报 1219。第二条:换账号跑。用另一个本地账号登录同一台机器,跑一遍上传,验证程序不依赖当前用户的任何会话缓存。第三条:满盘与只读。把共享目录临时设成只读,确认提示是「无写权限」而不是「目录不存在」。这三条每次发版前跑一遍,五分钟,能挡掉绝大多数的现场工单。

从那以后我每次交付这类上传功能,都会在启动时强制走一遍「建连 → 写探针 → 删探针 → 断开」的预检,宁可让用户一进来就看到红字,也不让他在点了上传之后对着进度条猜。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进