基于ASP.NET Core的新闻发布系统:从数据库设计到部署实践

发布时间:2026/9/16 21:31:49
基于ASP.NET Core的新闻发布系统:从数据库设计到部署实践 简介这套ASP.NET Core新闻发布系统源码附带SQL Server数据库文件面向想系统学习.NET Core Web开发的中级开发者也适合作为毕业设计或企业CMS二次开发的起点。压缩包共656个文件约18.52MB内部以119个dll程序集、55个cs源码文件、50个css样式、19个cshtml Razor视图、87个js脚本为主还包括png/jpg等前端素材、json配置文件与sln解决方案目录清晰。项目采用MVC分层架构通过EF Code First方式由模型自动生成数据库结构并集成了身份验证、依赖注入、数据库迁移和RESTful API等常用机制。已有520人学习下载适合对照源码拆解从控制器到Razor模板再到数据访问的完整链路快速提升ASP.NET Core实战能力。1. 为什么新闻系统选择 ASP.NET Core 而不是自己拼模板做新闻发布系统第一反应往往是找现成的 WordPress 或 CMS 改模板。可只要业务方提出“评论要过审”“编辑要分权”“头条要定时切换”通用 CMS 的插件体系反而碍事。用 asp.net core 从源码层做一套等于把文章、栏目、权限和发布流程全部收进自己的数据模型里后续加需求只改自己的代码库不跟第三方插件的升级节奏赛跑。交付形态里那“源码 数据库”两个部分恰好对应这套系统的两条主线数据库决定数据怎么存源码决定业务怎么跑。这套方案适合两类人。一类是 .NET 团队要做内部采编系统希望栏目、标签、审核、发布都按自己口径定制另一类是拿新闻发布系统当课程设计或面试项目的人想通过一个完整案例把 EF Core、认证授权、REST API 串起来。标题里的“完整”不该理解成文件齐全而应理解成链路完整从建表、迁移、后台 CRUD到前台渲染、鉴权、缓存再到部署验证一条线走通才算真正把源码和数据库握在手里。2. 数据库设计与 EF Core 迁移先把新闻系统的数据底座立稳新闻系统的实体不多但关系比普通 CRUD 复杂一点文章属于栏目文章有多对多的标签文章有作者文章还要有状态流转记录。如果一上来就写 Controller写到一半就会发现查询条件散落在各个方法里字段命名也前后不一致。先把表结构定下来后续接口、页面、权限全都对着这套模型展开。新闻发布系统的核心表是文章表 Article其他表都围绕它服务。栏目表用 ParentId 自引用而不是存路径字符串是为了将来做栏目级权限时能递归出子栏目集合标签表与文章表之间必须用中间表连接否则“按标签找文章”和“按文章列标签”都会变成模糊查询。账号表单独拆出来不把作者信息冗余到文章表里这是为了避免编辑改姓名时要去刷历史文章。2.1 新闻系统表结构与领域模型划分表名职责关键字段关联对象Category栏目树Id、Name、ParentId、SortOrderArticle.CategoryIdArticle新闻正文Id、Title、Summary、Body、Status、AuthorId、PublishTimeCategory、User、ArticleTagTag标签字典Id、NameArticleTagArticleTag文章与标签中间表ArticleId、TagIdArticle、TagUser采编账号Id、UserName、PasswordHash、RoleArticle.AuthorIdOperateLog操作审计Id、UserId、Action、TargetId、CreateTimeUser实体类在代码里的样子决定 EF Core 迁移生成什么表。字段类型、可空性、长度约束都应该直接写在实体特性上而不是等迁移出来再手工改 SQL。下面是一张文章表的实体简化版本[Table(article)] public class Article { [Key] public int Id { get; set; } [Required, MaxLength(200)] public string Title { get; set; } [MaxLength(500)] public string? Summary { get; set; } [Required] public string Body { get; set; } public int CategoryId { get; set; } public int AuthorId { get; set; } /// summary /// 0草稿 1已发布 2已下线 /// /summary public int Status { get; set; } public int ViewCount { get; set; } public DateTime CreatedAt { get; set; } public DateTime? PublishedAt { get; set; } public ICollectionArticleTag? Tags { get; set; } }这里 Status 用 int 而不是枚举是刻意做的决定。新闻发布系统的状态未来很可能增加“待审核”“已撤回”用枚举存数据库每次改都要迁移用 int 加注释业务层用常量或枚举映射即可。PublishedAt 必须可空草稿状态的新闻没有发布时间未来还要支持定时发布这个字段是定时任务的判断依据。Tags 属性是导航集合EF Core 会通过 ArticleTag 中间表自动维护关系查询“某文章有哪些标签”时只需要 Include 一次。2.2 用迁移把源码和数据库从两套东西变成可重建的整体数据库不是手工建出来的而是从实体类迁移出来的。这样源码、数据库结构、种子数据三者版本一致换一台机器重新部署执行一次迁移就能恢复整套结构。常见的做法是把 DbContext 放在独立的类库项目里Web 项目只做启动器迁移命名空间也随类库走。dotnet ef migrations add InitialNewsDb -p src/News.Data -s src/News.Web dotnet ef database update -s src/News.Web第一条命令生成迁移文件第二条命令把迁移应用到数据库。-p指定 DbContext 所在的项目-s指定启动项目。DbContext 的构造函数连接字符串在 Web 项目里配置所以迁移时必须以 Web 项目作为启动项才能读到配置。执行前确认 Web 项目里安装了Microsoft.EntityFrameworkCore.Design包否则会提示找不到命令。初始迁移除了建表还要把默认栏目和管理员账号写进去。在 DbContext 的OnModelCreating里用HasData配置种子数据modelBuilder.EntityCategory().HasData( new Category { Id 1, Name 时政, ParentId null, SortOrder 1 }, new Category { Id 2, Name 科技, ParentId null, SortOrder 2 } );种子数据的好处是交付后不用手动执行 INSERT。需要注意HasData必须显式指定主键 Id因为 EF Core 迁移在生成插入脚本时需要确定主键值。新环境上执行迁移后栏目表直接有数据登录账号也能立刻使用。这块如果漏掉项目跑起来后首页空白后台登录也进不去最容易被误判成代码问题。2.3 状态字段、软删除与时间戳的三个建模决定新闻系统的数据建模有三个点容易被新手忽略但恰恰是它们决定后期维护成本。第一是删除策略文章被删除后编辑经常要找回所以不建议硬删除。第二是状态与时间的关系发布状态和发布时间是两回事草稿可以有更新时间但没有发布时间已发布的文章下架再上架发布时间应该保持第一次发布时间。第三是时区服务器时区可能和编辑所在地不一致统一存 UTC 时间前端展示时再转本地时区能避免早晚八点定时发布偏差一小时的问题。软删除在 EF Core 里推荐用全局查询过滤器实现给实体加IsDeleted属性然后在OnModelCreating里配置HasQueryFilter这样所有查询自动带WHERE IsDeleted 0不需要每个查询手动加条件。删除操作变成一次 Update审计日志里也能看到是谁在什么时候删除的。全局过滤器唯一的副作用是某个地方确实需要查出已删除数据时要用IgnoreQueryFilters()绕过这个接口只留给管理员的数据恢复功能使用。时间戳字段推荐统一命名为CreatedAt、UpdatedAt、PublishedAt不要混用CreateTime和PublishTime。命名统一的好处是写列表排序时不用翻实体类确认字段名。创建时间在数据库中设置默认值GETDATE()应用程序里不显式赋值避免不同服务器时区导致时间不一致。3. 后台 CRUD 与发布状态机接口层源码怎么组织才不被改崩后台管理是新闻系统的重头戏栏目维护、文章录入、状态切换、标签管理都发生在这一侧。很多项目失败不是因为功能写不出来而是所有逻辑都堆在 Controller 里一个方法几百行改发布流程时牵扯到查询代码改查询时又碰到权限判断。把接口层拆出独立的 Service是新闻发布系统源码组织最关键的一步。Controller 只负责接收 HTTP 请求、绑定参数、返回状态码具体业务规则放在 Service 层。这样做的好处是发布状态机、权限校验、字段默认值可以在单元测试里直接调用不需要启动 Web 服务Controller 变得很薄也便于在中间件里统一做异常处理。下面以文章创建和状态切换为例展示 Service 层的接口定义范式。3.1 控制器、服务与数据访问三层如何拆分public interface IArticleService { TaskPagedResultArticleSummary QueryAsync(ArticleQuery query); TaskArticle? GetByIdAsync(int id); Taskint CreateAsync(ArticleDraft draft, int authorId); Task(bool Ok, string Message) ChangeStatusAsync(int id, int targetStatus, int operatorId); }创建文章的接口传入的是一个 DTO 草稿对象而不是直接传实体这样前端提交的字段不会绕过业务逻辑直接落到数据库。authorId从当前登录用户那里取不信任前端传值这是防止越权修改作者信息的基本要求。Service 内部把 DTO 转换成实体设置默认状态写入审计日志。public class ArticleService : IArticleService { private readonly AppDbContext _db; public ArticleService(AppDbContext db) { _db db; } public async Taskint CreateAsync(ArticleDraft draft, int authorId) { var entity new Article { Title draft.Title.Trim(), Summary draft.Summary?.Trim(), Body draft.Body, CategoryId draft.CategoryId, AuthorId authorId, Status 0, CreatedAt DateTime.UtcNow }; _db.Articles.Add(entity); await _db.SaveChangesAsync(); _db.OperateLogs.Add(new OperateLog { UserId authorId, Action CreateArticle, TargetId entity.Id, CreateTime DateTime.UtcNow }); await _db.SaveChangesAsync(); return entity.Id; } }注意这里没有额外抽象一层 IRepository。EF Core 本身就是工作单元加仓储模式的实现DbSet提供查询能力DbContext统一管理事务再包一层自定义仓储会让代码变得绕路。常见错误是每个实体都建一个 Repository 基类最后查询写法和直接使用 DbSet 没区别只是多了一堆空方法。除非真的要分库分表或读写分离新闻发布系统保持 Service 直接依赖 DbContext 是最轻量的结构。3.2 列表查询的分页、关键词、栏目和标签过滤管理后台的文章列表不会只查一次全表。编辑要按标题搜栏目管理员要按栏目筛还要支持按标签找相关文章。把这些查询条件封装成一个查询参数类Service 方法里根据条件动态拼接表达式代码可读性比层层 if 包裹好得多。public class ArticleQuery { public int Page { get; set; } 1; public int PageSize { get; set; } 20; public string? Keyword { get; set; } public int? CategoryId { get; set; } public int? TagId { get; set; } public int? Status { get; set; } }查询实现public async TaskPagedResultArticleSummary QueryAsync(ArticleQuery query) { var q _db.Articles.AsNoTracking().Where(a a.Status ! 2); if (!string.IsNullOrWhiteSpace(query.Keyword)) { q q.Where(a a.Title.Contains(query.Keyword) || a.Body.Contains(query.Keyword)); } if (query.CategoryId.HasValue) { q q.Where(a a.CategoryId query.CategoryId.Value); } if (query.TagId.HasValue) { q q.Where(a a.Tags.Any(t t.TagId query.TagId.Value)); } var total await q.LongCountAsync(); var list await q .OrderByDescending(a a.PublishedAt ?? a.CreatedAt) .Skip((query.Page - 1) * query.PageSize) .Take(query.PageSize) .Select(a new ArticleSummary { Id a.Id, Title a.Title, CategoryName a.Category.Name, AuthorName a.Author.UserName, Status a.Status, PublishedAt a.PublishedAt }) .ToListAsync(); return new PagedResultArticleSummary { Total total, Items list }; }AsNoTracking()告诉 EF Core 这些实体不需要追踪修改纯查询场景下能减少内存占用和变更检测开销。Contains在数据库端生成 LIKE 查询正文这样的大字段做模糊搜索性能会差数据量超过十万条时应该换成 SQL Server 的全文索引。分页用 Skip/Take 只适合管理后台前台资讯页数据量大用户翻到一百页以后 Skip 的偏移量会拖慢查询那时应该改用游标分页即基于Id lastId的方式往下取。标签过滤通过Any判断中间表是否存在对应记录EF Core 会自动生成 EXISTS 子查询性能比先把标签查出来再逐条比对好。OrderByDescending(a a.PublishedAt ?? a.CreatedAt)这个写法让草稿按创建时间排在后面已发布的按发布时间排序。空值合并表达式在 EF Core 中能直接翻译成 SQL 的COALESCE不需要先取数据到内存再排序。3.3 发布状态机与防止重复提交新闻系统的状态流转不能只是把一个字段从 0 改成 1。编辑点发布时要检查文章是否处于可发布状态审核不通过时状态要从待审核回到草稿而不是直接下线。把状态流转显式写成规则比在 Controller 里到处写Status 1安全得多。private static readonly (int From, int To)[] AllowedTransitions new[] { (0, 1), // 草稿 - 已发布 (1, 2), // 已发布 - 已下线 (2, 0) // 已下线 - 草稿重新编辑 }; public async Task(bool Ok, string Message) ChangeStatusAsync(int id, int targetStatus, int operatorId) { var article await _db.Articles.FindAsync(id); if (article null) { return (false, 文章不存在); } var allowed AllowedTransitions.Any(t t.From article.Status t.To targetStatus); if (!allowed) { return (false, $不允许从 {article.Status} 切换到 {targetStatus}); } article.Status targetStatus; if (targetStatus 1) { article.PublishedAt DateTime.UtcNow; } await _db.SaveChangesAsync(); // 记录状态变更日志包含操作人、来源状态、目标状态 return (true, 操作成功); }状态机规则集中在一个数组里新增“待审核”状态时只改这里。另一个隐藏问题是并发重复提交两个编辑同时点发布第一次读取状态为 0第二次也读到 0两次都通过校验。解决办法是给 Article 表加一个RowVersion并发令牌字段实体里配置为[Timestamp]保存时 EF Core 在 UPDATE 语句中带上版本条件一次更新失败会抛出DbUpdateConcurrencyException捕获后提示用户刷新页面。新闻后台并发不高但发布操作直接影响线上页面值得为这个边界场景兜底。4. 前台渲染、鉴权与会话把新闻页跑通并防住 XSS后台把文章存进数据库前台负责把已发布文章渲染出来。新闻发布系统的前台和后台是两个不同的关注点前台面对匿名访客要求响应快、不能泄露未发布内容后台面对编辑和管理员要求权限清晰、操作可追溯。所以前台页面必须只查询状态为已发布的文章后台接口必须登录后才能访问。先解决“谁能进来改”的问题。ASP.NET Core 自带 Cookie 认证登录成功后浏览器保存加密票据后续请求自动携带。在 Program.cs 中启用认证和授权中间件顺序不能颠倒。4.1 基于 Cookie 的身份认证与后台权限隔离builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options { options.LoginPath /Account/Login; options.ExpireTimeSpan TimeSpan.FromHours(8); options.SlidingExpiration true; }); builder.Services.AddAuthorization(options { options.AddPolicy(EditorOnly, policy policy.RequireRole(Editor)); options.AddPolicy(AdminOnly, policy policy.RequireRole(Admin)); }); var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();LoginPath指定未登录用户跳转的页面ExpireTimeSpan控制登录有效期SlidingExpiration表示用户持续操作时票据自动续期避免编辑写长文写到一半被踢出去。授权策略注册后在 Controller 或 Action 上标记[Authorize(Roles Editor)]标记了EditorOnly的接口只有编辑角色能访问管理员标记AdminOnly栏目管理员则要配合自定义IAuthorizationHandler做栏目级数据权限。登录密码存储不要自己发明加密算法用 ASP.NET Core Identity 的PasswordHasherT直接对密码做哈希。密码哈希是单向操作即使数据库泄露也无法逆向还原登录时比对哈希值而不是比对明文。如果项目坚持不用 Identity 框架也要用Rfc2898DeriveBytes等标准算法做 PBKDF2 派生绝不能在数据库里存明文密码。4.2 Razor 渲染新闻正文时的 XSS 防御新闻正文是富文本编辑可能插入图片、链接、列表这就给 XSS 留下了入口。Razor 默认会对字符串编码但Html.Raw(Model.Body)会原样输出 HTML等于把前端脚本直接放到页面上。正确做法是存储端过滤在编辑保存时就清理危险标签而不是输出时依赖前端转义。var sanitizer new HtmlSanitizer(); sanitizer.AllowedTags.Add(p); sanitizer.AllowedTags.Add(strong); sanitizer.AllowedTags.Add(em); sanitizer.AllowedTags.Remove(iframe); sanitizer.AllowedAttributes.Remove(onerror); var safeHtml sanitizer.Sanitize(draft.Body);HtmlSanitizer做的是白名单清洗AllowedTags控制哪些标签能保留AllowedAttributes控制属性。默认情况下onclick、onerror这类事件属性会被过滤但显式 Remove 一遍能避免将来升级类库导致默认行为变化。需要特别留意iframe标签新闻系统允许嵌入视频时应该只放行来自白名单域名的 src而不是直接放行所有 iframe。发布后还要定期抽查已入库的历史文章用HtmlSanitizer重新清洗一遍防止老编辑器时代残留的恶意脚本始终挂在页面上。前台展示时正文标题和摘要直接用 Razor 默认编码输出只有正文这一处经过白名单清洗后使用Html.Raw。这样即便编辑在摘要里手滑粘贴了脚本标签输出时也会被转义成普通文本。4.3 定时发布与前台页面缓存新闻发布系统经常要求稿子定时上线编辑晚上写好的文章设定第二天早上八点发布。方案是在 Entity 上加ScheduledPublishTime字段状态设为“待发布”由后台定时任务扫描执行。public class PublishScheduler : BackgroundService { private readonly IServiceProvider _services; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using var scope _services.CreateScope(); var db scope.ServiceProvider.GetRequiredServiceAppDbContext(); var dueArticles await db.Articles .Where(a a.Status 3 a.ScheduledPublishTime DateTime.UtcNow) .ToListAsync(stoppingToken); foreach (var article in dueArticles) { article.Status 1; article.PublishedAt DateTime.UtcNow; } await db.SaveChangesAsync(stoppingToken); await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } } }后台任务每 60 秒扫一次待发布表把到期文章改为已发布。状态 3 是“待发布”可以复用的是对比时间用 UTC避免服务器时区设置不一致导致早八点晚八点偏差。这里用CreateScope是因为 BackgroundService 是单例生命周期不能直接注入 Scoped 的 DbContext每次定时扫描必须从服务容器里重新解析一个作用域实例。前台访问量大新闻详情页和列表页要做缓存否则流量一上来数据库压力立刻显现。给详情页的 Action 加上响应缓存[ResponseCache(Duration 60, VaryByQueryKeys new[] { id }, Location ResponseCacheLocation.Any)] public async TaskIActionResult Detail(int id)Duration表示缓存 60 秒VaryByQueryKeys让不同 id 的新闻页面分别缓存。问题在于定时发布任务把文章状态改为已发布后如果有中间层缓存访客可能在一分钟内还看到旧状态。所以发布、下线操作执行成功后要主动调用缓存刷新接口或使用IMemoryCache.Remove清除对应条目。缓存策略和发布动作必须联动这是新闻系统前台最容易出现“文章显示不出来”或“下线了还能访问”的原因所在。5. 部署与验证生产环境里回滚一篇新闻要盯住哪些环节源码和数据库在开发机上跑通只算完成一半。真正交付时新环境重建数据库、发布脚本执行、健康检查通过整套才闭环。部署阶段最常见的三个故障是迁移没执行导致表缺失、生产环境和开发环境连接字符串不同、发布目录里残留旧版本 DLL。下面这套验证顺序适合常见做法按顺序执行能提前暴露大部分问题。首先是多环境配置。appsettings.json放公共配置appsettings.Production.json放生产连接字符串密钥放在环境变量里不要写进源码。发布时用dotnet publish -c Release -o ./release打包指定ASPNETCORE_ENVIRONMENTProduction启动程序才会读取生产配置文件。{ ConnectionStrings: { NewsDb: Servernews-db;DatabaseNewsSite;User Idnews_app;Passwordchange_me;TrustServerCertificatetrue } }TrustServerCertificatetrue是开发环境方便连接的选项生产环境如果数据库服务器证书受信任可以不加如果加上说明你不是在用证书加密链路要理解这个取舍。生产服务器通常没有 .NET SDK只有运行时不能执行dotnet ef database update。所以迁移要先生成 SQL 脚本交给 DBA 执行或由部署脚本调用sqlcmddotnet ef migrations script -p src/News.Data -s src/News.Web -o deploy/news.sql sqlcmd -S news-db -U news_app -P change_me -d NewsSite -i deploy/news.sql生成 SQL 脚本的方式比直接跑 update 更适合发布因为脚本可以纳入版本管理回滚时也能对着脚本核对数据库结构。接下来配置健康检查端点让负载均衡或手工验证能确认 Web 服务真的起来了builder.Services.AddHealthChecks(); app.MapHealthChecks(/health);然后写一个发布后的冒烟测试脚本按顺序执行一下#!/usr/bin/env bash set -euo pipefail BASE_URLhttp://127.0.0.1:5000 curl -fsS $BASE_URL/health | grep -q Healthy curl -fsS $BASE_URL/api/articles?status1page1 | python3 -c import sys, json; data json.load(sys.stdin); assert data[total] 0, no published articles curl -fsS -o /dev/null -w %{http_code} $BASE_URL/news/1 | grep -q 200先探测健康检查端点再查已发布文章列表接口最后访问前台详情页。三步都通过说明数据库连接正常、迁移数据存在、网站能够返回页面。如果第二步失败而健康检查通过大概率是迁移没执行完整或种子数据缺失去数据库里执行news.sql中未应用的片段即可。最后一步关注状态码为 200页面内容是否包含正文可以留到浏览器验证因为 HTML 内容结构调整频繁不适合写死在脚本里。回滚不必重跑整个部署流程。新闻系统回滚分两层代码回滚直接切换发布目录符号链接数据库回滚针对字段级变更。代码发布目录保留上一版本ln -sfn release_20250101 current切换数据库结构变更如果有破坏性改动迁移脚本要包含向下兼容的补偿语句。若发布后线上稿件数据异常优先停掉定时发布任务再改数据库状态而不是立刻回滚整个系统。本文还有配套的精品资源点击获取