C#文档管理系统实战:权限、版本、在线编辑与分片上传
简介《C#实现的文档管理系统》是一套面向Web开发初学者与中小团队文档协作场景的完整源码项目基于C#与ASP.NET构建后端采用SQL Server存储文档及元数据重点解决文档在线新建、编辑、预览、下载与权限控制等需求。压缩包共98个文件约1.31MB以32个cs源码、14个aspx页面、7个js脚本和4个css样式为主另含数据库、程序集、配置及解决方案文件覆盖代码、页面、样式与数据环境。已有557人学习下载适合拆解ASP.NET Web Forms项目的分层组织、数据库连接和页面交互流程。读者可了解在线编辑的实现思路、基于角色的访问控制、文档版本与操作日志等模块设计并借助使用手册和测试分析报告梳理需求、部署与测试环节对课程设计、毕业项目或小型文档管理系统原型开发有较直接的参考价值。1. 从一份对不上版本的归档说起这套 C# 文档管理系统要解决的问题项目收尾要交归档合同、图纸、验收单散在十几个人手里最后拼出来的那份永远有一两个版本对不上——这是这套 C# 文档管理系统最原始的需求来源。它不打算做成网盘而是把「谁在什么时候改了哪一版、谁能看、谁能改」这几件事固定下来目录树统一存放浏览器里直接在线编辑每次保存自动生成版本权限按用户和部门两级收口所有操作留审计日志。技术栈走的是 ASP.NET Core C#服务端渲染权限在服务端拦截不依赖前端自觉。这套结构适合正在做文档管理毕设或课程设计的同学照着搭也适合要给内部出一套轻量文档平台、需要一份能落地结构的从业者直接改。下面从需求拆解一路写到验收方法。2. 需求拆解与技术选型四类角色、五张表和一个不能省的分层做文档管理最容易犯的错是一上来就照着网盘的截图规划功能做到一半发现权限没收口、版本没法回滚。我一般的做法是先把角色和他们的真实操作路径写下来功能边界自然就出来了技术选型也才有依据。2.1 四类角色的操作路径决定了功能边界普通员工进来只干四件事在自己有权限的目录里找文档、在线看一眼、改一版、传一份新的。他们不关心目录是谁建的但非常在意「我刚才改的内容有没有存上」和「我怎么找不到上次那版」。部门负责人多两件事把本部门的目录共享给其他部门有时间范围以及看本部门文档的操作记录。这里有个反直觉的点——共享不应该改文档归属只应该多一条授权记录。我在早期版本里图省事共享时直接把DeptId改掉结果原部门的人集体失联回滚了半天数据。文档管理员负责目录结构的维护、回收站、分类标签和存储配额系统管理员只管账号、角色、部门树。这两类角色必须分开不然管理员账号就是超级权限黑匣子。把这些路径落成功能清单边界就清楚了目录树新建、重命名、拖拽移动、软删除、回收站恢复上传大文件分片、断点续传、相同文件秒传预览与在线编辑Office 文档、PDF、图片、纯文本版本历史列表、任意两版对比、回滚权限用户 / 部门 / 角色三种授权对象查看、下载、编辑、删除、授权五个权限位检索文件名 正文关键字支持按目录、按时间、按上传人过滤审计谁、什么时候、对哪个文档、做了什么、IP 是什么明确不做的也要写下来不做即时通讯、不做对外的大流量分享、不做多租户隔离。这三条砍掉整个系统的复杂度下降一大截课设周期内能做完从业者交付也不会延期。2.2 技术选型为什么是 ASP.NET Core 而不是拼一堆积木选型这块我把当时的对比表直接放出来参数都是实际项目里关心的维度不是凑数。层次选型主要理由备选与代价Web 框架ASP.NET Core MVC服务端渲染权限在 Filter 层统一拦截Razor 上手快前后端分离要多写一套接口和鉴权课设周期不划算数据访问EF Core 少量原生 SQL迁移脚本可版本化树形查询用递归 CTE 补全用 Dapper 写 SQL 更快但迁移和实体维护成本高数据库SQL Server / PostgreSQL事务、递归查询、全文索引都齐换库时注意分页语法和大小写敏感性差异缓存Redis权限位、上传分片状态、目录树节点单机部署可用内存缓存顶上但要接受多实例失效实时通知SignalR编辑占用提示、审批提醒轮询也能做代价是延迟和空请求文件存储本地磁盘 IFileStorage接口课设零依赖交付时换对象存储只改实现直接写死本地路径后期迁移要改一堆调用点在线编辑独立的文档编辑服务前端渲染 后端回调不自己造编辑器内核自研编辑内核是另一个项目的工作量有一句血泪经验不要试图自己写 Office 文档的在线渲染。渲染引擎的排版兼容性是个无底洞做到最后你会发现 80% 的时间花在「为什么这个表格的边框在浏览器里歪了两像素」。正确做法是把编辑能力交给一个独立的文档编辑服务后端只负责三件事给它一个带签名、有时效的文档地址接收它的保存回调校验回调里带的权限。服务注册和中间件顺序写在Program.cs里这段是整个系统的地基// Program.cs —— 服务注册与中间件顺序顺序错了鉴权会静默失效 var builder WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddDbContextDocDbContext(opt opt.UseSqlServer(builder.Configuration.GetConnectionString(DocDb))); // 业务服务按请求作用域注册内部持有 DbContext builder.Services.AddScopedIDocumentService, DocumentService(); builder.Services.AddScopedIVersionService, VersionService(); builder.Services.AddScopedIPermissionEvaluator, PermissionEvaluator(); builder.Services.AddScopedIAuditLogger, AuditLogger(); // 存储与缓存无状态单例即可换对象存储只替换这一行实现 builder.Services.AddSingletonIFileStorage, PhysicalFileStorage(); builder.Services.AddSingletonICacheStore, RedisCacheStore(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(o { o.LoginPath /Account/Login; o.ExpireTimeSpan TimeSpan.FromHours(8); // 与办公时段对齐减少重复登录 o.SlidingExpiration true; }); builder.Services.AddAuthorization(); builder.Services.AddSignalR(); var app builder.Build(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); // 必须在 UseAuthorization 之前反了就是所有人匿名可访问 app.UseAuthorization(); app.MapControllerRoute(default, {controllerHome}/{actionIndex}/{id?}); app.MapHubNotifyHub(/hub/notify); app.Run();几个参数值得单独说ExpireTimeSpan设 8 小时是按办公时段来的设太短用户一天要登录三四次设太长权限变更后旧会话还能用SlidingExpiration打开后活跃用户会自动续期IFileStorage注册成单例是因为它不持有请求上下文如果你在里面塞了DbContext那就必须改成 Scoped否则会撞上并发复用的问题。2.3 数据表设计五张表撑起目录、版本、权限、日志表设计的关键决策只有一条物理文件内容不进数据库数据库只存元数据和版本指针。这样备份策略可以分开做——数据库天天全量文件存储按增量同步。-- 目录与文档共表靠 NodeType 区分移动和权限都只针对这张表 CREATE TABLE Doc_Node ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, ParentId BIGINT NULL, -- 根目录为 NULL递归查询时作为终止条件 Name NVARCHAR(200) NOT NULL, NodeType TINYINT NOT NULL, -- 0文件夹 1文件 OwnerId BIGINT NOT NULL, DeptId BIGINT NOT NULL, -- 数据域共享时不改这里 CurrentVersionId BIGINT NULL, -- 只存当前版本指针历史在版本表 IsDeleted BIT NOT NULL DEFAULT 0, -- 软删除回收站靠它 CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); CREATE INDEX IX_Doc_Node_Parent ON Doc_Node(ParentId, IsDeleted); -- 版本表只增不改回滚不是删除记录而是插入一条新版本 CREATE TABLE Doc_Version ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, NodeId BIGINT NOT NULL, VersionNo INT NOT NULL, -- 从 1 递增用于乐观锁比对 StorageKey NVARCHAR(300) NOT NULL, -- 物理文件相对路径 Size BIGINT NOT NULL, Sha256 CHAR(64) NOT NULL, -- 秒传与去重靠它 EditorId BIGINT NOT NULL, CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT UQ_Version_Node_No UNIQUE (NodeId, VersionNo) ); CREATE INDEX IX_Version_Hash ON Doc_Version(Sha256); -- 授权表SubjectType 支持用户/部门/角色三种主体RightMask 是权限位 CREATE TABLE Doc_Permission ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, NodeId BIGINT NOT NULL, SubjectType TINYINT NOT NULL, -- 0用户 1部门 2角色 SubjectId BIGINT NOT NULL, RightMask INT NOT NULL, -- 1查看 2下载 4编辑 8删除 16授权 ExpireAt DATETIME2 NULL, -- 共享给外部门的临时授权靠它过期 CONSTRAINT UQ_Perm_Node_Subject UNIQUE (NodeId, SubjectType, SubjectId) ); -- 审计日志只写不改按月分区或定期归档 CREATE TABLE Doc_AuditLog ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, NodeId BIGINT NULL, UserId BIGINT NOT NULL, Action NVARCHAR(40) NOT NULL, -- Upload/Edit/Download/Delete/Grant Detail NVARCHAR(1000) NULL, ClientIp NVARCHAR(64) NULL, CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME() );Doc_Node.CurrentVersionId和Doc_Version.VersionNo这一对是版本回滚的关键回滚时不要删旧版本、也不要把VersionNo改回去而是插入一条新版本内容复制自目标版本再把指针移过去。这样历史永远是线性的审计日志里也能看到「某人回滚到了第 3 版」这个动作本身。ExpireAt这个字段很多人会漏等到共享链接三个月后还能打开才发现权限没法自动回收。3. 总体架构设计五层结构与三个核心模块的职责边界系统一旦超过三四个模块代码就会开始出现「A 服务调 B 服务、B 又反过来调 A」的循环依赖。解决办法不是靠自觉而是在分层上把依赖方向定死。3.1 分层与依赖方向只允许上层依赖下层整体分成五层依赖严格单向跨层调用一律禁止表现层MVC 控制器 Razor 视图 少量前端脚本只做参数校验、调用应用服务、组装视图模型应用服务层DocumentService、VersionService、ShareService负责编排业务流程和事务边界领域层权限判定、版本规则、目录约束纯业务逻辑不碰数据库数据访问层EF Core 的DbContext与仓储只做数据进出基础设施层文件存储、缓存、审计落库、通知推送这个分法的好处在权限模块上体现得最明显PermissionEvaluator属于领域层它需要数据但不直接持有DbContext而是通过仓储接口取。这样单元测试里可以塞一个内存假仓储把「跨部门共享过期后应该拿不到编辑权」这类用例跑穿不用起数据库。3.2 权限判定别只做角色要做「角色 数据域 文档级授权」只做 RBAC 的系统最后一定会被「这份合同只能给财务部三个人看」这种需求打败。所以我用的是三层叠加角色决定基础能力管理员、普通用户、只读访客数据域决定默认可见范围同部门可见文档级授权决定例外情况单独授权给某人或某部门。判定顺序是从文档往根目录回溯取并集。这里有个容易写错的地方不能只查当前节点的授权因为父目录上授的权对子节点同样有效。public sealed class PermissionEvaluator : IPermissionEvaluator { // 权限位1查看 2下载 4编辑 8删除 16授权 private readonly IPermissionRepository _repo; private readonly ICacheStore _cache; public async Taskint GetEffectiveMaskAsync(long nodeId, UserContext user) { if (user.IsSystemAdmin) return 31; // 系统管理员全权但不参与业务授权 if (!user.IsActive) return 0; // 停用账号不参与任何缓存直接拒 var cacheKey $perm:{nodeId}:{user.UserId}:{user.PermVersion}; if (_cache.TryGetint(cacheKey, out var cached)) return cached; // 从自身到根目录的路径近的在前 var path await _repo.GetAncestorIdsAsync(nodeId); int mask 0; foreach (var id in path) { var rules await _repo.GetRulesAsync(id, user.UserId, user.DeptId, user.RoleIds); foreach (var r in rules) { // 过期授权视为不存在别指望定时任务清表 if (r.ExpireAt.HasValue r.ExpireAt.Value DateTimeOffset.UtcNow) continue; mask | r.RightMask; } } // 同部门默认可见只给查看和下载编辑权必须显式授予 if (await _repo.GetDeptIdAsync(nodeId) user.DeptId) mask | 3; // 创建者默认可编辑但不能自我授权 if (await _repo.GetOwnerIdAsync(nodeId) user.UserId) mask | 6; _cache.Set(cacheKey, mask, TimeSpan.FromMinutes(10)); return mask; } }UserContext里的PermVersion是个全局版本号任何一次授权变更都把它自增缓存键随之变化旧缓存自然失效。这比「变更时精确失效某个 key」靠谱得多——后者只要漏掉一条路径就会出现「权限改了但页面还能看到」的经典事故。3.3 模块职责表把接口边界写死模块职责对外接口允许依赖DocumentService目录增删改移、软删除与恢复CreateFolder/Move/Recycle仓储、审计、权限VersionService建版本、查历史、回滚、哈希去重CommitAsync/RollbackAsync存储、仓储、审计PermissionEvaluator计算最终权限位GetEffectiveMaskAsync仓储、缓存FileStorage分片落盘、合并、读取流、算哈希IFileStorage无AuditLogger写操作流水LogAsync仓储SearchService文件名与正文关键字检索QueryAsync检索索引接口边界定死之后最直接的好处是控制器变薄。控制器里只允许出现三件事取当前用户、调一个服务方法、返回结果或视图。任何在控制器里if判权限的写法都是后面权限漏洞的源头。4. 在线编辑与上传落地从分片续传到版本回滚的完整链路文档管理系统真正难的不是列表页而是「传得上去、改得动、存得住、退得回」这四件事。这一章把这四条链路逐个拆开。4.1 分片上传、秒传与断点续传直接把整个文件ReadAllBytes读进内存再写库是这类系统最常见的翻车起点。几十兆的招标文件并发五个人传服务器内存就没了。正确的做法是前端切片、后端流式落盘、最后合并。[HttpPost(api/upload/chunk)] [RequestSizeLimit(8 * 1024 * 1024)] // 单分片 8MB前端按此切分 public async TaskIActionResult UploadChunk([FromForm] ChunkDto dto) { // uploadId 由 init 接口下发GUID不接受客户端自定义路径防目录穿越 if (!await _uploadSessions.ExistsAsync(dto.UploadId, UserId())) return BadRequest(new { code SESSION_EXPIRED }); var chunkPath _storage.ResolveChunkPath(dto.UploadId, dto.Index); await using var fs new FileStream(chunkPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); await dto.File.CopyToAsync(fs); // 流式复制内存占用与文件大小无关 return Ok(new { index dto.Index, received dto.File.Length }); }RequestSizeLimit是兜底防线设成单分片大小的 1.5 倍左右太大会让攻击者一次塞进来几百兆useAsync: true配合 81920 字节缓冲区是磁盘写入比较稳的组合uploadId由服务端下发而不是客户端指定这一条能挡掉绝大部分路径穿越尝试。合并阶段做三件事校验分片数量、按序拼接、算 SHA256 查秒传。[HttpPost(api/upload/complete)] public async TaskIActionResult Complete([FromBody] CompleteDto dto) { if (!await _storage.AllChunksExistAsync(dto.UploadId, dto.Total)) return BadRequest(new { code CHUNK_MISSING }); // 让前端从缺的那片续传 var target _storage.ResolveFilePath(dto.UploadId); await _storage.MergeChunksAsync(dto.UploadId, dto.Total, target); var hash await _storage.ComputeSha256Async(target); var exist await _versionRepo.FindByHashAsync(hash); // 全库范围内找相同内容 long versionId; if (exist ! null) { // 秒传物理文件复用引用计数 1省一次落盘 await _storage.AddRefAsync(exist.StorageKey); versionId await _versionService.CommitAsync(dto.NodeId, exist.StorageKey, hash, UserId()); await _storage.DeleteQuietlyAsync(target); // 临时文件清掉 } else { versionId await _versionService.CommitAsync(dto.NodeId, target, hash, UserId()); } return Ok(new { versionId, dedup exist ! null }); }秒传的前提是哈希去重表上有索引同时物理文件必须做引用计数。没有引用计数就做去重删文档时会把别人还在用的文件一起删掉——这个 bug 在现场排查起来极难因为文件「有时候在有时候不在」。4.2 在线编辑的保存链路签名令牌 回调校验在线编辑的整条链路是这样的用户点「编辑」后端生成一个带签名和过期时间的令牌把令牌和文档地址交给编辑服务编辑服务打开文档用户保存时编辑服务回调后端的保存接口并原样带回令牌后端验签、验权限、落新版本再返回成功。令牌必须同时绑定节点、用户和操作类型不能只绑节点。否则 A 用户拿到编辑令牌后理论上可以在有效期内反复以 A 的身份写同一个文档而他可能早已被移出这个部门。public string BuildEditorToken(long nodeId, long userId, string mode, TimeSpan ttl) { // payload 里带 nodeId/userId/mode/过期时间回调时原样带回做二次校验 var exp DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds(); var payload ${nodeId}|{userId}|{mode}|{exp}; var sig Convert.ToHexString( HMACSHA256.HashData(_secret, Encoding.UTF8.GetBytes(payload))); return ${Convert.ToBase64String(Encoding.UTF8.GetBytes(payload))}.{sig}; } public bool TryParseToken(string token, out EditorToken parsed) { parsed default; var parts token.Split(.); if (parts.Length ! 2) return false; var payload Encoding.UTF8.GetString(Convert.FromBase64String(parts[0])); var expect Convert.ToHexString( HMACSHA256.HashData(_secret, Encoding.UTF8.GetBytes(payload))); // 定长比较避免逐字节比较泄漏时序信息 if (!CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(expect), Encoding.ASCII.GetBytes(parts[1]))) return false; var f payload.Split(|); parsed new EditorToken(long.Parse(f[0]), long.Parse(f[1]), f[2], long.Parse(f[3])); return parsed.ExpiresAt DateTimeOffset.UtcNow.ToUnixTimeSeconds(); }保存回调进来之后顺序不能乱先验签再查令牌里的用户当前是否还有编辑权权限可能在编辑期间被撤销然后比对版本号做并发控制最后才落盘建版本。ttl一般给 30 到 60 分钟太短用户在编辑过程中会被踢出去太长令牌泄漏风险上升。4.3 版本管理只增不改回滚靠新版本版本这块的规则只有三句话每次保存生成一条新版本记录内容哈希没变就不生成新版本避免用户打开又保存产生一堆空版本回滚不删历史而是复制目标版本内容生成新版本。public async Tasklong CommitAsync(long nodeId, string storageKey, string hash, long editorId) { var node await _nodeRepo.GetForUpdateAsync(nodeId); // 行锁防并发提交 var lastNo await _versionRepo.GetMaxVersionNoAsync(nodeId); // 内容未变则不产生新版本直接把当前指针返回 if (node.CurrentVersionId.HasValue) { var cur await _versionRepo.GetAsync(node.CurrentVersionId.Value); if (cur.Sha256 hash) return cur.Id; } var version new DocVersion { NodeId nodeId, VersionNo lastNo 1, // 唯一索引兜底并发下重复会直接抛异常而不是写脏数据 StorageKey storageKey, Sha256 hash, EditorId editorId, Size await _storage.GetSizeAsync(storageKey) }; await _versionRepo.AddAsync(version); node.CurrentVersionId version.Id; await _nodeRepo.SaveChangesAsync(); await _audit.LogAsync(nodeId, editorId, Edit, $v{version.VersionNo}); return version.Id; }GetForUpdateAsync里加行锁SQL Server 用UPDLOCK提示PostgreSQL 用FOR UPDATE配上(NodeId, VersionNo)的唯一约束是并发保存的双保险。少了锁两个人同时保存会撞唯一索引报错少了唯一索引只靠锁在分布式部署下又防不住。两个都留着出问题时至少能快速定位是谁先写进去的。5. 避坑与排查这套系统最容易翻车的五个地方下面五条都是我在类似项目里真实踩过或帮别人排查过的按「现象 → 原因 → 解决」写遇到同类问题可以直接对照。5.1 上传与存储环节的三个坑第一个坑上传几十兆文件时服务器 CPU 和内存同时打满页面卡死。现象是单个用户传一个大文件其他用户的请求全部超时。原因基本可以锁定在把文件整体读进内存——IFormFile直接CopyToAsync到内存流、或者先ReadAllBytes再写盘。解决方式是全程用FileStream流式落盘关掉 MVC 的模型绑定缓冲把RequestSizeLimit和MultipartBodyLengthLimit设成合理值同时确认反向代理那一层也放开了请求体大小限制。很多人只改了应用配置忘了前置代理默认限制结果永远在 413 上打转。第二个坑中文名文件下载时文件名变成乱码或者直接 404。现象是浏览器下载下来的文件名是一串百分号编码或者点击下载提示找不到资源。原因是Content-Disposition头里直接塞了原始中文没有按 RFC 5987 做编码404 则多半是存储用的物理文件名直接用了用户上传的名字路径里带空格或特殊字符。解决办法是物理文件一律用 GUID 命名原始文件名单独存字段下载时用filename*UTF-8形式输出。第三个坑删了文档磁盘空间没降。现象是后台文档数少了一半磁盘占用纹丝不动。原因是软删除后物理文件还在且秒传去重让一个物理文件被多个版本引用。解决方式是给物理文件加引用计数或者干脆按StorageKey反查引用数删除动作只减计数计数归零后进入延迟清理队列再由定时任务统一删。千万别在删除接口里直接File.Delete那是给未来的自己埋雷。5.2 编辑与并发环节的两个坑第四个坑两个人同时编辑后保存的覆盖了前一个人的内容。现象是用户投诉「我明明改了第三章提交完发现还是旧的」。原因是保存回调里没有做版本比对谁最后到谁赢。解决办法是在保存回调里带上客户端拿到的VersionNo服务端比对当前版本号不一致就返回冲突让前端提示「文档已被他人更新请刷新后重试或另存为副本」。这个机制叫乐观锁实现成本很低但没有它多人协作场景下丢内容是必然事件。第五个坑权限改了用户刷新页面还是能看到不该看的文档。现象是管理员撤销了某人的部门授权对方还能打开。原因通常是权限位被缓存了而变更时没有让缓存失效。解决办法是前面提到的全局PermVersion方案授权变更时把目标用户的版本号自增缓存键随之变化旧缓存自然不命中。另一个常见原因是前端把目录树缓存到了 localStorage用户不刷新就一直看旧的——所以权限判定必须在服务端每次请求都做前端的隐藏只是体验优化不能当安全边界。排查这类问题的通用手法是打一条完整链路日志请求进来时记录userId、nodeId、目标权限位判定过程记录命中了哪几条规则、最终mask是多少。这几十行日志在出问题时能省下大半天。6. 验收与答辩怎么证明这套文档管理系统真的能用功能跑通不等于能交付。我一般用一张验收表把「能演示」和「能扛住」分开答辩时按表走比临场发挥稳得多。验收项判定标准验证方法目录与权限撤权后 10 秒内不可见双浏览器不同账号交叉验证大文件上传200MB 文件中断后能续传成功传一半关浏览器重开后继续秒传相同文件二次上传耗时 1 秒上传同一份文件两次看返回deduptrue在线编辑编辑保存后版本号 1打开版本历史核对 VersionNo并发保存后提交方收到冲突提示两个账号同时编辑同一段并交替保存版本回滚回滚后内容等于目标版本且产生新版本号回滚到 v2再查历史应为 v4审计上述所有操作都有记录查Doc_AuditLog按时间倒序性能目录列表接口 P95 500ms1 万文档量级造数据后压测看慢查询日志答辩演示建议固定成八步脚本从登录一路演到回滚中间刻意穿插一次权限拒绝和一次并发冲突——评委最爱问的恰恰是「如果两个人同时改会怎样」你当场演示出来比解释十分钟都管用。常被追问的三个问题也提前准备好答案为什么物理文件不入库备份策略与数据库体积、为什么用软删除审计与误删恢复、权限缓存失效怎么保证全局版本号而非精确删除。压测不用做得太复杂本地起两个实例用脚本并发 50 个上传请求、200 个列表请求观察应用内存曲线和数据库连接池占用。真正要看的是「并发上传时列表页有没有被拖慢」如果被拖慢说明上传和查询共用了一个线程池或数据库连接池得把文件 IO 部分拆出去。从那以后我每次交付这类系统都会强制走一遍这张验收表尤其是并发保存和撤权生效这两项——它们最容易在演示时「恰好没触发」然后在真实使用第一周集中爆发。你要是正在做文档管理方向的毕设或课程设计建议把这张表直接抄进你的验收章节答辩时会显得特别扎实。希望帮到你。本文还有配套的精品资源点击获取