☰
C#实现大文件秒传:图纸管理系统的分块上传与断点续传方案
2026/10/9 12:42:24 网站建设 项目流程

去年给一家非标自动化设备厂做图纸管理平台,遇到一个特别典型的场景:设计部把一套包含外购件模型的三维装配体导出成STEP文件传到服务器,3.8GB。内网千兆带宽,理论上只要几十秒,但文件服务器上还跑着一堆旧业务系统,磁盘IO经常打满,传输中断是家常便饭。工程师反复重传,最后直接打电话骂我,说这网页上传功能就是摆设。

后来我们把网页上传模块改造成基于C#的大文件秒传方案:前端先把整个STEP文件的哈希值发给后端,后端查一下库里有没有同内容文件。有,就直接返回“上传成功”,前端跳转下一步,整个过程不到1秒。没有,再按分块上传,传完合并复校哈希,断点续传也一起做掉。这篇文章就把这套方案拆开讲清楚:C#后端如何写秒传判断和分块合并接口,前端如何计算文件指纹、分块上传、失败续传,以及机械制造行业的图纸上传落地时有哪些绕不开的坑。

1. 先理解机械制造行业的“上传痛点”和秒传的适用场景

1.1 车间里的真实上传场景:三维模型、STEP、DWG、数控程序

机械制造行业和普通办公文件上传最大的区别,就是文件又大又多。普通企业传个PPT,撑死几十MB;制造企业里一份完整的三维装配体,动辄几个GB。常见的文件类型包括:

  • SolidWorks的零件和装配体文件:SLDPRT、SLDASM
  • 通用交换格式:STEP、STP、IGES、X_T
  • AutoCAD图纸:DWG、DXF
  • 数控加工程序:NC、CNC、TAP
  • 仿真分析结果和点云数据:可能有几十GB

这些文件在项目流转过程中会被反复传递。我遇到的情况是:设计部导出一个外购标准件的STEP模型,放到公共盘,工艺部又下载下来,再传到PLM系统。内容完全一样,只是人人都在重复传一遍。这种重复,正是秒传最值得发力的地方。

1.2 秒传适合机械制造行业的三个理由

第一,文件重复率高。机械行业的外购件模型、标准件库、通用夹具模型,在不同项目里被反复借用。同一个型号的液压阀模型,可能有二十个工程师都在传。只要有一个传上去,后面再传的人都能秒传。

第二,文件体积大,重复传输代价高。几GB的模型文件反复传,不仅占带宽,还占服务器磁盘临时空间。每次断点续传失败,磁盘上还会残留一堆垃圾分块,运维清理起来很头疼。

第三,使用网页上传的场景越来越多。很多企业开始把图纸管理、工艺文件下发、供应商协同放到B/S系统里,C#的ASP.NET Core是这类系统里非常常见的后端技术。网页上传天然受浏览器限制,大文件没有原生优势,必须在上传逻辑上做文章。

其中需要先说明的是:秒传并不是真的能把文件压缩或者瞬间传上去,而是跳过已经存在的传输过程。它能解决的是“重复上传”的问题,解决不了“第一次上传慢”的问题。第一次上传仍然依赖分块上传和断点续传。

2. 秒传的本质:先证明“文件已经在库”,再决定要不要传

2.1 秒传判断链路:文件指纹、内容索引、引用关系

秒传的判断过程其实很朴素:

  1. 前端读取文件内容,计算出一个固定长度的哈希值,一般用MD5或SHA-256。
  2. 把哈希值、文件大小、文件名一起发给后端。
  3. 后端拿哈希值和文件大小去数据库里查,看有没有完全一致的历史文件。
  4. 如果存在,说明服务器上已经有同一份文件,就直接生成一条新的上传记录,指向旧文件,返回“秒传成功”。
  5. 如果不存在,就进入真正的分块上传流程。

这里有个理念转变:上传记录不一定要对应一个独立物理文件,它可以引用一个已有的物理文件。数据库里存的是文件的元数据和内容指纹,物理文件则作为不可变对象保存。这样,秒传不是复制一份数据,而是增加一条引用。机械行业的图纸版本管理尤其适合这种逻辑,因为同一份模型文件可能同时被多个项目引用。

