简介:这是一套基于C#与ASP.NET构建的Web文档管理系统源码,面向具备一定.NET基础、希望学习企业级文档管理应用开发的学生与开发者,可用于理解在线文档创建、编辑、存储与共享的完整实现思路。压缩包共98个文件,约1.31MB,以32个cs业务与实体代码、14个aspx页面、7个js脚本和4个css样式为主,同时包含SQL Server的mdf/ldf数据库文件、dll/pdb程序集、doc说明文档及少量图片资源,另有解决方案与配置项,基本覆盖从页面展示、交互脚本到服务器端逻辑与数据存储的主要层次。已有558人学习下载。系统围绕在线编辑、基于角色的权限管理、版本控制和操作日志展开,并附带测试分析报告与使用手册,读者可对照源码梳理ASP.NET页面事件处理、C#业务分层、数据库表关系设计以及文档权限校验的实现方式,也可将其作为课程设计、毕业设计或.NET Web开发练习的参考案例。
1. 一个部门共享盘引发的重写:这套 C# 文档管理系统适合谁
去年帮一个朋友的公司收拾烂摊子:几十号人共用一个网络共享盘,合同、方案、报价单按"最终版""最终版2""真的最终版"命名,谁改过、改了什么、能不能退回上一版,全靠翻聊天记录去问。他们想要的东西朴素得很——一套用 C# 写的、能在线编辑的文档管理系统,别再让人下载下来改完再传回去。这套基于 ASP.NET Core 的实现就是照着这个需求长出来的:目录树、上传下载、浏览器里直接改 Office 文档、版本回滚、按人按角色授权,一条链路走完。
它适合两类人:一类是团队十几到上百号人、手里全是 Word/Excel/PDF 要管、又不愿意把文件放到别人服务器上的中小团队;另一类是刚学完 C# 语法、想找一个有真实业务厚度的项目练手的开发者——文档管理这活儿看着简单,真做起来版本、并发、权限、文件落盘样样都有坑,比增删改查的 Demo 有嚼头得多。
2. 技术选型与整体架构:ASP.NET Core + EF Core 的一次务实组合
2.1 为什么是 ASP.NET Core 而不是 WebForms
先说清楚技术栈:ASP.NET Core(MVC + Web API 混合),EF Core 做 ORM,SQL Server 做业务库,ASP.NET Core Identity 管账号和角色,文件本体走本地磁盘(也可以换对象存储),在线编辑接一套开源的文档编辑服务(OnlyOffice Document Server 这类)跑在 Docker 里,前端是 Razor 视图加少量 jQuery,没上前端框架。
选 ASP.NET Core 而不是老的 WebForms,理由很现实。文档管理系统里最重的两块是文件流和权限,WebForms 那套 ViewState + 服务端控件处理大文件上传会很难受,而 ASP.NET Core 的中间件管道、内置 DI、异步流式读写,写起来顺手很多。C# 本身在文件 IO、Office 文档处理(OpenXML SDK、NPOI)这块生态成熟,这也是很多团队选它的原因。
EF Core 则要看场景用。目录树这种递归查询、文件元数据的增删改查,EF Core 能省掉大量手写 SQL;但版本号自增、批量统计这类操作,我一般还是直接走FromSqlRaw或者 Dapper,别硬套 LINQ。不追求"纯 ORM",该裸写 SQL 就裸写,这是踩过坑之后养成的习惯。
在线编辑这块单独说,因为它是整个系统里最容易翻车的部分。浏览器本身没法直接编辑 docx,必须有一个服务端渲染层把文档转成网页可编辑的格式,再把改动回传。市面上能自建的就那么几套,OnlyOffice 的集成文档最全、回调机制最清晰,所以选它。代价是多一个 Docker 容器,部署时多占一两个 G 内存,这个心理预期要提前有。
2.2 分层与一次请求的完整走向
项目按经典四层切:
| 层 | 目录 | 职责 |
|---|---|---|
| 表现层 | Controllers/Views/ | 页面渲染、表单提交、API 端点 |
| 应用层 | Services/ | 业务编排:上传、版本、授权判定 |
| 领域层 | Models/ | 实体、枚举、领域约束 |
| 基础设施层 | Data/Storage/ | EF Core 上下文、文件读写、外部编辑器客户端 |
一次"打开文档并在线编辑"的请求,走向是这样的:浏览器请求GET /Document/Edit/{id},控制器先走授权策略验证当前用户对这份文档有没有写权限,通过后调用IEditorClient.BuildConfigAsync,服务端根据文档当前版本生成一个编辑会话 key(一个短时效的 GUID),把 key 和文档 ID 的映射写进缓存,然后返回一段配置 JSON 给前端 JS,前端用这段配置去初始化编辑器组件。
编辑器服务拿到配置后,自己通过document.url把文件下载过去(这个 url 是个带签名的一次性地址,指向我们系统的/api/editor/download/{key}),渲染完成后用户就能在浏览器里改。用户点保存时,编辑器服务反过来调我们的回调接口POST /api/editor/callback,带上状态码和新文件的下载地址。回调里判断状态码是 2(全部用户关闭,可以保存)或 6(强制保存),就通过dto.url把新文件拉回来,走一遍版本服务落盘、建版本记录。
这条链路的关键在于:文件从来不由浏览器直传编辑器,全程是服务端到服务端。这样文件不需要暴露公网,权限也只在服务端校验一次,前端改不了。
2.3 数据模型:四张表撑起整个系统
表不多,但每张都有讲究:
public class Document { public Guid Id { get; set; } public string Name { get; set; } = default!; // 展示名,含扩展名 public string Extension { get; set; } = default!; // docx / xlsx / pdf public Guid? FolderId { get; set; } public Guid OwnerId { get; set; } public string ContentHash { get; set; } = default!; // 当前版本内容哈希 public long Size { get; set; } public int CurrentVersion { get; set; } public bool IsDeleted { get; set; } // 软删除,回收站用 public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } public List<DocumentVersion> Versions { get; set; } = new(); } public class DocumentVersion { public Guid Id { get; set; } public Guid DocumentId { get; set; } public int VersionNo { get; set; } // 从 1 递增,不跳号 public string StorageKey { get; set; } = default!; // 物理存储相对路径 public string ContentHash { get; set; } = default!; public long Size { get; set; } public Guid CreatedBy { get; set; } public string? Comment { get; set; } // "在线编辑保存" / "上传" public DateTime CreatedAt { get; set; } }Document和DocumentVersion是一对多,Document上冗余了ContentHash、Size、CurrentVersion三个字段。这不是没做规范化,是故意的:文档列表页要显示大小和版本号,每次都去 join 最新版本表,几千条数据就开始卡。用冗余换读性能,写入时由服务层保证一致。
另外两张表是Folder(目录树,用ParentId自引用)和DocumentPermission(文档级授权,UserId + Level,Level 是读/写/管理三档)。目录树这块有个常见做法是加Path字段存物化路径(形如/a/b/c/),查子树时用LIKE '/a/b/%'一句搞定,比递归 CTE 好维护。数据量不大时我就这么干。
DbContext 里把唯一索引和级联关系配好,很多问题在数据库层就挡掉了:
protected override void OnModelCreating(ModelBuilder b) { b.Entity<Document>(e => { e.HasIndex(x => new { x.FolderId, x.Name }).IsUnique(); // 同目录不重名 e.HasIndex(x => new { x.OwnerId, x.IsDeleted }); e.Property(x => x.Name).HasMaxLength(200).IsRequired(); e.HasMany(x => x.Versions).WithOne() .HasForeignKey(v => v.DocumentId) .OnDelete(DeleteBehavior.Cascade); }); b.Entity<DocumentVersion>(e => { // 版本号在单文档内唯一,这是并发写入的最后一道闸 e.HasIndex(x => new { x.DocumentId, x.VersionNo }).IsUnique(); e.Property(x => x.StorageKey).HasMaxLength(300).IsRequired(); }); }(FolderId, Name)唯一索引要注意 SQL Server 对 NULL 的处理——根目录下FolderId为 NULL 时唯一约束不生效,得用一个固定的根节点 GUID 代替 NULL,或者加过滤索引HasFilter("[FolderId] IS NOT NULL")再单独处理根目录。这个细节不处理,根目录能建出两个同名文件,后面版本回溯会乱。
3. 核心模块拆解:上传落盘、在线编辑回调与版本链
3.1 文件入库:先算哈希再落盘
文件存储最忌讳拿原始文件名当物理路径。用户传个../../web.config,或者两个部门各传一份"合同模板.docx",前者是安全问题,后者是覆盖问题。我的做法是内容寻址:先算 SHA256,用哈希前两位做一级子目录,整个哈希做文件名,物理路径和业务名彻底解耦。
public async Task<DocumentVersion> SaveVersionAsync( Guid docId, Stream content, Guid userId, string? comment, CancellationToken ct) { // 1. 先把流读一遍算哈希,算完把位置拨回 0,后面还要用 var hash = await ComputeSha256Async(content, ct); content.Position = 0; var key = $"{hash[..2]}/{hash}.bin"; // 例:a3/a3f9c1....bin var full = Path.Combine(_root, key); // 2. 相同内容只落一份盘,天然去重 if (!File.Exists(full)) { Directory.CreateDirectory(Path.GetDirectoryName(full)!); await using var fs = new FileStream(full, FileMode.CreateNew, FileAccess.Write); await content.CopyToAsync(fs, ct); } // 3. 版本号取当前最大值 +1,配合唯一索引兜底 var last = await _db.DocumentVersions .Where(v => v.DocumentId == docId) .MaxAsync(v => (int?)v.VersionNo, ct) ?? 0; var version = new DocumentVersion { Id = Guid.NewGuid(), DocumentId = docId, VersionNo = last + 1, StorageKey = key, ContentHash = hash, Size = new FileInfo(full).Length, CreatedBy = userId, CreatedAt = DateTime.UtcNow, Comment = comment ?? "上传" }; _db.DocumentVersions.Add(version); await _db.SaveChangesAsync(ct); return version; }ComputeSha256Async用IncrementalHash分段读,别把几百兆的文件全ReadAllBytes进内存,那是内存溢出最常见的来源。参数上,content必须是可 Seek 的流(IFormFile.OpenReadStream()返回的就是),否则算完哈希没法回退位置;如果上游给的是不可 Seek 的HttpRequest.Body,就先落一个临时文件再处理。
返回值是DocumentVersion而不是Document,这个设计是为了让调用方能立刻拿到版本号去做后续操作,比如更新Document.CurrentVersion、写审计日志。上传、在线编辑保存、版本恢复三条路径都复用这一个方法,只是comment不同。
3.2 在线编辑:编辑器服务与回调保存
编辑器服务的容器这样起,注意JWT_SECRET必须和业务侧的配置一致,否则回调里的 token 验不过:
docker run -d --name doc-editor -p 8080:80 \ -e JWT_ENABLED=true \ -e JWT_SECRET=<与业务侧一致的密钥> \ -e JWT_HEADER=Authorization \ -v doc_editor_data:/var/www/onlyoffice/Data \ onlyoffice/documentserver业务侧生成编辑配置的代码:
public async Task<object> BuildConfigAsync(Guid docId, Guid userId) { var doc = await _repo.GetAsync(docId); var sessionKey = Guid.NewGuid().ToString("N"); // 一次性编辑会话标识 // key -> (文档ID, 用户ID) 的映射,存分布式缓存,有效期 12 小时 await _cache.SetAsync($"edit:{sessionKey}", new EditSession(docId, userId), TimeSpan.FromHours(12)); return new { document = new { fileType = doc.Extension.TrimStart('.'), key = sessionKey, // 同一 key 的多次回调归为同一会话 title = doc.Name, url = $"{_publicBase}/api/editor/download/{sessionKey}" // 编辑器来拉文件 }, editorConfig = new { callbackUrl = $"{_publicBase}/api/editor/callback", // 编辑器回传保存 user = new { id = userId.ToString(), name = _userName(userId) }, customization = new { autosave = true, forcesave = true } } }; }回调处理是整条链路的收口:
[HttpPost("callback")] [AllowAnonymous] // 认证靠 token,不能靠 Cookie public async Task<IActionResult> Callback([FromBody] CallbackDto dto) { if (!_tokenValidator.Validate(dto.Token, dto.Payload)) return Unauthorized(); var session = await _cache.GetAsync<EditSession>($"edit:{dto.Key}"); if (session is null) return Ok(new { error = 0 }); // 会话过期,直接放过 // status 2 = 所有用户已关闭可保存;6 = 强制保存(用户点了保存按钮) if (dto.Status is 2 or 6 && !string.IsNullOrEmpty(dto.Url)) { await using var remote = await _http.GetStreamAsync(dto.Url); await _versionService.SaveVersionAsync( session.DocumentId, remote, session.UserId, "在线编辑保存", default); await _cache.RemoveAsync($"edit:{dto.Key}"); } return Ok(new { error = 0 }); // 必须返回 {"error":0},否则编辑器会一直重试 }status的取值语义要记牢:1 是编辑中(不用管),2 是就绪可保存,3 是保存出错,4 是无改动关闭,6 是强制保存中,7 是强制保存出错。只处理 2 和 6 就够了,3 和 7 要记日志告警。另外dto.Url是编辑器容器内部的地址,业务侧能不能访问取决于容器网络配置——同机部署用--network host或者自定义 bridge 最省事,跨机就得把编辑器暴露到一个双方都能通的地址。
3.3 版本链与回滚
版本表只追加不修改,回滚不是删记录,而是复制一份旧内容生成新版本:
public async Task<DocumentVersion> RestoreAsync(Guid docId, int versionNo, Guid userId) { var target = await _db.DocumentVersions .FirstOrDefaultAsync(v => v.DocumentId == docId && v.VersionNo == versionNo) ?? throw new NotFoundException("版本不存在"); var full = Path.Combine(_root, target.StorageKey); await using var fs = File.OpenRead(full); // 复用保存逻辑,自动拿到新版本号,注释里标记来源 return await SaveVersionAsync(docId, fs, userId, $"从 v{versionNo} 恢复", default); }这样做的好处是审计链完整:谁在什么时间把文档退回到哪一版,本身就是一条新记录,事后追责有据可查。如果改成"把当前版本指针指向旧版本",历史就断了,用户下次改完再保存,版本号会撞车。
版本列表页只查元数据不读文件,点"下载某版本"时才去拼接路径。这里有个内存优化:列表按VersionNo DESC分页,Skip/Take配合索引(DocumentId, VersionNo DESC),一页 20 条,几千个版本也不卡。
3.4 权限控制:策略授权比 if-else 好维护
刚开始我在每个 Action 里写if (doc.OwnerId != userId && !isAdmin) return Forbid();,写到第八个控制器的时候就开始漏了。后来统一成 ASP.NET Core 的策略授权:
public class DocumentAccessHandler : AuthorizationHandler<DocumentAccessRequirement, Document> { protected override Task HandleRequirementAsync( AuthorizationHandlerContext ctx, DocumentAccessRequirement req, Document doc) { var uid = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); if (uid is null) return Task.CompletedTask; if (ctx.User.IsInRole("Admin") || doc.OwnerId.ToString() == uid) ctx.Succeed(req); else if (doc.Permissions.Any(p => p.UserId.ToString() == uid && p.Level >= req.Level)) ctx.Succeed(req); return Task.CompletedTask; } }注册时把三档权限映射成三个策略,控制器上直接标[Authorize(Policy = "DocWrite")],配合IAuthorizationService.AuthorizeAsync(User, doc, "DocWrite")在服务层做二道校验。关键是Document实体要带着Permissions一起加载(.Include(d => d.Permissions)),否则 handler 里拿到空集合,永远判失败——这个坑我在联调时踩过一次,现象是管理员能进、文档协作者进不去,查了半天以为是角色没配上。
4. 本地跑通:从 clone 到能点开一份文档
4.1 环境准备与依赖安装
需要的环境不多,但版本要对齐:
| 依赖 | 版本建议 | 备注 |
|---|---|---|
| .NET SDK | 8.0 | 项目 TargetFramework 是 net8.0 |
| SQL Server | 2019+ 或 LocalDB | 开发机用 LocalDB 最省事 |
| Docker Desktop | 任意较新版本 | 只用来跑在线编辑服务 |
| EF Core CLI | 8.x | dotnet tool install -g dotnet-ef |
dotnet tool install --global dotnet-ef git clone <仓库地址> doc-manager cd doc-manager dotnet restore如果不想装 SQL Server,把连接串换成 SQLite 也能跑,代价是部分FromSqlRaw的语法要改,物化路径查询的LIKE行为也有差异。我在本地调试时就用 SQLite,图的是删库重建快,但上线前一定切回 SQL Server 跑一遍全流程,两种数据库对大小写和排序的处理不一样,血泪经验。
在线编辑服务的容器必须先起来,否则前端点"编辑"会一直转圈:
docker run -d --name doc-editor -p 8080:80 \ -e JWT_ENABLED=true -e JWT_SECRET=dev-secret-please-change \ -v doc_editor_data:/var/www/onlyoffice/Data \ onlyoffice/documentserver首次启动要转一两分钟初始化,docker logs -f doc-editor看到Document Server is running再往下走。
4.2 配置文件与数据库初始化
appsettings.Development.json里四段配置是关键:
{ "ConnectionStrings": { "Default": "Server=(localdb)\\MSSQLLocalDB;Database=DocMgmt;Trusted_Connection=True;TrustServerCertificate=True" }, "Storage": { "RootPath": "D:/docstorage", "MaxFileSizeMb": 200 }, "Editor": { "ServerUrl": "http://localhost:8080", "PublicBaseUrl": "http://localhost:5000" }, "Cache": { "EditSessionMinutes": 720 } }RootPath别放在项目目录下面,否则dotnet clean或者重新发布一次,用户文件全没了。PublicBaseUrl是编辑器服务访问我们系统的地址,本机调试写http://localhost:5000,如果编辑器跑在容器里而业务跑在宿主机,得写成宿主机在容器网络里的地址(Windows 下是http://host.docker.internal:5000),写localhost会指向容器自己,回调永远拉不到文件。
Program.cs的注册顺序也要注意,认证在授权之前,静态文件在认证之前:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext<DocDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); builder.Services.Configure<StorageOptions>( builder.Configuration.GetSection("Storage")); builder.Services.AddScoped<IDocumentStorage, DocumentStorage>(); builder.Services.AddScoped<IVersionService, VersionService>(); builder.Services.AddHttpClient<IEditorClient, EditorClient>(); // 回调要发 HTTP 请求 builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(o => { o.LoginPath = "/Account/Login"; o.ExpireTimeSpan = TimeSpan.FromHours(8); }); builder.Services.AddAuthorization(); builder.Services.AddScoped<IAuthorizationHandler, DocumentAccessHandler>(); builder.Services.AddControllersWithViews(); var app = builder.Build(); app.UseStaticFiles(); // 必须在认证前,否则样式表也要登录才能加载 app.UseAuthentication(); app.UseAuthorization(); app.MapDefaultControllerRoute(); app.Run();初始化数据库:
dotnet ef migrations add Init -o Data/Migrations dotnet ef database update首次跑会创建四张表加索引。想验证索引有没有生效,直接在 SSMS 里执行sp_helpindex 'DocumentVersions',看到IX_DocumentVersions_DocumentId_VersionNo就对了。
4.3 启动、冒烟验证与首个管理员
dotnet run --urls "http://localhost:5000"启动后按这个顺序验证,每一步失败都能定位到具体环节:
- 打开
http://localhost:5000,能跳转到登录页,说明路由和认证中间件正常。 - 用种子数据里的管理员账号登录(一般是
admin@local/ 首次启动随机密码打印在控制台),进不去就查数据库AspNetUsers表有没有数据。 - 新建一个文件夹,上传一个 docx,列表里能看到,大小和版本号 v1 正确,说明上传链路通了。
- 点"下载",文件能打开且内容完整,说明物理存储路径拼对了。
- 点"在线编辑",编辑器加载出文档内容,说明
PublicBaseUrl和编辑器服务互通。 - 在浏览器里改几个字、点保存、关掉再打开,改动还在,说明回调落盘成功。
第 5 步和第 6 步是最容易出问题的两处,前者一般是网络地址配错,后者一般是回调返回格式不对或者 token 校验没过。验证时把日志级别调到Information,回调接口里每一行都打日志,出问题直接看日志比猜快十倍。
种子管理员别用硬编码密码,我一般写成:首次启动时如果用户表为空,生成一个随机密码写进控制台和logs/seed.txt,并强制首次登录改密码。硬编码admin/123456的项目,十个有八个上线后忘了改。
5. 排查与避坑:五个把我按在地上摩擦的问题
5.1 上传大文件直接 413,前端连错误提示都没有
现象:传一个 80MB 的 PDF,浏览器卡一会儿然后失败,服务端日志里连请求都没记录。
原因是请求体大小在两层被限制:Kestrel 默认 30MB,IIS 托管时maxAllowedContentLength默认也是 30MB(IIS 层这个更隐蔽,因为 Kestrel 的日志根本不会出现)。解决要两处一起改:
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 200L * 1024 * 1024); // 200MB,与 Storage 配置对齐IIS 那边在web.config里加<requestLimits maxAllowedContentLength="209715200" />,单位是字节不是 MB,写错一个数量级就白改。表单上传还要额外设[RequestFormLimits(MultipartBodyLengthLimit = 209715200)],否则 Form 解析器的默认 128MB 又会拦一次。
5.2 在线编辑保存后,文档内容没变
现象:编辑器里改完点保存,提示成功,重新打开还是旧内容。
查下来九成是回调地址问题。callbackUrl里写的是http://localhost:5000/api/editor/callback,但编辑器跑在容器里,容器内的localhost指向它自己,回调根本没到业务侧。把PublicBaseUrl换成http://host.docker.internal:5000(Mac/Linux 用宿主机内网 IP),重启编辑器容器后立刻恢复。另一个可能是回调返回了非{"error":0}的结构,编辑器认为保存失败会重试几次然后放弃,日志里能看到重复请求。
5.3 版本号撞车,抛唯一索引冲突异常
现象:两个人几乎同时保存同一份文档,其中一个报 500,日志里是Cannot insert duplicate key ... IX_DocumentVersions_DocumentId_VersionNo。
原因很直白:Max(VersionNo) + 1不是原子操作,两个请求都读到 5,都写 6。解决分两层,第一层是唯一索引,它保证了数据不会脏;第二层是重试,捕获DbUpdateException后重查一次最大值再插入,最多重试三次:
for (var attempt = 1; attempt <= 3; attempt++) { try { await _db.SaveChangesAsync(ct); return version; } catch (DbUpdateException) when (attempt < 3) { _db.Entry(version).State = EntityState.Detached; version.VersionNo = await NextVersionNoAsync(docId, ct); } }文档级并发不高的话这样就够了,真到高并发场景得上数据库序列或者sp_getapplock,别用lock关键字,多实例部署时进程锁就是个摆设。
5.4 中文文件名下载后变成乱码
现象:合同模板.docx下载下来叫???.docx或者一串百分号编码。
原因在Content-Disposition头。文件名里有非 ASCII 字符时,必须按 RFC 5987 用filename*=UTF-8''编码,只写filename=浏览器按 ISO-8859-1 解析就废了:
var encoded = Uri.EscapeDataString(fileName); Response.Headers.ContentDisposition = $"attachment; filename=\"{Guid.NewGuid():N}.{ext}\"; filename*=UTF-8''{encoded}";前面那个filename=给老浏览器兜底用 ASCII 名,现代浏览器优先读filename*。另外物理存储用哈希命名,跟这个完全无关,两套名字不要混。
5.5 部署到服务器后登录状态秒失效
现象:开发机一切正常,发布到服务器后登录成功,点第二个页面就又跳回登录页。
这是 ASP.NET Core Data Protection 的典型问题。Cookie 是加密的,加密密钥默认存在%LOCALAPPDATA%\ASP.NET\DataProtection-Keys或者容器内临时目录,多实例负载均衡或者容器重启后密钥变了,旧 Cookie 全解不开。解决是把密钥持久化到共享位置:
builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo("/var/keys")) .SetApplicationName("DocManager");多台机器时要保证这个目录共享且可写,SetApplicationName必须一致,否则即便密钥文件一样也解不开,因为这个值参与密钥派生,改了它等于换了一套密钥。
6. 二次开发:三个最常被提的改造点怎么落地
接手这类系统的人,需求十有八九集中在三块:接企业统一登录、加全文检索、把本地存储换成对象存储。我按改动量从小到大排:
| 改造点 | 涉及文件 | 大致工作量 | 风险点 |
|---|---|---|---|
| 换对象存储 | Storage/DocumentStorage.cs | 半天 | 流式上传/下载要改成 SDK 的流式 API |
| 接统一登录 | Program.cs+ 登录控制器 | 1~2 天 | 用户首次登录的自动建档 |
| 加全文检索 | 新增Search/+ 索引服务 | 3~5 天 | 中文分词、增量索引一致性 |
换对象存储最简单,因为存储层已经是接口IDocumentStorage,实现一个S3DocumentStorage在 DI 里换掉就行。唯一要注意的是别把整个文件读进byte[]再上传,几百兆的文件会把内存打满,用 SDK 的流式重载。删除逻辑也要跟着改,本地存储能靠文件是否存在判断,对象存储的删除是最终一致的,得靠业务表记录状态。
接统一登录,我的习惯是保留本地用户表,把外部身份映射成AspNetUsers里的一条记录(用IdentityProvider + ExternalId做唯一键),别想着完全去掉本地用户——权限表、版本记录里的CreatedBy全指向本地 ID,动了就是全表改。首次登录时如果本地没这个人,自动建号并赋一个默认"只读"角色,让管理员后续手动提权,比默认给写权限安全得多。
全文检索是投入最大的一块。文档内容存在物理文件里,数据库只有元数据,所以必须有一个抽取环节:docx 用 OpenXML SDK 读MainDocumentPart的文本,xlsx 用 NPOI 遍历单元格,PDF 用 PdfPig 抽文本层。抽取放在保存版本之后异步做,用BackgroundService挂一个队列,别在保存请求里同步抽,一个大文件能卡十几秒。索引可以用 Lucene.NET,中文分词配个盘古或者结巴的分词器,别用默认的 StandardAnalyzer,那个按空格切,中文整段当一个词,搜什么都是零结果。
索引一致性是这里唯一的难点。我的做法是给每个版本记一个IndexedAt字段,定时任务扫IndexedAt IS NULL的版本补索引,同时提供一个手动"重建索引"的后台按钮。这样即使异步任务挂了,数据也不会永久缺失,最多延迟一会儿。
从那以后我每次接这类文档系统,都先把"谁能看、谁能改、改坏了怎么退"这三件事在表结构里定死,再谈界面和交互——权限和版本是骨架,界面只是皮。骨架没搭对,后面每加一个功能都要打补丁,补到最后自己都不敢改。希望帮到你。
本文还有配套的精品资源,点击获取