WinForm上传共享文件夹实战:UNC、权限、分块与断点续传
简介这是一份面向C#桌面开发者的Winform文件上传实例聚焦局域网内将本地文件写入服务器共享文件夹这一常见需求可用于企业内部数据传递、文档归档等场景。示例以VS2010解决方案组织串联OpenFileDialog选取文件、NetworkCredential配合UNC路径建立访问凭据、FileStream与NetworkStream完成流式读写并用BackgroundWorker异步上报进度同时兼顾异常提示与文件类型、大小校验适合需要落地内网传输功能的初中级开发者参考。压缩包共23个文件、约36KB以6个cs源码和Form1界面资源为核心另含sln、csproj工程配置与exe、pdb等编译产物解压即可打开对照调试。目前已有1429人学习下载可借此理解共享目录读写、网络凭据与跨线程更新界面的配合方式并提炼权限不足、连接失败、文件不存在等场景的排错思路。1. 共享文件夹不是网盘WinForm 上传文件到共享文件夹先跨过 UNC 与权限两道门WinForm 上传文件到共享文件夹难点从来不在File.Copy那一行。真实场景是档案归集客户端选完本地文件点上传文件要落到内网文件服务器的共享目录里用户只看到一根进度条背后却要穿过 UNC 路径、SMB 会话、共享权限、NTFS 权限和 WinForm 的 UI 线程。文件上传到服务器在局域网里经常就是“写共享盘”它适合内部工具、桌面客户端、小型管理系统做附件归集不适合直接暴露到公网。这套 WinForm 上传模块把路径探测、凭据连接、分块进度、临时文件和断点续传拆成可替换的类拿来改服务器地址和凭据就能嵌进现有工程也方便逐段排查翻车点。2. 从 UNC 路径到凭据连接文件上传到共享文件夹的第一公里2.1 UNC 路径不是普通字符串共享权限与 NTFS 权限的叠加关系\\server\share\folder\file.txt这种 UNC 路径看起来只是字符串实际每次读写都在触发一次 SMB 会话。WinForm 进程默认拿当前 Windows 登录令牌去连接所以“同域账号能打开共享”和“这个客户端能写进去”是两件事。共享权限管的是“能不能通过这个共享入口进来”NTFS 权限管的是“进来之后能不能在这个目录创建、修改、删除文件”两者取交集。常见翻车是共享权限给了“完全控制”NTFS 只给了“读取和执行”资源管理器能浏览目录上传时却报拒绝访问。选型上同域环境优先直接用 UNC 路径少一层映射盘符少一个“提权后看不到映射盘”的玄学问题。需要不同凭据时常见做法是用WNetAddConnection2建立临时连接而不是在代码里拼net use命令。临时连接不持久化进程退出就释放适合 WinForm 客户端。这里有一条血泪经验不要用管理员身份运行客户端去测试普通用户的上传UAC 提权后网络会话可能换了一套令牌原本能写的目录突然写不进去排查半天以为是权限配置错了。上传前先确认三件事UNC 目录是否存在、当前进程能否创建文件、目标文件是否被占用。Directory.Exists只回答第一件后两件必须用探针文件实测。探针文件不要用固定名字多个客户端同时启动会互相覆盖也不要把探针文件留在业务目录里异常退出后会被业务扫描当成脏数据。我一般用 GUID 命名写完立刻Flush(true)再在finally里删除。提示共享目录不要给Everyone完全控制按业务账号或用户组授权客户端也不要把密码写进配置文件明文能走域账号就走域账号。2.2 用 WNetAddConnection2 建立临时凭据连接不同凭据的场景很常见客户端跑在普通办公账号下共享盘只允许文件归集账号写入。这时可以在上传前建立临时连接上传结束释放。下面这段代码只做连接管理不负责上传目的是把“认证”和“复制”分开出问题时能快速判断是连不上还是写不进。using System; using System.ComponentModel; using System.Runtime.InteropServices; public sealed class UncConnection : IDisposable { private readonly string _remoteName; private bool _connected; [StructLayout(LayoutKind.Sequential, CharSet CharSet.Unicode)] private class NETRESOURCE { public int dwScope; public int dwType; public int dwDisplayType; public int dwUsage; public string lpLocalName; public string lpRemoteName; public string lpComment; public string lpProvider; } [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); private const int RESOURCETYPE_DISK 0x00000001; private const int CONNECT_TEMPORARY 0x00000004; public UncConnection(string remoteName, string username, string password) { _remoteName remoteName; var nr new NETRESOURCE { dwType RESOURCETYPE_DISK, lpRemoteName remoteName }; int code WNetAddConnection2(nr, password, username, CONNECT_TEMPORARY); if (code ! 0) { throw new Win32Exception(code, $连接共享失败错误码 {code}); } _connected true; } public void Dispose() { if (!_connected) return; WNetCancelConnection2(_remoteName, 0, true); _connected false; } }这段代码调用mpr.dll的WNetAddConnection2把共享路径作为远程资源传进去CONNECT_TEMPORARY表示临时连接不写入用户持久凭据。remoteName传共享根路径例如\\server\share不要传文件全路径username可以是domain\user或server\user取决于共享所在环境password只在调用瞬间使用不要拼进日志。Dispose里用WNetCancelConnection2释放连接fForce传true是为了避免句柄未释放时取消失败。错误码要会看53 通常是找不到网络路径先查服务器名、DNS 和共享名67 是找不到网络名多半共享名拼错1219 是同一服务器已有不同用户名连接先取消旧连接或改用同一个账号1326 是用户名或密码错误检查账号格式和密码状态5 是拒绝访问回到共享权限和 NTFS 权限。不要把所有错误都提示成“网络异常”用户看到这句话没法配合排查。2.3 上传前做一次可写探测不只看 Directory.Exists路径存在不代表能写能写不代表目标文件没被占用。上传前做一次创建、写入、删除的探针能提前暴露大部分权限问题和杀软拦截问题。探针文件放在目标目录下不要放到共享根目录避免污染其他业务。using System; using System.IO; public static class ShareProbe { public static void EnsureWritable(string uncDirectory) { if (!Directory.Exists(uncDirectory)) { throw new DirectoryNotFoundException($共享目录不存在{uncDirectory}); } string probePath Path.Combine( uncDirectory, $.write_probe_{Guid.NewGuid():N}.tmp); try { using (var fs new FileStream( probePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { fs.WriteByte(0); fs.Flush(true); } } finally { if (File.Exists(probePath)) { File.Delete(probePath); } } } }uncDirectory必须是目录路径不能传成文件路径探针名带 GUID避免多客户端冲突FileMode.CreateNew保证不会覆盖已有文件FileShare.None能顺带验证能否独占创建Flush(true)让数据真正落到服务端而不是只写进客户端缓存。删探针放在finally即使写失败也尽量清理。若这一步就抛UnauthorizedAccessException不用继续查上传代码先查共享权限、NTFS 权限和账号是否被禁用。3. 把复制拆成流WinForm 分块上传、进度回调与取消的落地代码3.1 File.Copy 为什么不适合 WinForm 上传大文件File.Copy写起来最快但它有三个硬伤没有进度回调UI 上只能转圈调用期间阻塞当前线程WinForm 界面直接假死取消只能靠杀进程大文件传到一半留下半截目标文件。文件上传到共享文件夹一旦涉及几百 MB 的附件用户第一反应就是“是不是卡死了”没有进度条和取消按钮体验和可维护性都很差。分块流式复制把大文件拆成固定大小的缓冲区读一块写一块每块之间检查取消令牌并上报进度。缓冲区不是越大越好局域网千兆环境常用 1MB 到 4MB跨网段或 SMB 延迟高时用 256KB 到 1MB太小会增加往返次数太大则单次写阻塞变长、进度更新变粗。目标文件先写成.uploading临时文件全部写完再重命名业务侧不会读到半截文件。这个临时文件也是后面断点续传的基础。3.2 一个可复用的分块上传类下面这个类只依赖System.IO不依赖 WinForm 控件方便放进任何客户端工程。进度对象带总字节数、已上传字节数和当前文件名UI 层自己决定怎么展示。using System; using System.IO; using System.Threading; using System.Threading.Tasks; public sealed class UploadProgress { public long TotalBytes { get; init; } public long UploadedBytes { get; init; } public string CurrentFile { get; init; } string.Empty; public double Percent TotalBytes 0 ? 100 : UploadedBytes * 100.0 / TotalBytes; } public sealed class SharedFolderUploader { private readonly int _bufferSize; public SharedFolderUploader(int bufferSize 1024 * 1024) { _bufferSize bufferSize; } public async Task UploadAsync( string sourcePath, string targetDirectory, string targetFileName, IProgressUploadProgress progress, CancellationToken cancellationToken) { if (!File.Exists(sourcePath)) { throw new FileNotFoundException(源文件不存在, sourcePath); } Directory.CreateDirectory(targetDirectory); string finalPath Path.Combine(targetDirectory, targetFileName); string tempPath finalPath .uploading; long total new FileInfo(sourcePath).Length; long uploaded 0; using (var source new FileStream( sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, _bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) using (var target new FileStream( tempPath, FileMode.Create, FileAccess.Write, FileShare.None, _bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) { byte[] buffer new byte[_bufferSize]; int read; while ((read await source.ReadAsync( buffer.AsMemory(0, buffer.Length), cancellationToken)) 0) { await target.WriteAsync( buffer.AsMemory(0, read), cancellationToken); uploaded read; progress?.Report(new UploadProgress { TotalBytes total, UploadedBytes uploaded, CurrentFile Path.GetFileName(sourcePath) }); } await target.FlushAsync(cancellationToken); } if (File.Exists(finalPath)) { File.Delete(finalPath); } File.Move(tempPath, finalPath); } }bufferSize默认 1MB局域网内可以调到 4MBsource用FileShare.Read允许其他程序读源文件target用FileShare.None防止多个客户端同时写同一个临时文件。FileOptions.Asynchronous让异步读写真正走异步路径SequentialScan提示顺序访问对 SMB 大文件比较友好。进度在每块写完后上报IProgressT会自动回到创建它的同步上下文WinForm 里可以直接更新控件。File.Move(tempPath, finalPath)之前先删目标文件是为了兼容 .NET Framework 没有覆盖重载的情况。严格来说这会有一个极短窗口目标文件已删、新文件未就位。如果业务要求原子替换目标文件存在时改用File.Replace(tempPath, finalPath, null)不存在时再File.Move。这个差异在 .NET 6 和 .NET Framework 4.8 上都存在迁移时别只看编译通过。3.3 WinForm 事件处理器里的 async、进度条与取消UI 层只做三件事选文件、启动上传、更新进度。不要在按钮事件里写Thread.Sleep或同步等待也不要在后台线程直接改控件。下面这段代码用async void只因为它是事件处理器内部所有异常都捕获按钮同时承担“上传”和“取消”两个状态。using System; using System.IO; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; private CancellationTokenSource? _cts; private async void btnUpload_Click(object sender, EventArgs e) { if (_cts ! null) { _cts.Cancel(); return; } _cts new CancellationTokenSource(); btnUpload.Text 取消; progressBar.Value 0; var progress new ProgressUploadProgress(p { progressBar.Value (int)Math.Clamp(p.Percent, 0, 100); lblStatus.Text ${p.CurrentFile} ${p.UploadedBytes / 1024 / 1024}MB / ${p.TotalBytes / 1024 / 1024}MB; }); try { var uploader new SharedFolderUploader(4 * 1024 * 1024); await uploader.UploadAsync( txtSource.Text, txtTargetDir.Text, Path.GetFileName(txtSource.Text), progress, _cts.Token); lblStatus.Text 上传完成; } catch (OperationCanceledException) { lblStatus.Text 已取消; } catch (Exception ex) { MessageBox.Show(ex.Message, 上传失败, MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { _cts.Dispose(); _cts null; btnUpload.Text 上传; } }ProgressUploadProgress在 UI 线程创建回调会自动回到 UI 线程所以可以直接改progressBar和lblStatus。取消按钮复用同一个按钮第二次点击调用_cts.Cancel()上传循环在ReadAsync或WriteAsync处抛出OperationCanceledException临时文件会留在目标目录后面可以接断点续传或清理策略。async void的异常必须自己接住否则会直接把进程打崩这是 WinForm 异步代码里最容易忽略的细节。4. 大文件不断在半路临时文件、断点续传与重试的四个参数4.1 临时文件与原子落盘别让业务读到半截文件共享目录里最怕的是“文件已经出现在列表里但内容不完整”。业务系统如果按文件名轮询读到半截文件会直接解析失败甚至把脏文件归档。临时文件命名建议固定后缀例如.uploading或.part业务扫描时跳过这个后缀。上传完成后重命名成目标文件名同一目录内的重命名在 SMB 上通常是原子操作业务侧要么看到旧文件要么看到完整新文件。同名冲突要提前定策略不要等用户上传时弹一个“文件已存在”就结束。常见做法有四种覆盖、跳过、改名、拒绝。覆盖适合固定报表或每日全量文件跳过适合幂等上传改名适合多客户端往同一个目录投递拒绝适合归档目录要求唯一文件名。下面的表可以直接拿去做配置项。策略实现方式适用场景注意点覆盖File.Replace或先删后移固定文件名、每日更新先删后移有短暂空窗跳过上传前判断目标存在幂等重试、重复提交判断和写入之间仍有竞争改名追加时间戳或 GUID多客户端并发投递文件名会变长业务要能解析拒绝目标存在直接抛异常归档唯一性要求高需要给用户明确提示注意File.Exists判断目标文件是否存在只能作为提示不能作为并发控制的唯一依据两个客户端同时判断“不存在”再同时写入仍然会冲突。4.2 断点续传的偏移计算与长度校验断点续传的核心不是“记住进度条”而是“临时文件已经写了多少字节”。上传前读临时文件长度源文件Seek到同样偏移目标文件也Seek到同样偏移继续往后写。临时文件比源文件还大说明上一次写入异常或源文件被替换直接截断重来。全部写完后比较临时文件长度和源文件长度不一致就抛异常不要直接重命名。using System; using System.IO; using System.Threading; using System.Threading.Tasks; public async Task ResumeUploadAsync( string sourcePath, string targetDirectory, string targetFileName, IProgressUploadProgress progress, CancellationToken cancellationToken) { string finalPath Path.Combine(targetDirectory, targetFileName); string tempPath finalPath .uploading; int bufferSize 1024 * 1024; long total new FileInfo(sourcePath).Length; long offset File.Exists(tempPath) ? new FileInfo(tempPath).Length : 0; using (var source new FileStream( sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) using (var target new FileStream( tempPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) { if (offset total) { target.SetLength(0); offset 0; } source.Seek(offset, SeekOrigin.Begin); target.Seek(offset, SeekOrigin.Begin); byte[] buffer new byte[bufferSize]; int read; while ((read await source.ReadAsync( buffer.AsMemory(0, buffer.Length), cancellationToken)) 0) { await target.WriteAsync( buffer.AsMemory(0, read), cancellationToken); offset read; progress?.Report(new UploadProgress { TotalBytes total, UploadedBytes offset, CurrentFile Path.GetFileName(sourcePath) }); } await target.FlushAsync(cancellationToken); } if (new FileInfo(tempPath).Length ! total) { throw new IOException(断点续传长度校验失败临时文件长度与源文件不一致); } if (File.Exists(finalPath)) { File.Delete(finalPath); } File.Move(tempPath, finalPath); }offset来自临时文件长度不是来自配置文件或数据库因为临时文件才是真实写入结果。Seek在 SMB 上不一定便宜但比重新传整个文件划算。长度校验只能证明字节数一致不能证明内容一致对完整性要求高的场景再追加 SHA256但大文件算哈希很耗时建议放在业务低峰或只对关键文件开启。断点续传还依赖连接可用SMB 会话断开后要先重新建立凭据连接再继续写否则会一直报网络错误。4.3 重试与超时失败分类比循环次数更重要重试最怕两种写法一种是所有异常都重试认证失败也重试越重试账号越容易被锁另一种是死循环不设上限客户端卡在“正在上传”一下午。正确做法是先分类UnauthorizedAccessException和明确的认证错误码不重试IOException里的网络抖动、共享暂时不可用可以重试OperationCanceledException立即抛出。重试间隔用指数退避给服务端一点恢复时间。using System; using System.IO; using System.Threading; using System.Threading.Tasks; public static async TaskT RetryAsyncT( FuncTaskT action, int maxAttempts 3, CancellationToken cancellationToken default) { Exception? last null; for (int attempt 1; attempt maxAttempts; attempt) { try { return await action(); } catch (OperationCanceledException) { throw; } catch (UnauthorizedAccessException ex) { throw new IOException(权限或凭据失败不重试, ex); } catch (IOException ex) when (attempt maxAttempts) { last ex; int delay 500 * (int)Math.Pow(2, attempt - 1); await Task.Delay(delay, cancellationToken); } } throw last ?? new IOException(上传重试失败); }maxAttempts默认 3 次延迟分别是 500ms、1000ms、2000msUnauthorizedAccessException直接包装成IOException抛出避免上层误以为还能继续重试。超时用CancellationTokenSource.CancelAfter控制整体上传时间不要在每个缓冲区读写上单独设超时否则大文件会被切得很难管理。重试次数和超时时间建议做成配置项不同共享服务器的网络质量差别很大硬编码一套参数迟早翻车。5. 避坑与排查权限、占位、长路径、并发、认证失败的五类翻车现场5.1 能打开共享却写不进去共享权限与 NTFS 的叠加现象资源管理器能打开\\server\share\folder能看到里面的文件WinForm 上传时抛UnauthorizedAccessException提示拒绝访问。原因通常是共享权限给了入口但 NTFS 权限只允许读取或者上传账号对目标子目录没有“写入”和“修改”权限。还有一种情况是客户端以管理员身份运行网络会话使用的令牌和普通用户不同测试结果不能代表最终用户。解决先确认上传进程用的是哪个 Windows 账号再在服务端同时看共享权限和 NTFS 权限不要只看其中一层。用探针文件实测创建和删除比看目录属性更直接。如果必须用不同账号用WNetAddConnection2显式连接连接后立刻跑一次EnsureWritable把认证和写权限分开验证。5.2 文件上传到一半消失临时文件命名与杀软扫描现象上传大文件时目标目录里偶尔出现.uploading文件过一会儿又没了或者业务系统读到半截文件解析失败。原因可能是临时文件后缀被清理脚本或杀软扫描删除也可能是上传完成后先删目标文件再移动中间被业务轮询读到空窗。还有客户端异常退出临时文件留在目录里下次上传没有清理策略。解决临时文件固定后缀并加入清理白名单业务扫描明确跳过上传完成用File.Replace或同目录重命名减少空窗客户端启动时清理超过一定时间的.uploading文件但不要删正在被其他进程占用的文件。杀软拦截只能通过加目录信任或换后缀验证不要靠反复重试硬扛。5.3 中文路径与长路径不是所有 WinForm 都默认支持现象本地文件路径包含中文、空格或超过 260 个字符时上传抛出PathTooLongException或找不到文件。原因通常是 .NET Framework 默认没有启用长路径支持或者代码里手工拼接路径时漏了转义。共享路径本身也可能很长尤其是多级部门目录。解决优先用Path.Combine拼接不要用字符串加斜杠目标文件名做非法字符过滤需要长路径时在应用配置里启用长路径支持或者使用\\?\UNC\server\share\...前缀但这个前缀对某些 API 不友好要先小范围验证。中文路径本身不是问题问题多是编码转换和日志输出时被截断日志里记录完整路径能省很多排查时间。5.4 多客户端并发覆盖同名文件与最后写入者现象两个客户端同时上传同名文件最后目标文件内容属于其中一个另一个用户以为自己的文件上传成功了。原因是没有冲突策略或者只在上传前用File.Exists判断判断和写入之间有时间差。共享目录没有数据库行锁靠文件系统本身很难做到“先到先得”。解决按业务定策略。固定报表可以覆盖但要在文件名里带日期或版本多客户端投递用时间戳或 GUID 改名归档目录用“目标存在就拒绝”并在 UI 上提示用户改名。服务端目录按日期或业务号分桶减少同名概率。不要依赖File.Exists做并发控制它只能做提示。5.5 认证失败被当成网络故障1326/1219 的排查顺序现象上传时报“网络路径找不到”或“连接共享失败”用户反复点重试账号被锁。原因是客户端把认证失败也当成网络抖动重试或者同一服务器已经用另一个账号建立过连接返回 1219。还有客户端缓存了旧凭据改了密码仍然用旧会话。解决先看错误码再重试。1326 查账号格式、密码过期、账号锁定1219 先取消已有连接或统一使用一个账号53/67 查服务器名和共享名5 查权限。重试逻辑里把认证失败排除掉日志记录错误码和当前用户名不要记录密码。上传结束后释放连接避免下一次换账号时被旧会话挡住。6. 进阶验证把上传成功率、耗时和失败码变成可观测数据上传功能写完不等于能交付。我一般会做一轮小验证准备 10MB、100MB、1GB 三个文件分别在同一网段和跨网段各跑三次再模拟目标目录只读、共享名错误、上传中取消、同名文件已存在四种异常。每次记录总耗时、平均速度、重试次数、错误码和临时文件是否清理。这样做的目的不是追求漂亮数字而是把“用户说上传慢”变成可比较的指标。验证项输入观察指标通过标准小文件10MB耗时、进度是否到 100%无残留临时文件大文件1GB平均速度、内存占用UI 不卡死、可取消权限拒绝只读目录错误码、提示文案不重试认证错误网络中断上传中禁用网卡临时文件长度、重试次数可续传或可清理同名冲突目标已存在覆盖/跳过/改名行为与配置一致日志不要只写“上传失败”至少带上文件名、源文件大小、目标路径、缓冲区大小、尝试次数、耗时和错误码。下面这段日志结构可以直接落 JSON Lines一行一条方便后面用脚本统计。using System; using System.IO; using System.Text.Json; var logItem new { Time DateTimeOffset.Now, FileName Path.GetFileName(sourcePath), SourceBytes total, TargetPath finalPath, BufferSize bufferSize, Attempt attempt, ElapsedMs (int)stopwatch.ElapsedMilliseconds, ErrorCode errorCode }; File.AppendAllText( logPath, JsonSerializer.Serialize(logItem) Environment.NewLine);Time用来对齐用户反馈时间SourceBytes和ElapsedMs能算平均速度BufferSize方便比较不同参数Attempt和ErrorCode用来区分是网络抖动还是权限问题。日志文件不要放在共享目录里防止上传失败时连日志都写不进去。验证时也别只用管理员账号跑一遍就结束普通用户、不同网段、目标目录已存在文件这三种情况最容易把问题留到上线后。从那以后我每次接入新的共享目录都先跑一遍可写探测和 100MB 压测再让业务方用自己的账号试一次确认进度条、取消和错误提示都能对上才把客户端发出去。希望帮到你。本文还有配套的精品资源点击获取