2.2 为什么文件名和文件大小不能作为秒传依据

很多非技术同事会问:直接用文件名判断不就行了?同名的文件就是同一个文件。实际完全不是这样。

用文件名判断,最大的问题是不同内容的文件完全可以同名。比如设计部有三个人都导出了“支撑座.STEP”,但可能分别对应三个不同版本。如果按文件名秒传,就会出现A上传了老版本,B传新版本时被误判为已存在,导致全厂人都下载到老版本,这是致命的。

文件大小也不能单独作为依据。两个不同结构的模型,文件大小完全可能一样,几GB的大文件更是如此。

我一般用下面这个组合来判断:

判断字段作用注意点
文件哈希内容指纹,核心依据一般用MD5或SHA-256,内部系统用MD5足够
文件大小辅助过滤理论上不同内容也可能同大小,所以不能单独用
文件扩展名/类型业务校验防止传错格式,但与是否秒传无关

后端在建立索引时,推荐用(FileHash, FileSize)作为唯一约束。哈希碰撞的概率极低,再加上大小过滤,实际误判率几乎可以忽略。

2.3 秒传的边界:首次上传、内容修改、数据库膨胀

秒传不是万能的,有三个边界必须清楚。

**首次上传永远无法秒传。**服务器上从来没有的内容,再怎么查哈希都不可能查到,只能老老实实传完整文件。所以一个设计合理的上传组件,必须把“秒传检查”和“分块上传”结合,而不是只做秒传判断。

**文件内容只要修改过,秒传就会失效。**CAD里任何一个圆弧参数变化,导出的文件哈希都会完全不同。这是优点也是麻烦。优点在于不会误判,麻烦在于设计人员频繁改版时,秒传的命中率会下降。应对办法是结合版本管理,让用户明确知道自己在传哪个版本。

**数据库不能无限膨胀。**每条秒传记录都对应一个文件索引,如果同一个内容被100个人引用,索引表只有一条物理记录,但引用表会有100条。如果系统长期不管,垃圾文件、无效引用会越来越多。所以秒传系统必须同步做文件引用计数和定时清理,否则后续磁盘空间会逐渐耗尽。

3. C#后端实现:把秒传判断接进ASP.NET Core上传接口

3.1 技术选型:ASP.NET Core + SQLite + 本地磁盘存储

这套方案我选择ASP.NET Core Minimal API实现。原因很直接:机械行业的内部系统大多是Windows Server部署,开发人员对C#的熟悉度普遍高于Java或Node.js。Minimal API代码量少,没有Controller那一套模板,适合快速集成到现有项目中。

存储方面,中小型制造企业用本地磁盘就行。文件放到专门的D:\\FileStore\\Uploads这类目录下,数据库里只记录路径和哈希。等系统规模大了再平滑迁移到对象存储,但接口设计要保持不变。

先定义一个数据模型:

public class UploadedFile { public string Id { get; set; } public string FileName { get; set; } public long FileSize { get; set; } public string FileHash { get; set; } public string StoragePath { get; set; } public DateTime CreateTime { get; set; } public int RefCount { get; set; } }

引用关系可以单独建表,也可以用RefCount计数。我建议单独建引用表,因为后续要做引用方追溯,比如查出“这个模型被哪些订单用了”。

3.2 秒传判断接口:/api/upload/precheck

秒传判断接口接收前端传来的文件名、文件大小、文件哈希。查询数据库时,同时匹配哈希和大小,条件严格一点,避免误判。

app.MapPost("/api/upload/precheck", async (PreCheckRequest req, UploadDbContext db) => { var same = await db.UploadedFiles .FirstOrDefaultAsync(f => f.FileHash == req.FileHash && f.FileSize == req.FileSize); if (same != null) { same.RefCount++; await db.SaveChangesAsync(); return Results.Ok(new { instantSuccess = true, uploadId = same.Id, message = "文件已存在,秒传成功" }); } var uploadId = Guid.NewGuid().ToString("N"); return Results.Ok(new { instantSuccess = false, uploadId, message = "文件不存在,请分块上传" }); });

