简介:这是一份面向C# WinForm开发者的共享文件夹上传实践示例,聚焦局域网内将本地文件写入网络共享路径的典型需求,适合需要为内部传输工具搭建原型或补齐网络文件操作经验的开发者参考。压缩包共23个文件、约36KB,以6个cs源代码文件为核心,配有sln解决方案、csproj项目文件、Form1界面与resx资源文件,以及exe、pdb等编译调试产物,打开后可直接对照窗体逻辑和后台实现。项目基于VS2010,围绕OpenFileDialog选择文件、NetworkCredential建立共享访问凭据、FileStream与网络流读写、BackgroundWorker更新上传进度、异常提示及文件类型和大小校验等环节展开,较完整地呈现了文件操作、网络编程与UI交互的组合方式。已有1429人学习,可从中梳理共享目录上传流程、资源释放与错误处理思路。
1. 从「把文件拖进共享盘」说起:WinForm 直传 UNC 路径能省掉什么
内网办公里有一类很土但很常见的需求:上传文件的终点不是对象存储,而是一台文件服务器上的共享目录。用户每天打开资源管理器,敲\\192.168.1.20\docs,把报表拖进去,一天重复几十遍。业务系统要接管这件事,就绕不开一个问题:WinForm 客户端怎么在不弹资源管理器、不手动映射盘符的前提下,把文件上传到共享文件夹,还要让用户看得到进度、让运维拿得到失败原因。这就是「文件上传到服务器」里成本最低的一条路径——不经过 HTTP 接口,直接走 SMB 写 UNC 路径。
它适合手里已经有文件服务器、又不想为一次上传专门搭一套后端接口的团队。省掉的是服务部署和鉴权代码,付出的是必须直面网络、权限、凭据这些细节。常见做法是先在客户端把通路验证通,再谈界面;顺序反了,后面全是玄学问题。下面按「通路 → 骨架 → 凭据 → 排错 → 收口」的顺序拆,代码基于 .NET 6+ 的 WinForms,.NET Framework 4.8 下的差异我会单独点出来。
2. 先把通路打通:UNC 路径、共享权限与 NTFS 权限的两道门
2.1 UNC 路径、445 端口与名称解析
UNC 的格式是\\主机\共享名\子目录\文件名,主机部分可以写 IP,也可以写主机名。写 IP 少一次名称解析,排错时更干净;写主机名在服务器换 IP 时不用改客户端配置。两者没有对错,我一般在测试阶段用 IP,上线配置里用主机名,并且要求运维把 DNS 记录或 hosts 记录固定下来。
SMB 走 TCP 445,更老的 SMB1 还会用 139。现在主流文件服务器普遍禁用了 SMB1,所以客户端机器只要能连 445 就够了。判断「网络通不通」和「共享能不能访问」是两件事,很多人一上来就在代码里试写文件,异常一抛就开始猜,效率很低。我习惯先用两条命令把范围缩小:
# 1. 只测端口:能通说明路由、防火墙这一层没问题 Test-NetConnection -ComputerName 192.168.1.20 -Port 445 # 2. 再看当前机器上已建立的 SMB 会话和连接 net useTest-NetConnection的-Port参数会发起一次 TCP 握手,返回TcpTestSucceeded : True才算通。如果这一条就失败,后面所有代码都不用看了,直接找网络或安全软件。net use输出里如果已经有指向同一台服务器的连接,注意看它用的是哪个账号——凭据冲突是后面第 5 章要讲的第一个坑。
还有一条更接近真实访问的验证方式,就是用net use带凭据临时挂一个连接,用完立刻删掉:
# 建立临时会话,/persistent:no 表示不写进注册表,重启后不保留 net use \\192.168.1.20\docs /user:svc_upload "P@ssw0rd" /persistent:no # 验证完主动断开,避免残留会话干扰后续测试 net use \\192.168.1.20\docs /delete/user:后面跟账号,工作组环境直接写账号名,域环境写域\账号。/persistent:no这个参数建议测试时一律带上,否则连接会被记到当前用户的凭据列表里,下次换账号测试就报 1219 冲突,白白浪费半小时。
2.2 共享权限与 NTFS 权限:两层权限要同时放行
这是新手最容易翻车的地方:资源管理器里明明能看到文件夹,代码写文件却报「拒绝访问」。原因通常是只看了其中一层权限。Windows 的文件访问最终权限是共享权限和 NTFS 权限的交集,取更严格的那一方。
| 层级 | 配置入口 | 作用范围 | 常见误配 |
|---|---|---|---|
| 共享权限 | 文件夹属性 → 共享 → 高级共享 → 权限 | 仅影响通过网络访问的用户 | 只给了「读取」,写入被拒 |
| NTFS 权限 | 文件夹属性 → 安全 → 编辑 | 本地登录和网络访问都生效 | 账号没有「修改」或「写入」 |
举个典型的组合:共享权限给了Everyone完全控制,NTFS 里svc_upload只有「读取和执行」,结果就是能列目录、不能建文件,异常是UnauthorizedAccessException。反过来,NTFS 给了「修改」但共享权限只给「读取」,异常还是拒绝访问。所以排权限问题时,两层都要看,别只盯着安全选项卡。
另外一个容易忽略的点:写入权限不仅要落在目标目录上,如果代码里用了「先写临时文件再改名」的方案,改名操作需要目标目录的「删除」权限(重命名本质是先删除旧目录项)。这一点在第 6 章会再用到。
2.3 先用一个最小探针跑通,再写界面
界面是最后一步。我一般的顺序是:控制台或者一个隐藏的调试按钮里先跑通一次纯写入,确认网络、共享、NTFS 三层全部放行,再往上叠 UI。探针代码可以很短:
// 最小写入探针:能跑通说明三层权限都过了,失败时按异常类型定位 var target = @"\\192.168.1.20\docs\_probe.txt"; try { File.WriteAllText(target, $"probe {DateTime.Now:O}"); var info = new FileInfo(target); Console.WriteLine($"写入成功,长度={info.Length},路径={info.FullName}"); } catch (DirectoryNotFoundException ex) { // 主机不可达、共享名拼错、子目录不存在,都归到这里 Console.WriteLine("路径或主机有问题:" + ex.Message); } catch (UnauthorizedAccessException ex) { // 共享权限或 NTFS 权限没放行,也可能是凭据用错了账号 Console.WriteLine("权限不足:" + ex.Message); } catch (IOException ex) { // 网络中途断开、磁盘满、文件被占用 Console.WriteLine("IO 异常:" + ex.Message); }这段代码的价值不在功能,在于把异常类型和排查方向对应起来。DirectoryNotFoundException往上查主机和共享名,UnauthorizedAccessException往上查两层权限和当前进程身份,IOException往上查网络和磁盘。有了这张映射表,后面界面里抛出来的异常才有地方落。
3. 代码骨架:OpenFileDialog 选文件、分块复制与进度条
3.1 界面状态机:三个控件和一条 async void 的边界
界面上真正需要的控件很少:一个「选择并上传」按钮、一个进度条、一个状态标签,再加一个取消按钮。状态只有四个:空闲、选择中、上传中、结束(成功或失败)。上传中要禁用按钮,否则用户连点两下会起两个复制任务写同一个目标文件。
WinForm 里事件处理器用async void是允许的,但必须是「只在事件处理器里用」,并且整个方法体要包在 try/catch 里,异常漏出去就是进程级未处理异常。示例:
private CancellationTokenSource? _cts; private async void btnUpload_Click(object sender, EventArgs e) { using (var dialog = new OpenFileDialog()) { dialog.Title = "选择要上传的文件"; dialog.Filter = "所有文件 (*.*)|*.*"; dialog.Multiselect = false; dialog.CheckFileExists = true; if (dialog.ShowDialog(this) != DialogResult.OK) return; _cts = new CancellationTokenSource(); btnUpload.Enabled = false; progressBar.Value = 0; try { // Progress<T> 必须在 UI 线程构造,回调才会自动切回 UI 线程 var progress = new Progress<int>(p => progressBar.Value = p); var target = BuildTargetPath(@"\\192.168.1.20\docs", dialog.FileName); await CopyToShareAsync(dialog.FileName, target, progress, _cts.Token); lblStatus.Text = "上传完成:" + target; } catch (OperationCanceledException) { lblStatus.Text = "已取消"; } catch (Exception ex) { lblStatus.Text = "失败:" + ex.Message; // 这里落日志,记录源路径、目标路径、异常类型,别只弹 MessageBox } finally { btnUpload.Enabled = true; _cts.Dispose(); _cts = null; } } }Progress<int>的构造位置是关键:它在 UI 线程创建时捕获SynchronizationContext,Report调用会被投递回 UI 线程;如果放到后台线程里 new,回调就在后台线程执行,改控件直接抛跨线程异常。CancellationTokenSource每次上传新建一个,finally 里释放,不要复用一个全局实例,取消过一次之后它就一直处于取消状态。
3.2 分块复制:为什么不用 File.Copy
File.Copy一行就能完成上传,但它没有进度、没有取消、超时也没法中断。要进度就得自己读自己写。缓冲区大小是要调的参数,我一般用 1MB:
private async Task CopyToShareAsync(string sourcePath, string targetPath, IProgress<int> progress, CancellationToken token) { const int bufferSize = 1024 * 1024; // 1MB,SMB 下单次写太小会放大往返开销 var buffer = new byte[bufferSize]; using (var src = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true)) using (var dst = new FileStream(targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize, useAsync: true)) { long total = src.Length; long copied = 0; int read; while ((read = await src.ReadAsync(buffer, 0, buffer.Length, token)) > 0) { await dst.WriteAsync(buffer, 0, read, token); copied += read; // 小文件除零保护 progress.Report(total == 0 ? 100 : (int)(copied * 100 / total)); } await dst.FlushAsync(token); } }几个参数值得说清楚。bufferSize取 4KB 时,一个 100MB 文件要发起两万多次写请求,每次都是一次网络往返,速度会明显掉下来;取 1MB 到 4MB 之间是比较常见的区间,再大对吞吐提升有限,反而占内存。useAsync: true让ReadAsync/WriteAsync走真正的异步 IO,不占线程池线程,界面才不会卡。源文件的FileShare.Read允许别的程序同时读这个文件,避免用户刚在 Excel 里打开报表就传不了;目标文件的FileShare.None表示写入期间独占,防止别的进程读到半截内容。FileMode.CreateNew是刻意选的——目标已存在时直接抛异常,而不是静默覆盖,覆盖策略放到BuildTargetPath里显式处理。
FlushAsync不能省。缓冲区里的数据没落盘就返回成功,用户看到 100% 但服务器上文件是残的,这种问题排查起来很费时间。
3.3 目标文件名:防覆盖和防路径穿越
客户端传上来的文件名不可信。哪怕文件是用户自己选的,也要当成外部输入处理:先剥掉目录成分,再过滤非法字符,最后处理重名。
private static string BuildTargetPath(string shareDir, string localPath) { // 只保留文件名,丢掉任何目录成分,防止 ..\..\ 之类的路径穿越 var name = Path.GetFileName(localPath); // 过滤 Windows 文件名非法字符(: * ? " < > | 等) foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); var stem = Path.GetFileNameWithoutExtension(name); var ext = Path.GetExtension(name); var candidate = Path.Combine(shareDir, name); // 重名就加序号,最多试 1000 次 for (int i = 1; File.Exists(candidate) && i < 1000; i++) candidate = Path.Combine(shareDir, $"{stem}_{i}{ext}"); return candidate; }Path.GetFileName这一步不能省,它把C:\temp\..\..\windows\system32\x.dll这类输入压成纯文件名。Path.GetInvalidFileNameChars返回的是当前平台相关的字符集,中文文件名本身是合法的,不需要额外编码转换,乱码问题一般出在服务器端的区域设置或者日志写入环节,不在文件系统 API 这一层。
File.Exists在 UNC 路径上每次都要一次网络往返,文件多的时候会明显变慢,而且它和后面的创建之间不是原子的,多客户端并发时有竞态。单机单用户场景够用;并发写入同一目录时,更稳的做法是让服务端分配唯一文件名,或者用时间戳加 GUID 前缀。
4. 凭据这道坎:WNetAddConnection2 与 LogonUser 模拟的取舍
4.1 明文账号密码为什么不能进配置文件
最常见的做法是把共享账号密码写进appsettings.json或者app.config,程序启动读出来直接用。这在演示环境没问题,交付现场就是隐患:配置文件跟着安装包发到每台机器上,任何一个能打开安装目录的人都能看到密码。域环境下的替代方案是让程序以某个域账号运行(计划任务、服务账号),由系统自动带上凭据;工作组环境没有这套机制,就只能显式传凭据,但至少要把它放在受控位置,比如安装时由管理员录入、加密后存到本机凭据管理器,而不是明文躺在 exe 旁边。
这一节讲两种显式带凭据的方式,各有适用边界,不是谁替代谁。
4.2 WNetAddConnection2:给整个进程建立一条会话
这是 Windows 网络重定向器的原生接口,效果等价于net use。它建立的是进程级的连接,建好之后,同一进程里所有对\\192.168.1.20\docs的访问都会走这个凭据。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private class NetResource { public int Scope; public int Type = 1; // RESOURCETYPE_DISK,固定值 public int DisplayType; public int Usage; public string? LocalName; // 不映射盘符就留 null public string? RemoteName; // \\192.168.1.20\docs public string? Comment; public string? Provider; } [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetAddConnection2(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);调用时要处理 1219 这个错误码,它表示本机已经存在指向同一服务器但凭据不同的会话。正确做法是先断开再重连,而不是直接报错给用户:
const int ERROR_SUCCESS = 0; const int ERROR_SESSION_CREDENTIAL_CONFLICT = 1219; var nr = new NetResource { RemoteName = @"\\192.168.1.20\docs" }; int rc = WNetAddConnection2(nr, password, userName, 0); if (rc == ERROR_SESSION_CREDENTIAL_CONFLICT) { // 旧会话用的是别的账号,先强制断开再重来 WNetCancelConnection2(nr.RemoteName!, 0, true); rc = WNetAddConnection2(nr, password, userName, 0); } if (rc != ERROR_SUCCESS) throw new Win32Exception(rc); // 把错误码转成可读消息,日志里记原始码dwFlags传 0 表示不把连接写进用户配置文件,进程退出后会话失效,适合「用完就走」的客户端。如果传CONNECT_UPDATE_PROFILE(值为 1),连接会被持久化,下一台用这台机器的人可能莫名其妙地继承上一个人的权限,不建议在客户端程序里用。
WNetAddConnection2的边界是:连接在整个进程生命周期内有效,多个上传任务共享同一凭据。如果程序需要针对不同共享用不同账号,或者同一共享在不同时刻切换账号,就会频繁撞上 1219,管理成本上来了。
4.3 LogonUser + Impersonate:只对当前线程生效的凭据切换
另一种做法是登录一个令牌,在代码块内模拟该身份,出了这个块就恢复原身份。关键参数是登录类型,必须用LOGON32_LOGON_NEW_CREDENTIALS(值为 9),它只替换网络访问用的凭据,不改变本地身份:
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool LogonUser(string lpszUsername, string? lpszDomain, string lpszPassword, int dwLogonType, int dwLogonProvider, out SafeAccessTokenHandle phToken); private const int LOGON32_LOGON_NEW_CREDENTIALS = 9; // 只替换网络凭据 private const int LOGON32_PROVIDER_DEFAULT = 0; public static void RunAs(string user, string? domain, string password, Action action) { if (!LogonUser(user, domain, password, LOGON32_LOGON_NEW_CREDENTIALS, LOGON32_PROVIDER_DEFAULT, out var token)) throw new Win32Exception(Marshal.GetLastWin32Error()); using (token) using (WindowsIdentity.Impersonate(token.DangerousGetHandle())) { action(); // 这段里对 UNC 的访问走指定凭据 } }用LOGON32_LOGON_INTERACTIVE(值 2)也能登录成功,但它要求账号有本地登录权限,服务账号通常没有,会报 1385 错误。这是选型上最常见的误用。
更隐蔽的坑是:Impersonate只对当前线程有效,而await之后续体可能跑在另一个线程池线程上。所以模拟块里不能有await,一旦跨过await,身份就丢了:
// 错误写法:await 之后线程可能换了,模拟身份丢失,写文件报拒绝访问 RunAs(user, domain, pwd, async () => await CopyToShareAsync(src, dst, progress, token)); // 正确写法:模拟块内只做同步工作,或者把整个异步任务包在块外 RunAs(user, domain, pwd, () => File.Copy(src, dst, overwrite: false));如果一定要用异步复制,可以把「建立会话」和「复制」拆开:外层用WNetAddConnection2建会话,内层用异步复制,这样不受线程切换影响。两种方式的选择标准很简单——需要跨多个共享、需要随时切换账号,用模拟令牌;一个客户端固定访问一台服务器的一个共享,用WNetAddConnection2更省心。
5. 五个翻车现场:从「拒绝访问」到进度条卡在 99% 的排查表
5.1 开发机能传,客户机报「找不到网络路径」
现象:同一个安装包,在开发机上一切正常,装到客户现场第一次上传就抛DirectoryNotFoundException,消息是「找不到网络路径」。
原因:客户机的安全软件或系统防火墙拦了 445 出站,或者目标服务器禁用了 SMB1 而客户端还在按 SMB1 协商。开发机通常早就放行过,所以复现不出来。
解决:先在客户机上跑Test-NetConnection -ComputerName <主机> -Port 445,不通就找网络负责人放行;通了还报错,用Get-SmbConnection看协商到的 SMB 版本,确认服务器没有强制要求 SMB1。把这条检查写进部署文档,比事后远程排错省事得多。
5.2 资源管理器能进,程序报「拒绝访问」
现象:用资源管理器输入账号密码能正常读写,程序里同样的路径就抛UnauthorizedAccessException。
原因:资源管理器的凭据是登录用户手动输入并缓存的,程序进程跑在另一个身份下(计划任务、服务、或者以管理员身份启动的 exe),两者不是同一套凭据。另一种情况是本机已有指向同一服务器的旧会话,凭据冲突返回 1219,被上层包装成拒绝访问。
解决:先跑net use看有没有残留会话,有就先/delete;然后用WNetAddConnection2显式带凭据,或者用模拟令牌。判断当前进程身份可以直接在异常日志里打WindowsIdentity.GetCurrent().Name,这一行信息能省掉很多来回。
5.3 进度条到 100%,服务器上的文件是残的
现象:界面上显示上传完成,去服务器一看文件大小不对,或者下游程序读到半截 CSV 直接报解析错误。
原因:一是缓冲区没刷盘就返回成功,二是下游程序监听目录,文件刚创建就被读走。前者是代码问题,后者是流程问题。
解决:代码侧加FlushAsync并确保FileStream走Dispose;流程侧改成「先写.part临时文件,校验通过再改名为正式文件名」,下游只处理正式文件名。改名操作在同一个共享目录内是原子的,成本很低。
5.4 中文文件名变成乱码或者直接报「文件名无效」
现象:文件名里带中文、空格、括号,上传后服务器上显示成问号,或者直接抛ArgumentException。
原因:文件名里混进了: * ? " < > |这类非法字符(常见于从邮件附件另存的文件名),或者日志和显示环节的编码没统一。文件系统 API 本身对中文是支持的,不需要做 GBK 转换。
解决:用Path.GetInvalidFileNameChars()统一替换成下划线,这一层放在客户端做,别指望服务器兜底。日志文件统一用 UTF-8 写入,避免在服务器上打开日志看到乱码又怀疑是上传问题。
5.5 大文件传到一半卡住,取消按钮也没反应
现象:几百 MB 的文件传了几分钟之后进度条不动,点取消也没立刻停,最后超时失败。
原因:用了同步 IO 或者缓冲区太小,UI 线程被占住,取消令牌根本没机会被检查;也可能是网络中间设备对长连接做了空闲超时。
解决:确认复制循环走的是ReadAsync/WriteAsync并且每次循环都传了CancellationToken;缓冲区调到 1MB 以上;在循环里加一个「连续 N 秒没有进展就主动断开重试」的保护。重试逻辑不要写在复制函数内部,放在调用层,配合第 6 章的断点续传一起用。
6. 收口:上传后校验、临时文件改名与断点续传
上传这个动作,「没抛异常」不等于「传对了」。我现在的习惯是每次上传都走三步:写临时文件、校验、改名。
// 1. 先写 .part,避免下游读到半截文件 var partPath = targetPath + ".part"; await CopyToShareAsync(sourcePath, partPath, progress, token); // 2. 对比源和目标的 SHA256,不一致就重传 var srcHash = await ComputeSha256Async(sourcePath, token); var dstHash = await ComputeSha256Async(partPath, token); if (!string.Equals(srcHash, dstHash, StringComparison.OrdinalIgnoreCase)) throw new IOException($"校验失败:{srcHash} != {dstHash}"); // 3. 校验通过再改名,下游只监听正式文件名 File.Move(partPath, targetPath, overwrite: false);ComputeSha256Async就是标准的流式哈希,分块读、边读边算,不要一次性File.ReadAllBytes,几百 MB 的文件会直接把内存打满。校验这一步的代价是多读一遍文件,在千兆内网里对 100MB 级别的文件大概多几百毫秒,换来的是「上传成功」这四个字能站得住。
断点续传也不复杂,SMB 支持随机写,记录已传字节数就能接着写:
// resumeOffset 来自上次失败时记录的已传字节数 using (var dst = new FileStream(partPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 1024 * 1024, useAsync: true)) { dst.Seek(resumeOffset, SeekOrigin.Begin); // 定位到断点位置继续写 // 后续循环从源文件的 resumeOffset 处开始读 }这里要注意,续传的前提是源文件没变。判断依据可以存源文件的长度加最后修改时间,两者任一变化就放弃续传、从头开始,否则拼出来的文件是坏的。这个判断我一般放在上传任务开始之前,和「选择文件」那一步绑在一起。
还有一个容易忽略的收尾:.part文件在上传失败后会留在共享目录里。要么在失败分支里主动删掉,要么在目录侧加一个定期清理任务。我见过共享盘被几百个孤儿.part文件占满的现场,运维查了半天才发现是客户端崩在改名之前。
从那以后,我每次做这类直传共享目录的功能,都会强制走一遍「探针写入 → 小文件全流程 → 大文件中断重试 → 校验比对」这四步,任何一步没过就不往界面里加功能。这套顺序看着慢,实际比在客户现场靠日志猜问题快得多。希望帮到你。
本文还有配套的精品资源,点击获取