“C#大附件文件夹上传到Web页面”这个问题,几乎每隔一段时间就会有人在群里问一遍。我最近也在帮朋友做一个 MES 系统的看板页面,需求就是要把车间电脑上的整个工艺文件夹(里面全是几十上百MB的PDF、图片和日志)直接拖到 Web 页面里去归档。一开始我心想这不就是 <input type="file"> 加个多选嘛,结果一测就翻车了。这事的坑远不止“文件大小限制”那点破事,文件夹层级、分片传输、断点续传、服务端内存溢出,每一个都能让你在测试环节怀疑人生。
这篇文章我就基于自己做 C# Web 项目 + 大附件/文件夹上传的实战经验,把整个方案的选型、前端写法、后端处理、断点续传思路,以及我在实际项目中踩过的大大小小的坑,完整梳理一遍。适合正在做 C# Web 开发、想搞定“文件夹级大附件上传”的开发者参考。里面每一段代码都是可以直接抄去改的,不是那种只讲理论不给方案的文章。
1. 为什么“把文件夹拖进网页”这件事远比想象中麻烦
先说个现象:很多第一次接到这个需求的开发,下意识会觉得“上传文件夹嘛,不就是递归一下然后逐个上传”。这话从逻辑上来讲没错,但一旦落到真实的 Web 场景,会遇到一连串想象不到的问题。
1.1 浏览器天生就不是为传输 GB 级数据设计的
常规的 multipart/form-data 表单上传,本质上是把整个文件读进浏览器内存,然后以二进制流的形式 POST 到服务器。小文件没问题,但当你面对一个 2GB 的文件夹时,这个过程基本就是死路一条。原因有三:
第一,浏览器在把文件数据塞进请求体时,会在内存和磁盘临时缓存之间来回倒腾。我实测过,单个 1.5GB 的文件用普通表单上传,Chrome 的内存占用能飙到将近 2GB,直接导致页面卡死,用户以为程序崩溃了。
第二,Web 服务器默认对请求体大小有限制。比如 ASP.NET Core 的 Kestrel 默认请求体上限是 30,000,000 字节(约 28.6MB),IIS 也有 maxAllowedContentLength 的限制。你如果不去显式改这些配置,大文件一到服务端就被直接拒收,返回一个 413 或者干脆连接被重置。
第三,也是最关键的——没有断点续传能力。网页连接稍微抖一下,或者用户中途切了下网络,整个上传进度直接归零,从头再来。这对于动辄几个GB的文件夹来说是毁灭性的。
1.2 “文件夹”和“文件列表”是两码事
传统的多文件上传拿到的是一个扁平的 File[] 数组,文件夹结构没了。但真实业务里,归档文件夹的目录层级是有业务含义的,比如:
code复制工艺包_20250115/
├── 图纸/
│ ├── A系列/
│ │ └── 总装图.pdf
│ └── B系列/
│ ├── 零件图_v2.dwg
│ └── 零件图_v2.bak
├── 加工记录/
│ ├── 01_CNC/ ...
│ └── 02_EDM/ ...
└── 质检报告/
├── 2025-01-10.pdf
└── 2025-01-11.pdf
如果在传输过程中把层级丢掉了,变成一堆摊平的文件,那接收方就抓瞎了,还得靠文件名去猜原结构,这显然是不可接受的。所以,前端必须拿到 webkitRelativePath 或者用递归读取 DirectoryEntry 的方式把“相对路径”保留下来。
我最初用 File System Access API 研究过,就是那个 showDirectoryPicker() 接口,它可以真·读取本地文件夹,体验非常现代,兼容性也还不错(Chrome 和 Edge 都支持)。但后来发现它在企业内网环境里有兼容性风险,尤其是领导电脑还在用老版本 Edge 的情况不少。所以还是走了最稳妥的方案:<input webkitdirectory> + 递归解析,这个是近十年所有 Chromium 内核浏览器都支持的老接口了,兼容性是最稳的。
1.3 上传通道需要重新设计,而不是“改大配置”
既然单次请求传输大文件行不通,那业界通用的解法就是分片上传——前端把一个大文件切成若干个小块(比如每片 5MB),一片一片传,传完一片服务端立刻确认,最后一篇传完后服务端再把分片合并成完整文件。文件夹就是“多个文件 + 完整目录结构”的组合,本质上是把问题拆成两个维度:
- 文件维度的分片(解决单文件超大)
- 文件夹维度的并发调度(解决文件数量多、层级深)
借“分治”的思想,把“传输一个 2GB 的大文件”这个不可能任务,拆成“传输 400 个 5MB 的分片”这个轻松任务,逐步提交、逐步确认。分片还可以并行、可以断点重传、可以做秒传校验,很多之前难解决的问题都变得可控了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型对比:自己封装 vs 第三方组件
你别急着写代码,先搞清楚有哪些现成轮子,每个轮子适用什么场景。
我调研过市面上比较流行的几个方案,实际对比后又回到了自己封装的路线上。这里把结论给出来,你根据自己的项目情况选。
| 方案 | 核心思路 | 分片/断点/目录结构 | 适合场景 | 我踩过的问题 |
|---|---|---|---|---|
| 原生 XMLHttpRequest/fetch 手写 | 自己实现 File.slice 切分 + 请求调度 | 全部自研,可完全掌控 | 需要深度定制、内网部署、C#后端为主 | 开发工作量大一些,但代码全在自己手里 |
| Resumable.js | 基于分片的断点续传库,前端为主 | 分片、断点支持好,目录需要额外处理 | 想快速实现断点续传 | 目录结构保留不完整,配合 C# 后端时要自己造接口 |
| WebUploader(百度) | 综合上传组件,支持分片、并发 | 分片支持,文件夹支持弱 | 老项目、对 UI 有现成需求 | 已多年不维护,兼容现代浏览器要做一堆 patch |
| Plupload | 多运行时上传库(HTML5/Flash/Silverlight) | 分片、并发控制齐全,文件夹支持一般 | 需要考虑老浏览器时代的环境 | Flash 时代放弃后,新特性跟进慢,和现代 C# 后端整合也一般 |
2.1 直接选第三方库,方向对不对?
如果你问我要不要直接用 Resumable.js,我的看法是:如果需求只是“单个大文件断点续传”,那直接上现成的库没问题。但你现在要解决的是“文件夹 + 大附件”的组合型需求,这里有个很尴尬的体验问题:第三方库大多以“文件”为核心抽象,文件夹只是它的一个文件来源。你上传一堆文件后,得到的服务端结构是把所有文件平铺在一个“相对路径前缀”下,目录层级往往需要你在业务逻辑里重新拼。
我最早用 Resumable.js 试过一个版本,前端把每个文件的路径放在 file.relativePath 里带过去了,但服务端合并时得自己做一套复杂的目录管理逻辑——这块你始终有绕不开的“自研代码”,还不如从分片调度开始就全掌握在自己手里,遇到问题更容易排查。
2.2 自己封装的边界在哪里?
自己封装不是什么都从头写,而是把“核心链路”攥在手里:
- 前端负责:目录解析、文件遍历、切片、并发调度、进度计算、失败的本地重试
- 后端负责:接收分片、临时存储、分片完整性校验、文件合并、目录还原
第三方库可以依赖,但只是辅助,比如用 hash-wasm 做文件指纹,用 localStorage 做上传状态记录。主逻辑必须自己来,因为只有你自己才知道 C# 后端的接口该怎么设计、目录结构该怎么还原。一旦这两层打通,以后不管是嵌套几十层的文件夹还是单个几十G的文件,都在同一个逻辑框架下解决。
2.3 为什么我最终选择“前端原理解析 + 后端自研接口”的路线
我这边项目部署环境是 Windows Server + IIS + ASP.NET Core,内网带宽比较稳定但客户端电脑配置参差不齐,有老掉牙的 Win7 机器,也有一些性能不错的 Windows 10/11 工作站。用第三方前端库时,遇到一台配置较差的机器,一并发上传几十个分片就卡死了,排查半天也定位不到问题。后来一怒之下自己写了一套基于 fetch 的分片上传线程池,结果同样的机器,并发控制在 3~5 个分片时,CPU 和内存都很平稳,页面也不卡了。从这以后我就坚定了一个观点:大附件上传这种需求,一定不能只听第三方库的默认配置,你要能精确控制并发度、分片大小和错误重试策略。
自研路线的另一个决定性优势是:后端 C# 接口完全是我自定义的,前端有什么状态、后端怎么响应,我可以在协议层面严格对齐。比如我可以设计一个 POST /api/upload/merge,前端同时把“文件唯一标识 + 分片总数 + 相对路径 + 所属批次”传过来,服务端按统一规则合并。第三方库在这里反而因为协议固定、扩展不易,会约束你的设计。
3. 前端核心实现:目录遍历、切片、并发调度
前端这一块是整个方案里最容易被低估的部分,但恰恰又是体验好坏的关键。我下面把每一步拆开讲,代码直接可以复制到你的 Vue / React / 原生 JS 项目里用。
3.1 第一步:拿到带相对路径的文件列表
HTML 侧用一个隐藏的 <input>,加上 webkitdirectory 属性,就能让用户选择整个文件夹:
html复制<input
type="file"
id="folderPicker"
webkitdirectory
multiple
style="display: none"
/>
当用户选择了文件夹之后,<input>.files 里拿到的每个 File 对象都会带一个 webkitRelativePath 属性,这个属性就是相对于所选的根目录的路径。举个例子,上面那个“工艺包”文件夹里,总装图.pdf 的 webkitRelativePath 就是 工艺包_20250115/图纸/A系列/总装图.pdf。
接下来把 File 对象包装成我们自己的任务模型:
javascript复制function parseFolderFileList(fileList) {
const tasks = [];
for (const file of fileList) {
tasks.push({
relativePath: file.webkitRelativePath || file.name,
file: file,
size: file.size,
uploadedChunks: new Set(), // 记录已上传成功的分片索引
status: 'pending'
});
}
return tasks;
}
这里有两点需要提醒:
- 不同浏览器对
webkitRelativePath的首段(根目录名)处理基本一致,都是包含根文件夹名称的。如果你不想把根目录名存进数据库,可以在后端拼接时去掉第一段。 - 尽量别直接使用
File.name作为存储路径,因为不同子目录下可能有同名文件,比如A系列/总装图.pdf和B系列/总装图.pdf是两码事。
3.2 第二步:文件切片与唯一指纹
分片大小我用的是 5MB,这个数值不是拍脑袋定的。实测下来,在常见内网带宽和 ASP.NET Core 默认并发限制下,5MB 是个兼顾“请求次数”和“单请求耗时”的平衡点:
- 如果分片太细(比如 1MB),一个 2GB 文件会拆成 2048 个请求,服务端接口压力大,前端请求调度的开销也高。
- 如果分片太粗(比如 50MB),单个请求要传输的数据量又上去了,反而不如小分片灵活。
用 File.slice() 方法做切片,它返回一个新的 Blob,可以像 File 一样被 FormData 发送,内存占用也很小:
javascript复制const CHUNK_SIZE = 5 * 1024 * 1024;
function splitFile(file) {
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push(file.slice(start, end));
start = end;
}
return chunks;
}
同时,我给每个文件计算一个唯一标识(fileUid)。一般用文件的 size + lastModified + name 做粗略指纹就够用了,如果要更强的唯一性,可以用加密摘要算法计算 MD5 或 SHA-256,但要注意大文件整体计算耗时较长。我建议对超大文件(>1GB)采用“增量计算”的方式,边读边算,否则用户会等得很烦躁。
javascript复制async function computeFileUid(file) {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return `${file.size}-${file.lastModified}-${hashHex.substring(0, 16)}`;
}
注意:
crypto.subtle只在安全上下文(HTTPS 或 localhost)下可用。如果内网是 http 访问,建议退回到 SHA-1 或直接使用size + lastModified组合,别在这里卡住整个流程。
3.3 第三步:并发调度与上传队列
文件夹里几十个文件,不能一股脑同时传。我的策略是“全局并发控制 + 文件内分片串行 + 多文件并发”。也就是:
- 每个文件在同一时刻只发送一个分片,保证单个文件的旧分片传输有序;
- 但多个文件之间可以并行,这里用一个信号量(Semaphore)控制全局并发,默认并发 3~5 个分片请求。
用 JS 实现一个简单的并发控制器:
javascript复制class Semaphore {
constructor(maxConcurrency) {
this.max = maxConcurrency;
this.current = 0;
this.queue = [];
}
async acquire() {
if (this.current < this.max) {
this.current++;
return;
}
await new Promise(resolve => this.queue.push(resolve));
this.current++;
}
release() {
this.current--;
const next = this.queue.shift();
if (next) {
next();
}
}
}
实际上传时,单个分片的代码大概是这样的:
javascript复制const semaphore = new Semaphore(5);
async function uploadChunk(task, chunkIndex, chunkBlob, fileUid) {
await semaphore.acquire();
try {
const formData = new FormData();
formData.append('fileUid', fileUid);
formData.append('chunkIndex', chunkIndex);
formData.append('fileName', task.file.name);
formData.append('relativePath', task.relativePath);
formData.append('chunk', chunkBlob);
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`分片 ${chunkIndex} 上传失败,HTTP ${response.status}`);
}
task.uploadedChunks.add(chunkIndex);
} finally {
semaphore.release();
}
}
很多朋友问,为什么要限制并发数?这背后有个容易被忽略的坑:浏览器对同一域名下的并发 TCP 连接数有限制(通常是 6 个),如果你一次性发 20 个请求,其余的全部排队,表面上看起来没崩,但进度跳变和超时重试就会非常频繁。另外,服务器端如果用的是 Kestrel 的默认配置,线程池调度也扛不住太高的瞬时并发。把并发控制在 3~5 个,是我在多个项目里实测下来最稳的区间。
3.4 第四步:进度计算不能只看“已发送的文件数”
这个点看似简单,实际容易踩坑。真实的进度应该按“已上传分片字节数 / 总分片字节数”来算,而不是“已完成的文件数 / 总文件数”。因为一个大文件可能占文件夹体积的 80%,但按文件数算只有几十分之一,进度条会显得很假。
我在前端实现了一个以字节为单位的进度累加器:
javascript复制function updateProgress(tasks) {
let totalBytes = 0;
let uploadedBytes = 0;
for (const task of tasks) {
totalBytes += task.size;
for (const idx of task.uploadedChunks) {
uploadedBytes += Math.min(CHUNK_SIZE, task.size - idx * CHUNK_SIZE);
}
}
const percent = totalBytes ? Math.floor((uploadedBytes / totalBytes) * 100) : 0;
// 更新 UI 进度条
renderProgress(percent, uploadedBytes, totalBytes);
}
注意,这里我用了“已上传分片索引集合”而不是遍历文件,因为这样在断点续传恢复时能准确地知道哪些分片还没传,而不是把所有文件重新读一遍。
4. 后端 C# 接收与合并:Web API 的关键设计
前端做得再花哨,后端接口设计不合理也是白搭。我在 ASP.NET Core 里把接口拆成了两个:
POST /api/upload/chunk:接收单个分片POST /api/upload/merge:全部传完后触发合并
为什么拆成两个接口?因为分片上传过程中,前端随时可能中断,服务端不能要求“必须在一个请求里把所有分片传完”。每个分片到服务端后,先落盘到临时目录,等最终收到 merge 请求时,再把临时分片按顺序组合成完整文件。这样既灵活,又可靠。
4.1 接收分片接口的实现
csharp复制[HttpPost("api/upload/chunk")]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> UploadChunk(
[FromForm] string fileUid,
[FromForm] int chunkIndex,
[FromForm] string fileName,
[FromForm] string relativePath,
IFormFile chunk)
{
if (chunk == null || chunk.Length == 0)
{
return BadRequest("分片内容为空");
}
// 按 fileUid 隔离临时目录,避免不同文件的相同 chunkIndex 互相覆盖
var tempDir = Path.Combine(_env.WebRootPath, "_upload_tmp", fileUid);
Directory.CreateDirectory(tempDir);
var chunkPath = Path.Combine(tempDir, $"{chunkIndex}.part");
await using (var stream = new FileStream(chunkPath, FileMode.Create))
{
await chunk.CopyToAsync(stream);
}
return Ok(new { chunkIndex, received = true });
}
几个实现要点:
[RequestSizeLimit]一定要加,但只需要大于“单分片大小”就行。比如分片 5MB,限制就设置成 10MB,留出表单字段的余量,防止有人直接传一个超大包把你的临时目录打爆。- 临时文件名用
chunkIndex.part,所有分片放在fileUid目录里,互不干扰。 IFormFile.CopyToAsync在网络高并发场景下性能稳定,因为它内部会用BufferSize=81920的流式复制,不会一次性把整个分片读入内存。
4.2 合并接口的实现
合并接口是整个方案中“确定性”最强的一环:前端保证所有分片都已上传成功后,通知服务端合并。
csharp复制[HttpPost("api/upload/merge")]
public async Task<IActionResult> MergeChunks(
[FromBody] MergeRequest request)
{
var tempDir = Path.Combine(_env.WebRootPath, "_upload_tmp", request.FileUid);
if (!Directory.Exists(tempDir))
{
return BadRequest("临时目录不存在,分片可能未上传");
}
// 计算分片数量,校验是否齐全
var totalChunks = (int)Math.Ceiling((double)request.FileSize / _chunkSize);
var partFiles = Directory.GetFiles(tempDir, "*.part");
if (partFiles.Length != totalChunks)
{
return BadRequest($"分片不完整,预期 {totalChunks} 片,实际 {partFiles.Length} 片");
}
// 目录还原:相对路径的安全处理
var safeRelativePath = SanitizeRelativePath(request.RelativePath);
var saveDir = Path.Combine(_env.WebRootPath, "uploads", Path.GetDirectoryName(safeRelativePath));
Directory.CreateDirectory(saveDir);
var finalPath = Path.Combine(saveDir, Path.GetFileName(safeRelativePath));
await using (var output = new FileStream(finalPath, FileMode.Create))
{
for (int i = 0; i < totalChunks; i++)
{
var partPath = Path.Combine(tempDir, $"{i}.part");
await using var input = new FileStream(partPath, FileMode.Open);
await input.CopyToAsync(output);
}
}
// 合并成功后清理临时分片
Directory.Delete(tempDir, true);
return Ok(new { finalPath });
}
合并时有个关键点是 SanitizeRelativePath。因为 relativePath 是前端传上来的字符串,而服务端要用它去拼接物理路径,如果没做过滤,有心人可以传一个 ../../../../Windows/system32 之类的路径,导致任意文件写入或覆盖,这就是路径穿越漏洞。我处理时把相对路径中的 ..、盘符冒号、非法字符全部过滤或替换:
csharp复制private static string SanitizeRelativePath(string relativePath)
{
var invalidChars = Path.GetInvalidFileNameChars()
.Concat(new[] { ':', '*', '?', '"', '<', '>', '|' })
.ToArray();
var parts = relativePath.Split('/', '\\')
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => {
var cleaned = new string(p.Where(c => !invalidChars.Contains(c)).ToArray());
return cleaned;
})
.Where(p => p != "..")
.ToArray();
return string.Join(Path.DirectorySeparatorChar, parts);
}
4.3 “边传边存目录结构” vs “合并时再还原”?
这两个方案我都实现过。合并时再还原的优点是逻辑直观、代码集中,缺点是合并阶段耗时长。边传边存目录结构则会在传输过程中就把每个分片放到最终目录的隐藏临时目录里,合并时只需把 .part 重命名。但从数据安全性角度看,我推荐“合并时再还原”,因为边传边存到最终位置的话,一旦中途某片失败,你已经写到一半的文件就会变成残缺文件;反而放在统一的临时目录里可以很干净地重传和清理。
5. 断点续传:如何在刷新页面后继续上传
很多人做完基本的切片上传就交差了,但真实用户使用场景是什么?大概率是一次传几百个文件,传到一半,网线松了,或者老板喊开会上传中断了。如果不支持断点续传,用户得重新选一次文件夹、从头再传一遍,这种体验会让人抓狂。
5.1 前端如何恢复上传状态?
断点续传的核心是“尽力而为地记录状态”,我说得直白一点:前端把每个文件的 fileUid + chunkIndex 记录在一个持久化的 Map 里,每次分片上传成功后,就把这个索引记下。遇到中断后,重新选文件夹时,前端先把所有文件的 fileUid 作为参数发给服务端,服务端返回每个文件已经收到了哪些分片索引。前端拿到这个列表后,只上传缺失的分片。
服务端可以提供一个简单的查询接口:
csharp复制[HttpPost("api/upload/status")]
public IActionResult GetUploadStatus([FromBody] StatusRequest request)
{
var tempDir = Path.Combine(_env.WebRootPath, "_upload_tmp", request.FileUid);
if (!Directory.Exists(tempDir))
{
return Ok(new { uploadedChunks = Array.Empty<int>() });
}
var uploadedChunks = Directory.GetFiles(tempDir, "*.part")
.Select(Path.GetFileNameWithoutExtension)
.Select(int.Parse)
.ToArray();
return Ok(new { uploadedChunks });
}
前端拿到已上传分片列表后,循环要传的分片,跳过已有的即可:
javascript复制const status = await fetch('/api/upload/status', {...}).then(r => r.json());
for (let i = 0; i < chunkCount; i++) {
if (status.uploadedChunks.includes(i)) {
task.uploadedChunks.add(i); // 跳过已上传的分片
continue;
}
await uploadChunk(task, i, chunks[i], task.fileUid);
}
5.2 文件指纹的计算时机与性能优化
断点续传的精度取决于 fileUid 的稳定性。如果文件内容没变,但 lastModified 变了,那 fileUid 就会变,之前的断点状态就全对不上了。所以如果需要搞“真·断点续传”(而不是“相同文件名的重传”),我建议对文件夹上传这种场景使用“增量式文件哈希”。
增量计算哈希的思路是:把文件分成若干个 1MB 的块,每次读取一块,更新哈希上下文,最后输出摘要。用 .NET 的 IncrementalHash 或者前端 Web Crypto 的 SubtleCrypto 都可以实现逐块读取计算。这里给一个简化的思路示意:
csharp复制// C# 服务端计算参考
using var stream = new FileStream(filePath, FileMode.Open);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[1024 * 1024];
int read;
while ((read = await stream.ReadAsync(buffer)) > 0)
{
hash.AppendData(buffer, 0, read);
}
var result = hash.GetHashAndReset();
但实际上,前端需要的是它自己的 fileUid,所以上面的 C# 代码逻辑要翻译成前端 JS 版本,用 File.stream() + ReadableStream 逐块读,每次读 1MB,喂给 crypto.subtle.digest 的上下文。注意,crypto.subtle.digest 只能一次性接收全部数据,没有增量接口,这里需要自己实现分段读取和合并,或者干脆用第三方库 hash-wasm。我因为不愿意引额外依赖,最终采用的是“多级指纹”:小文件(<100MB)直接整体算 SHA-256;大文件用 size + lastModified + 前 1MB 内容的哈希 作为指纹。实测下来,对于“同一个文件被重新复制粘贴导致 lastModified 变化”的情况,命中率也很高,基本够用。
5.3 秒传与“垃圾分片”的清理策略
断点续传的另一个副作用是:用户传了一半,发现选错文件夹了,于是重新选择。这时候服务端临时目录里会残留上一批的分片文件。我用两种方式解决:
- 用户在 UI 上点击“取消上传”,前端发一个
POST /api/upload/cancel,把fileUid传过来,服务端直接删除对应临时目录。 - 服务端写一个后台清理任务,删除超过 24 小时未被访问的临时目录。这个做法比较稳妥,哪怕用户直接关浏览器,不触发取消接口,垃圾也不会永久堆积在磁盘上。
csharp复制// 后台定时清理,避免临时目录无限膨胀
public class TempFolderCleaner : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var tmpRoot = Path.Combine(_env.WebRootPath, "_upload_tmp");
var staleDirs = Directory.GetDirectories(tmpRoot)
.Where(dir => Directory.GetLastWriteTimeUtc(dir) < DateTime.UtcNow.AddHours(-24));
foreach (var dir in staleDirs)
{
try { Directory.Delete(dir, true); }
catch { /* 占用中则跳过 */ }
}
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}
6. 实测翻车记录:我在大附件上传这条路上踩过的坑
理论讲完,上点硬核实操经验。下面这些都是我在真实项目中遇到的问题,每一个都曾让我排查好几个小时,然后恍然大悟。
6.1 坑一:分片全传完了,服务端合并时却说文件被占用
现象:前端明明提示“所有分片上传完成”,但服务端 merge 阶段去打开 .part 文件时报“进程无法访问文件,因为另一个进程正在使用”。
排查链路:
- 检查是不是有多个 merge 请求同时发起?前端确实只发了一次,排除。
- 检查是不是杀毒软件锁定了
.part文件?内网电脑装的是 360,确实可疑,但范围太大,无法验证。 - 最终用
handle.exe查了一遍,发现是我们的一个日志上传中间件把_upload_tmp目录加入了实时监控,文件一被创建就尝试去解析,导致文件句柄未释放。
解决:把 _upload_tmp 目录加入日志监控的排除列表,并在写临时文件时给 FileStream 加上 FileShare.Read 属性。如果你是自托管的 Kestrel 或 IIS,也建议把上传临时目录与应用程序运行目录分离,避免各种文件监控服务干扰。
6.2 坑二:用 taskkill /F 杀掉 IIS 工作进程后,分片目录里的文件全是 0 字节
现象:分片上传过程中,队友为了更新程序在服务器上重启了 IIS,结果正在传输的分片全部变成 0 字节文件。
原因:我把分片写入路径设计成了 FileMode.Create,也就是先创建空文件再边写边填充。进程被强杀时,数据还没来得及 flush 到磁盘,文件就停留在“空文件”状态。
解决思路:把写入模式改成“先写临时文件(如 .tmp),写完再原子性重命名成 .part”。因为 File.Copy 或 File.Move 在同一个卷内是原子性的,进程被杀也不会出现半截文件。这样下次断点续传查询时,没有 .part 文件就不会误报“已上传”,前端会重新传这个分片,服务端再覆盖。
6.3 坑三:Linux 部署时,路径拼接出现了反斜杠
现象:开发环境是 Windows(IIS),测试环境是 Linux(Docker + Nginx 反代 Kestrel)。在 Linux 上一切正常,但把 Windows 物理服务器上的临时上传目录整个拷到 Linux 后,merge 时找不到分片文件。
原因:我在保存 relativePath 时,前端在 Windows 上是 \ 分隔,切到 Linux 上代码忘了兼容两种分隔符。Path.Combine 在 Linux 上不会把反斜杠当成目录分隔符,导致拼接出来的路径不正确。
解决:前端统一把 relativePath 替换成 /,后端在 SanitizeRelativePath 里同时处理两种分隔符,然后统一用 Path.DirectorySeparatorChar 拼回操作系统本地路径。代码层面我已经在上面那个 SanitizeRelativePath 里处理了。
6.4 坑四:上千个小文件上传时,请求数爆炸导致页面卡死
现象:文件夹里 1000 个小文件,每个 100KB 左右,5MB 分片意味着每个文件 1 个分片,1000 个请求瞬时全发出去,页面卡住。
排查链路:
- 观察浏览器 Network,发现所有请求都在
pending,不是发送失败,而是浏览器的 6 个连接限制被排队塞满了。 - 即便设置了并发 5,1000 个文件也要循环调度很久,因为每个文件的请求都有 RTT 开销。
优化:对小文件做“打包上传”。我把小于 4MB 的文件累积在一起,组成一个“小文件批次”,每个批次作为一个请求发到服务端,服务端解包后逐文件落盘。实施后,1000 个小文件的请求数从 1000 降到了几十个,整体上传时间缩短了数倍。
服务端接收批次分片时,同样是个 IFormFile,但内部是一个自定义的压缩包结构。为了不引入额外的压缩逻辑,我用 JSON 描述文件列表,然后将多个文件字节拼接在一个二进制表单字段里;服务端解析时按 contentLength 切分即可。嫌麻烦的话,直接用 application/x-zip-compressed 在前端用 JSZip 打一个 zip 包也行,看你的 COTS(商用现成软件)策略。
6.5 坑五:文件完整性校验明明通过,解压时却提示 “Unexpected end of data”
现象:所有分片都传完了,merge 成功,前端给的 upload 成功的提示也出来了。但用户下载文件后,发现 PDF 文件打不开,报“文件已损坏”。
排查链路:
- 对比源文件和下载文件的 MD5,发现不一致。说明传输/合并过程中出现了数据损坏。
- 单独重传某个分片再合并,结果变化了。说明不是固定某一分片的问题,而是随机性的。
- 查看服务端日志,发现
CopyToAsync的默认BufferSize在低配机器上有时会触发底层IOException,而该异常被我上层代码吞掉了(因为我当时try { CopyToAsync } catch { }只记录了日志没有抛出来),导致部分分片写入中断,但前端并不知道。
解决:所有分片上传和合并的 CopyToAsync 都要严格检查写入长度,并在 catch 分支重新抛出业务异常(如 UploadChunkIncompleteException),绝不能吞掉。同时,在 merge 完生成完整文件后,服务端可以返回文件哈希,前端做一个“源哈希 vs 服务端哈希”的最终校验。虽然这会增加读盘开销,但对于大附件场景,这一步是必要的安全网。
结尾:我现在的固定套路
如果在问我一次“C# 里到底怎么完成大附件文件夹上传到 Web 页面”,我会告诉你的不是某一个开源组件,而是一套组合拳:前端 webkitdirectory 拿相对路径 + File.slice 切 5MB 分片 + 全局并发控制 3~5 + 断点状态记录;后端 ASP.NET Core 两个接口(接收分片/合并文件)+ 临时目录隔离 + 严格路径清洗 + 后台垃圾清理。这套组合在我目前维护的多个内网项目中已经稳定运行了大半年,最大上传过 12GB 的文件夹,没出过大乱子。
最后提醒一句:如果你的项目允许直接对目录做映射盘访问(比如内网机器全在一个域里),Web 上传可能本身就不是最优方案,直接用 Robocopy 或共享文件夹更快。但如果你必须做成 Web 界面、必须跨浏览器操作、必须让领导在浏览器里拖文件夹,那分片上传 + 自研后端接口这条路,依然是最可控的选择。