注意这里uploadId和最终的物理文件ID不是一回事。uploadId是临时会话ID,用于关联前端上传的各个分块,合并完成后才会生成真正文件的记录。

前端拿到instantSuccess = true,就直接显示上传成功,不用再走后面的分块流程。

3.3 分块上传与合并接口:从.part到完整文件

如果秒传检查不通过,前端会把文件切成多个块,一块一块POST到后端。后端先存成uploadId\\0.part、1.part这样的临时文件,等所有分块都到位后再合并。

接收分块的核心代码:

app.MapPost("/api/upload/chunk", async ( IFormFile file, [FromQuery] string uploadId, [FromQuery] int index, [FromServices] IWebHostEnvironment env) => { var root = Path.Combine(env.ContentRootPath, "FileStore", "Temp", uploadId); Directory.CreateDirectory(root); var chunkPath = Path.Combine(root, $"{index}.part"); await using (var fs = new FileStream(chunkPath, FileMode.Create, FileAccess.Write)) { await file.CopyToAsync(fs); } return Results.Ok(new { index = index, length = file.Length }); });

合并接口要做几件事:检查分块是否齐全、按顺序合并、删除临时分块、整体哈希复核、写入数据库。代码如下:

app.MapPost("/api/upload/merge", async (MergeRequest req, UploadDbContext db, IWebHostEnvironment env) => { var tempRoot = Path.Combine(env.ContentRootPath, "FileStore", "Temp", req.UploadId); if (!Directory.Exists(tempRoot)) { return Results.BadRequest("上传会话不存在"); } var missing = Enumerable.Range(0, req.TotalChunks) .Where(i => !System.IO.File.Exists(Path.Combine(tempRoot, $"{i}.part"))) .ToList(); if (missing.Count > 0) { return Results.BadRequest($"缺少分块: {string.Join(",", missing)}"); } var finalDir = Path.Combine(env.ContentRootPath, "FileStore", "Files"); Directory.CreateDirectory(finalDir); var finalName = $"{Guid.NewGuid():N}_{req.FileName}"; var finalPath = Path.Combine(finalDir, finalName); await using (var finalFs = new FileStream(finalPath, FileMode.CreateNew, FileAccess.Write)) { for (var i = 0; i < req.TotalChunks; i++) { var partPath = Path.Combine(tempRoot, $"{i}.part"); await using var partFs = new FileStream(partPath, FileMode.Open, FileAccess.Read); await partFs.CopyToAsync(finalFs); } } // 整体哈希复核,防止网络传输或磁盘写入导致数据损坏 await using (var verifyFs = System.IO.File.OpenRead(finalPath)) { var md5 = System.Security.Cryptography.MD5.Create(); var hashBytes = await md5.ComputeHashAsync(verifyFs); var hash = Convert.ToHexString(hashBytes); if (!string.Equals(hash, req.FileHash, StringComparison.OrdinalIgnoreCase)) { System.IO.File.Delete(finalPath); Directory.Delete(tempRoot, true); return Results.BadRequest("文件哈希校验失败"); } db.UploadedFiles.Add(new UploadedFile { Id = Guid.NewGuid().ToString("N"), FileName = req.FileName, FileSize = new FileInfo(finalPath).Length, FileHash = hash, StoragePath = finalPath, CreateTime = DateTime.Now, RefCount = 1 }); await db.SaveChangesAsync(); } Directory.Delete(tempRoot, true); return Results.Ok(new { instantSuccess = false, url = $"/files/{finalName}" }); });

这里的关键点是,不能信任前端传的哈希作为最终结果。分块传输过程中可能出现磁盘坏道、临时文件被占用、网络分包导致内容错乱等问题,所以合并后必须由后端重新计算整个文件的哈希,与前端预检查时传的哈希做对比。不一致就删除文件,要求前端重新上传。

3.4 断点续传:查询已上传分块,跳过重复部分

断点续传和秒传是天然一对。秒传检查完发现文件不存在,用户传到一半掉线,重试时如果把所有分块重新传一遍,体验依然很差。正确做法是让前端先查一下这个uploadId下已经传了哪些分块,然后只传缺失部分。

后端查询接口:

app.MapGet("/api/upload/chunks", async (string uploadId, IWebHostEnvironment env) => { var root = Path.Combine(env.ContentRootPath, "FileStore", "Temp", uploadId); if (!Directory.Exists(root)) { return Results.Ok(new List<int>()); } var uploaded = Directory.GetFiles(root, "*.part") .Select(f => int.Parse(Path.GetFileNameWithoutExtension(f))) .OrderBy(i => i) .ToList(); return Results.Ok(uploaded); });

前端拿到已上传分块列表后,直接跳过这些索引,继续传剩余部分。这在大模型文件上传时非常实用,尤其机械行业员工经常在车间、办公室、供应商现场切换网络,断线频率远超办公室环境。

4. 前端要做的配合:哈希计算、分块、并发上传的完整流程

4.1 前端哈希计算工具的选型:spark-md5还是Web Crypto

后端秒传判断依赖前端传哈希,所以前端计算哈希的效率和准确性直接影响体验。

大文件不能一次性读进内存再算哈希,必须分块读取。比较成熟的前端方案是spark-md5,通过FileReader逐块读取文件内容,边读边算。样例代码如下:

async function computeFileHash(file) { return new Promise((resolve, reject) => { const chunkSize = 2 * 1024 * 1024; const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let offset = 0; function readNext() { const slice = file.slice(offset, offset + chunkSize); reader.readAsArrayBuffer(slice); } reader.onload = (e) => { spark.append(e.target.result); offset += chunkSize; if (offset < file.size) { readNext(); } else { resolve(spark.end()); } }; reader.onerror = reject; readNext(); }); }

这个方案的原理是增量计算,内存占用稳定,不会因为文件大而崩溃。缺点是计算大文件MD5需要一些时间,3.8GB的STEP文件在主流PC上可能需要几秒到十几秒。但相比重传统一文件的时间,这个成本完全可以接受。为了避免用户因为等待哈希而困惑,界面上可以做一个进度提示。

4.2 分块策略与并发上传实现

分块大小需要根据网络环境灵活设置。机械企业内部通常是千兆局域网,单块可以大一点,比如8MB或16MB;如果是供应商通过外网访问,建议5MB。后端接口不关心具体块大小,只关心索引和顺序。

前端分块上传的简化示例:

const chunkSize = 8 * 1024 * 1024; const totalChunks = Math.ceil(file.size / chunkSize); async function uploadChunk(uploadId, index, file) { const start = index * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); const form = new FormData(); form.append('file', blob, `${file.name}.part${index}`); const resp = await fetch(`/api/upload/chunk?uploadId=${uploadId}&index=${index}`, { method: 'POST', body: form }); if (!resp.ok) { throw new Error(`chunk ${index} upload failed`); } }

并发上传不是越多越好。分块较多时,同时发起所有请求会打爆浏览器连接数,也会让后端磁盘IO瞬间拉满。我建议用并发池控制,比如同时最多传3到5个分块。这在机械行业尤其重要,因为文件服务器可能是老旧机械硬盘,并发太高反而比串行更慢。

4.3 秒传失败后的续传策略

完整的上传流程应该是:

  1. 前端计算文件哈希。
  2. 调用/api/upload/precheck,如果秒传成功就直接结束。
  3. 秒传失败后,拿到uploadId。
  4. 调用/api/upload/chunks查询已上传分块。
  5. 遍历所有分块,跳过已上传的,其余用并发池上传。
  6. 所有分块传完后,调用/api/upload/merge合并。
  7. 合并成功后进入后续页面逻辑。

这里有一个容易被忽略的点:如果用户上传到一半刷新页面,得到的uploadId可能已经丢失。处理办法是用浏览器localStorage保存上传会话状态,刷新后根据uploadId继续。如果后端清理临时文件的策略是“超过24小时未合并就清理”,前端还可以在页面加载时向上传接口询问会话是否还存在,不存在则提示用户重新选择文件。

5. 机械制造行业落地的避坑笔记

5.1 常见文件类型与推荐分块大小

不同文件类型对分块大小的接受度其实差不多,但结合使用场景看,建议按下面方式配置:

文件类型典型大小建议分块备注
NC数控程序几十KB到几MB1MB即可文本文件,体积小,秒传意义有限
DWG图纸几MB到几百MB2MB-5MB常伴随外部参照,需注意业务逻辑
SLDPRT/SLDASM几百MB到几GB8MB-16MB内部局域网场景
STEP/STP几百MB到几十GB8MB-16MB外购件重复率高,秒传收益最大
仿真结果/点云几GB到几十GB16MB-32MB需要同时做好磁盘空间监测

从机械行业的实际使用看,几个GB的模型才是真正需要优化体验的点。几MB的文件即使不秒传,重传也很快,不值得做过分复杂的逻辑。

5.2 标准件库的高命中率:把秒传做进“借用流程”

机械制造企业很多都有标准件库,比如螺栓、轴承、液压阀、气缸、电机的三维模型。这些模型由少数人维护,全公司都在下载使用。如果直接拷贝到个人电脑后,再往系统里传,哈希其实是一样的,秒传命中率会极其高。

运营上有一个技巧:在网页上传界面上增加“标准件库检测”按钮。选择文件后,先查一下系统里有没有标准件库的分类索引。如果发现用户上传的文件哈希命中标准件库里的文件,直接把分类和物料编码带出来,自动填充到表单里。这样秒传不仅省了传输时间,还省了图纸分类的人工操作。

5.3 局域网和云端的存储路径规划

机械行业很多企业可能没有上云,文件存储就在本地Windows服务器上。这种情况下一定要避免把几万个文件直接堆在同一个目录里。文件目录建议按年月分:

FileStore ├── Files │ ├── 2025 │ │ ├── 01 │ │ ├── 02 │ │ └── 03 │ └── 2026 ├── Temp │ └── {uploadId} └── Trash

临时目录专门放分块,文件名就是{index}.part,不要带原始文件名,避免Windows路径过长问题。完整文件用GUID文件名加原文件名后缀,防止重名覆盖。

5.4 与Windows权限、杀毒软件的恩怨

机械制造企业常见的服务器问题是杀毒软件实时扫描。每上传一个分块,杀毒软件就要扫描这个.part文件,导致普通上传经常出现“传着传着就卡住不动”的情况。

解决办法有两条路:

  1. 在杀毒软件中排除文件存储目录。
  2. 使用专用存储分区,并把目录设为仅允许服务账户访问。

另外要注意Windows文件权限。ASP.NET Core的应用池账户如果不是管理员,可能没有权限在D:\\FileStore下创建目录和写文件。提前为应用池账户单独分配D:\\FileStore的读写权限,能避免很多莫名其妙的“目录不存在”报错。

5.5 数据库、日志、回收站的清理机制

秒传系统上线后,最容易被忽视的是垃圾数据。临时分块目录如果合并失败,会一直留着;数据库里如果记录了引用关系,但物理文件被手动删除,就会出现“秒传成功但下载404”。

我的建议是:

  • 建一个定时任务,清理超过24小时未合并的临时目录。
  • 数据库记录和物理文件做一致性检查,每天跑一次。
  • 删除文件时不要物理删除,先移动到回收站目录并标记DeletedAt,保留30天。机械行业经常有追责需求,文件不能随意彻底删。
  • 文件引用计数归零后才允许标记删除,否则会出现其他项目还在用,文件却没了的事故。

6. 我做这套系统的一点体会

秒传本身不是什么高深技术,真正让它在机械制造行业里发挥价值的,是对业务场景的理解。我后期在好几个企业里复盘过,发现只要外购件模型复用率高、项目制设计流程明显,秒传带来的效率提升都特别直接。但别忘了,秒传只是上传体验的一部分,真正考验系统的是首次上传大文件时的稳定性、合并失败后的恢复机制,以及长期运行后的文件生命周期管理。

如果你现在准备给自己的图纸管理系统做上传优化,先把“秒传+分块+断点续传”这三位一体做好,再考虑对接PLM、物料编码这些外围业务。第一步走稳了,后面的麻烦会少很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询