ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C#文档管理系统实战:权限、版本、在线编辑与分片上传

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.AddDbContext<DocDbContext>(opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("DocDb"))); // 业务服务按请求作用域注册,内部持有 DbContext builder.Services.AddScoped<IDocumentService, DocumentService>(); builder.Services.AddScoped<IVersionService, VersionService>(); builder.Services.AddScoped<IPermissionEvaluator, PermissionEvaluator>(); builder.Services.AddScoped<IAuditLogger, AuditLogger>(); // 存储与缓存无状态,单例即可;换对象存储只替换这一行实现 builder.Services.AddSingleton<IFileStorage, PhysicalFileStorage>(); builder.Services.AddSingleton<ICacheStore, 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", "{controller=Home}/{action=Index}/{id?}"); app.MapHub<NotifyHub>("/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 Task<int> 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.TryGet<int>(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 Task<IActionResult> 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 Task<IActionResult> 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 Task<long> 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 秒上传同一份文件两次,看返回dedup=true
在线编辑编辑保存后版本号 +1打开版本历史核对 VersionNo
并发保存后提交方收到冲突提示两个账号同时编辑同一段并交替保存
版本回滚回滚后内容等于目标版本,且产生新版本号回滚到 v2,再查历史应为 v4
审计上述所有操作都有记录查Doc_AuditLog按时间倒序
性能目录列表接口 P95 < 500ms(1 万文档量级)造数据后压测,看慢查询日志

答辩演示建议固定成八步脚本,从登录一路演到回滚,中间刻意穿插一次权限拒绝和一次并发冲突——评委最爱问的恰恰是「如果两个人同时改会怎样」,你当场演示出来,比解释十分钟都管用。常被追问的三个问题也提前准备好答案:为什么物理文件不入库(备份策略与数据库体积)、为什么用软删除(审计与误删恢复)、权限缓存失效怎么保证(全局版本号而非精确删除)。

压测不用做得太复杂,本地起两个实例,用脚本并发 50 个上传请求、200 个列表请求,观察应用内存曲线和数据库连接池占用。真正要看的是「并发上传时列表页有没有被拖慢」,如果被拖慢,说明上传和查询共用了一个线程池或数据库连接池,得把文件 IO 部分拆出去。

从那以后我每次交付这类系统,都会强制走一遍这张验收表,尤其是并发保存和撤权生效这两项——它们最容易在演示时「恰好没触发」,然后在真实使用第一周集中爆发。你要是正在做文档管理方向的毕设或课程设计,建议把这张表直接抄进你的验收章节,答辩时会显得特别扎实。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表