资讯动态

ASP.NET MVC新闻发布系统实战:从三层架构到缓存与避坑指南

发布时间:2026/10/18 9:16:09 来源:尧图企业网站定制
简介一份基于ASP.NETC#的新闻发布系统完整源码包面向需要课程设计或项目实战参考的开发者尤其适合正在学习Web Forms或MVC的入门与进阶人员。系统覆盖新闻发布、关键字搜索、后台登录验证、数据库增删改查等完整流程并兼顾样式模板、响应式布局与SQL注入、XSS等安全防护能帮助理解ADO.NET/Entity Framework的数据访问方式与角色权限管理思路。资源包共79个文件主要包括18个C#源文件、14个ASPX页面、30张JPG截图和2个ASCX用户控件同时附带数据库MDF/LDF文件、使用说明书DOC及CSS样式配置压缩包仅1.24MB结构清晰便于本地部署。目前已有一千余人学习下载适合从基础到进阶逐步拆解前台展示、后台管理、登录授权等模块也可配合说明文档快速还原运行环境是练习ASP.NET全栈开发的实用素材。1. 别把新闻发布系统当CRUD这套代码里的门道比你想象的多接手的第一个活儿就是一套运行了三年的新闻发布系统后台编辑每天把稿子粘进去、点发布前台隔几分钟才看得到更新。表面上就是“文章表增删改查 一个列表页”可真往里走才发现里面全是坑文章有状态流、有权限分级、富文本要防XSS、首页和详情页有缓存还要考虑发布后前台多久能看到。这套系统不是什么高并发神作但它把内容管理里最常见的那堆问题都摊开了适合正在学ASP.NET MVC的同学拆着看也适合刚入职要接手老项目的朋友拿来当“避坑手册”。它解决的是“编辑能自己发新闻访客能稳定看新闻”这件事顺带把权限、缓存、部署这些企业项目的老生常谈都练了一遍。2. 架构与目录三层分离下的“同构设计”才是重点2.1 为什么选ASP.NET MVC 5 EF6 SQL Server这套系统不是刻意追新选型都是奔着“能维护、能招到人接手”去的。ASP.NET MVC 5的路由和过滤器机制让每个请求入口都很清晰EF6的延迟加载让开发期写查询很快SQL Server做新闻这种读多写少的业务很稳Bootstrap做后台界面几乎是零成本。整个解决方案被拆成了五个项目表现层的News.Web、业务层的News.BLL、数据访问层的News.DAL、实体层的News.Models以及存放通用扩展和加密工具的News.Common。这种结构就是经典的三层架构好处是替换任何一层都不牵连别人——你以后想把EF换成Dapper或者把MVC升级成Web API动线非常清楚。做这套系统的时候我一度觉得“每层都写一遍CRUD很啰嗦”但真正线上出问题才发现数据访问层只负责SQL和结果映射业务层只负责状态流转和校验页面只负责展示定位问题的效率高得多。二手项目最怕的是一个人把代码全揉在一个aspx文件里而这份代码的“同构设计”——每个模块的Controller - Service - Repository - Entity路径完全一致——让新人半天就能找到入口。2.2 目录结构逐层看从解决方案到数据库先列一份目录清单拿到源码后按这个路径对照着看心里就有数了。项目/目录职责关键文件示例News.Web页面、路由、过滤器、静态资源Controllers/NewsController.cs, Views/News/Detail.cshtmlNews.BLL业务逻辑、状态流转、缓存清理Services/NewsService.cs, Services/CategoryService.csNews.DALEF数据上下文、仓储实现Repository/NewsRepository.cs, DbContext/NewsDbContext.csNews.Models实体、视图模型、枚举Entities/News.cs, ViewModels/NewsDetailViewModel.csNews.Common扩展方法、Html净化、加密Security/HtmlSanitizer.cs, Extensions/StringExtensions.cs把项目跑起来之前先看数据库核心就四张表News文章主表存标题、摘要、正文、分类ID、状态、发布时间、阅读数、Category分类表、Tag标签表和NewsTag文章标签关联表。News表里那个Status字段是整个系统的灵魂它不叫IsPublish而是用了整数状态码0草稿、1待审核、2已发布、3已下线。这种设计让文章能走“编辑起草 - 主编审核 - 自动发布 - 手动下线”的完整流程而不是只有一个“上/下架”开关。我第一次接手时看到Status是int而不是bool还觉得多此一举后来发现编辑误操作把没审完的稿子直接发布这种事只有状态机才能兜住。实体类里对应的是这样的public class News { public int Id { get; set; } public string Title { get; set; } public string Summary { get; set; } public string Content { get; set; } public int CategoryId { get; set; } public int Status { get; set; } public DateTime CreatedAt { get; set; } public DateTime? PublishedAt { get; set; } public int ViewCount { get; set; } }这段代码里重点在PublishedAt用了可空类型DateTime?因为文章在草稿阶段没有发布时间Status是int配合枚举类NewsStatus做转换。为什么不用bool因为bool只能表达“是/否”表达不了“待审核”这种中间态后期加审批流你会哭的。你如果要扩展直接往枚举里加一个“4已撤回”就行不用改表结构。2.3 EF上下文与仓储写查询的重要约定NewsDbContext继承自DbContext里面就三个DbSetNews、Category、Tag外加一个文章的按条件分页查询方法。这套系统的分页不在数据库层面写存储过程而是用EF的Skip/Take配合一个自定义的PagedResult 返回总记录数和当前页数据。这里有个约定所有列表查询都必须走同一个分页方法禁止各自写Skip否则统计总数和列表条件不一致页面上就会出现“显示第2页却没有第1页单数”的诡异问题。仓储层长这样简化版public class NewsRepository : INewsRepository { private readonly NewsDbContext _db; public NewsRepository(NewsDbContext db) { _db db; } public PagedResultNews GetPaged(int categoryId, int pageIndex, int pageSize, NewsStatus? status) { var query _db.News.AsQueryable(); if (categoryId 0) { query query.Where(n n.CategoryId categoryId); } if (status.HasValue) { query query.Where(n n.Status (int)status.Value); } var total query.Count(); var items query .OrderByDescending(n n.PublishedAt ?? n.CreatedAt) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToList(); return new PagedResultNews(items, total, pageIndex, pageSize); } }注意排序那一行OrderByDescending(n n.PublishedAt ?? n.CreatedAt)这是为了兼容“还没正式发布但有创建时间的草稿”。如果你直接按PublishedAt排序草稿会因为PublishedAt为null被排到最前面列表就乱了。参数说明pageIndex从1开始传pageSize是每页条数status传null表示查询全部状态配合后台列表很顺手。这套写法在我接手过的几个项目里几乎成了标配因为它把分页条件收敛在了一个方法里没人能绕过它乱来。3. 核心模块拆解一条发布链路从草稿走到首页3.1 请求入口路由注册与Controller的前世今生新闻详情页的URL是/news/123/1这种形式而不是/Home/Detail?id123这是专门配置的路由。全局路由注册里加了一条约束id必须是数字这能避免一大堆无效请求落到数据库里。public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute({resource}.axd/{*pathInfo}); routes.MapRoute( name: NewsDetail, url: news/{id}/{pageIndex}, defaults: new { controller News, action Detail, pageIndex 1 }, constraints: new { id \d } ); routes.MapRoute( name: Default, url: {controller}/{action}/{id}, defaults: new { controller Home, action Index, id UrlParameter.Optional } ); } }这段代码的重点是routes.MapRoute的paramsurl里写路径模式defaults提供默认值constraints限定id只能是数字。加了约束之后/news/abc/1会直接404不会去数据库里查一个注定不存在的文章。你以后改URL风格比如想改成带拼音的永久的链接/blog/{slug}只要在这段里换掉url模板再给News加一个Slug字段就行。Controller里最值得看的是权限过滤器。后台发布和编辑页面用了[Authorize(Roles Admin,Editor)]但详情页是[AllowAnonymous]这一对组合拳直接覆盖了“前台所有人可看后台只有指定角色能进”的需求。[Authorize(Roles Admin,Editor)] public class NewsController : Controller { private readonly INewsService _newsService; public NewsController(INewsService newsService) { _newsService newsService; } [HttpGet] [AllowAnonymous] public ActionResult Detail(int id, int pageIndex 1) { var detail _newsService.GetPublishedDetail(id, pageIndex); if (detail null) { return HttpNotFound(); } return View(detail); } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Publish(NewsDraftModel model) { if (!ModelState.IsValid) { return View(Edit, model); } var newsId _newsService.CreateOrUpdate(model); _newsService.SetStatus(newsId, NewsStatus.Published); return RedirectToAction(Detail, new { id newsId }); } }整体链路是这样浏览器请求/news/123/1触发布路由MVC把id和pageIndex绑定到Detail方法参数Service根据id去查文章并判断状态查到就渲染视图查不到就返回404。这里有一个小细节Detail方法是HttpGet但发布文章是HttpPost两者的语义被C#的特性分得明明白白GET永远不会改数据。你如果在二次开发时把Publish也改成HttpGet会有两个后果爬虫会把草稿发出去任何人用一个链接就能改新闻内容。3.2 文章发布与状态流转草稿、审核、发布、下线新闻系统最不敢写叉的就是状态机。这套系统的写法是保存草稿时Status0点“提交审核”时Status1审核通过后Status2手动下线置为3。业务逻辑没有散落在Controller里而是统一收在NewsService这样不管是从后台页面发、还是将来从API发走的都是同一套规则。public int CreateOrUpdate(NewsDraftModel model) { News news; if (model.Id 0) { news _db.News.Include(n n.Tags).First(n n.Id model.Id); news.Title model.Title; news.Summary model.Summary; news.Content _safeHtml.Filter(model.Content); news.CategoryId model.CategoryId; news.UpdatedAt DateTime.Now; } else { news new News { Title model.Title, Summary model.Summary, Content _safeHtml.Filter(model.Content), CategoryId model.CategoryId, Status (int)NewsStatus.Draft, CreatedAt DateTime.Now, ViewCount 0 }; _db.News.Add(news); } _db.SaveChanges(); return news.Id; }参数说明model.Id为0表示新建大于0表示更新_safeHtml.Filter是净化富文本的关键调用专门杀掉script标签和onclick这类属性新建时Status写死为草稿保证每篇稿子都先进入草稿状态而不是直接裸奔到前台。SaveChanges之后立刻返回自增主键这是给后续SetStatus用的。状态流转方法更有意思public void SetStatus(int newsId, NewsStatus status) { var news _db.News.Find(newsId); if (news null) { throw new ArgumentException(news not found); } news.Status (int)status; news.PublishedAt status NewsStatus.Published ? DateTime.Now : news.PublishedAt; _db.SaveChanges(); _cache.Remove($news:detail:{newsId}); _cache.Remove(news:home:hot); }这里有一个容易被忽略的细节只有状态切到Published时才会更新PublishedAt从已发布改成下线不会把发布时间清掉这样重新上线时发布时间还是原值符合“首次发布时间不可变”的编辑习惯。缓存清理埋在每个状态变更之后避免编辑改了文章前台还看到旧数据的翻车现场。3.3 前台渲染Razor视图模型与数据绑定前台详情页的视图模型不是直接把实体扔过去而是由一个专用的NewsDetailViewModel组装里面除了标题、正文、发布时间还有上一篇下一篇的id和分页页码。这种做法叫“按需查询”防止用不上的字段被带进视图。model NewsDetailViewModel div classnews-detail h1Model.Title/h1 div classmeta spanModel.CategoryName/span spanModel.PublishedAt.ToString(yyyy-MM-dd HH:mm)/span spanModel.ViewCount 次阅读/span /div div classcontent Html.Raw(Model.SanitizedContent) /div div classpager for (var i 1; i Model.PageCount; i) { a href/news/Model.Id/ii/a } /div /div这里单独解释下Html.Raw默认Razor会把字符串里的HTML标签转义成普通文本显示“”字样不渲染成段落。新闻正文本身就是富文本必须用Raw放行。放行的前提是Model.SanitizedContent已经在Service层被过滤过如果你偷懒直接绑定Content再Raw那等于给攻击者开了一扇门。这是我接手后第二件事就补上的漏洞下面避坑章会说具体过程。分页器用的是for循环生成页码链接PageCount由Service根据总条数和每页条数算好Controller不用关心分页算法。4. 运行与部署从本地跑通到IIS上线4.1 环境准备还原数据库和第一次启动这套系统跑起来需要三样东西Visual Studio 2019及以上SQL Server 2016及以上.NET Framework 4.7.2。拿到源码后第一步不是F5而是先建数据库。项目里带了一个Scripts文件夹里面按编号排好了1_Init.sql、2_SeedData.sql、3_Index.sql按顺序执行即可。1号脚本建表和主外键2号脚本插入默认分类和一条示例新闻以及账号数据3号脚本建索引——新闻列表页是靠PublishedAt排序的这个索引不加数据量上万后分页很明显会慢。默认账号是admin密码文件里写的是Admin123启动前第一件事是改成自己的密码。数据库连接字符串默认指本机的SQL Server实例如果你的实例命名不是默认的就需要动手改web.config。4.2 配置项逐一说明改之前先弄清楚是干什么的web.config是整个项目的“总闸”里面有几个键值得花两分钟看懂。connectionStrings add nameNewsDb connectionStringServer.;DatabaseNewsDb;User Idsa;Passwordyourpwd;MultipleActiveResultSetstrue providerNameSystem.Data.SqlClient / /connectionStrings appSettings add keyUploadRoot value~/Uploads/News/ / add keyPageSize value10 / add keyEnableStaticHtml valuefalse / add keyStaticHtmlRoot value~/StaticHtml/ / /appSettings参数说明一下配置键作用常见的改法Server数据库实例地址本机写.或者localhost线上写成IP或主机名MultipleActiveResultSets是否允许多个结果集同时打开建议保持true否则EF延迟加载时会报“连接被占用”UploadRoot图片上传根目录改成网站专用上传目录注意IIS要给写权限PageSize每页新闻数量网站首页列表调成15后台列表调成20自行权衡EnableStaticHtml是否生成静态页流量小可关流量大再开下面避坑章会讲它的坑改连接字符串的时候最常翻车的点不是密码打错而是忘了把Server.改成线上数据库的实例名。很多人本地调试用.很顺手到了服务器忘了改结果IIS跑起来了页面却一直报连接失败。MultipleActiveResultSets这个参数表面不起眼但EF6的延迟加载依赖它关闭之后会出现“在一个连接上打开第二个结果集”的断言错误排查起来很玄学。4.3 IIS部署从Visual Studio到应用池发布部署我一般不用Visual Studio的“发布”向导而是用dotnet publish或者直接右键项目選“发布”生成文件然后把整个文件夹拷到服务器IIS目录。部署有几个点必须说第一应用程序池必须选“集成”模式。这套系统的路由和静态资源处理依赖集成管道如果选成经典模式URL重写和MVC路由会直接不工作症状就是首页能开、打到/news/123这种链接就404。第二上传目录要给IIS_IUSRS写入权限否则编辑传图会一直转圈。第三别忘了把数据库连接字符串改成线上实例。部署完先访问/Home/Index看首页再用后台账号登录试一遍发布流程别直接拿线上数据练手。本地调试和IIS部署最大的区别是身份。Visual Studio开发服务器用的是你的Windows账号权限很大IIS应用池用的是IIS_IUSRS写文件、连数据库都需要单独授权。这也是很多项目本地好好的、一上服务器就各种报错的总根源。5. 避坑排查我在这套系统上踩过的五个坑5.1 连接数据库失败最常见的启动翻车点现象F5一跑页面直接报“建立与服务器的连接时出错”。原因连接字符串里Server的值写成了别的机器名或者SQL Server实例名不对也可能是账号没有远程登录权限。解决先打开SQL Server Management Studio用同样账号登录试试能登录再排查代码另外用命令行工具test-netconnection测一下数据库端口通不通。从血泪经验来说八成问题出在“实例名”而不是密码。Test-NetConnection -ComputerName 192.168.1.10 -Port 1433返回TcpTestSucceeded为True说明网络通了再回web.config里看用户名密码。如果返回False先看SQL Server是否开启了TCP/IP协议默认安装时这个协议经常是禁用的必须在SQL Server配置管理器里手动启用。5.2 富文本编辑器成了XSS的后门现象编辑在后台插入一段带onerror的img标签前台其他用户一打开就弹出广告脚本。原因旧版本代码直接用了Html.Raw(model.Content)后台编辑器存什么就吐什么攻击代码被原样执行。解决在Service层加HtmlSanitizer只放行白名单标签p、br、strong、em、a、img、ul、li并把a的href限制为http或https开头。那之后我写了一个极简过滤方法public static string Sanitize(string html) { if (string.IsNullOrWhiteSpace(html)) return html; // 移除script标签及内容 html Regex.Replace(html, script.*?/script, , RegexOptions.IgnoreCase | RegexOptions.Singleline); // 移除事件属性 on* html Regex.Replace(html, \son\w\s*\s*[^]*, , RegexOptions.IgnoreCase); return html; }逻辑说明第一行Regex删除整个script块第二行删除onclick、onerror之类的事件绑定两个正则一前一后把最常见的攻击面堵上了。参数说明RegexOptions.Singleline让.能匹配换行防止攻击者把script拆成多行绕过匹配。但要实话实说正则只能防君子不防高手生产环境要上真正的反XSS库不能只靠这段代码。5.3 静态化页面与数据库不一致现象编辑在后台把文章标题改了前台首页和详情页两天都没变。原因项目开了EnableStaticHtml文章详情页会被生成到StaticHtml目录下的静态HTML文件但改文章的时候只清了内存缓存没有删除对应的物理文件IIS直接吐旧文件。解决在SetStatus方法里除了清缓存把静态页文件也一起删掉。我后来加了一个约定凡是命中静态化生成的文件文件名里必须带上文章Id和缓存版本号更新时用通配符删除该文章所有静态文件简单粗暴但有效。从那以后每次改完配置我都会强制检查一遍静态文件时间戳再也不敢只信内存缓存了。5.4 分页排序字段拼接引发的SQL注入现象安全扫描报告后台新闻列表页存在SQL注入风险排序参数orderBy被直接拼进了SQL语句。原因我图省事用$ORDER BY {orderBy}拼排序字段结果orderBy传DESC; DROP TABLE News--也能拼进去。解决排序字段做白名单映射只允许SortId、PublishedAt、ViewCount三选一其余一律回退默认值。核心逻辑是值不直接进SQL白名单索引才进SQL。private static readonly Dictionarystring, string SortMap new Dictionarystring, string { { time, PublishedAt DESC }, { hot, ViewCount DESC }, { id, Id DESC } }; var orderBy SortMap.TryGetValue(input, out var sortSql) ? sortSql : PublishedAt DESC;参数说明input是前端传的排序字段SortMap把外部值映射成内部写死的SQL片段TryGetValue失败就用默认的按发布时间倒序。这样攻击者传什么都影响不了SQL结构参数化查询再配合这个双重保险。这算是我在这套系统上最深刻的一课查询条件可以动态SQL片段永远不能动态。5.5 线上500错误看不到日志现象部署到服务器后某个页面白屏显示“服务器错误”但Event Viewer里也找不到细节。原因web.config里customErrors模式设成了RemoteOnly远程访问看不到具体异常而项目本身没接日志框架异常被MVC吞了。解决接了一个最简单的文本日志在Application_Error里拦截未处理异常写入App_Data/Logs目录文件名按日期区分。从那以后我排查问题第一件事就是打开当天的日志文件而不是反复刷新页面猜原因。日志接好之后你会发现自己改Bug的速度快了一倍不止因为异常信息、堆栈、访问路径全在眼前。6. 二次开发把老系统改造成可扩展的API服务这套系统改起来最有价值的动作不是换个皮肤而是把“读新闻”的能力拆成开放接口。我的做法是新增一个Web API项目只暴露只读接口老后台继续用MVC页面但前台App或小程序直接走API。这样做的现实原因很简单同一个新闻库以后可能同时喂给PC端、手机站和内部系统每次都给MVC页面显然不现实。API控制器很简单核心就是把Service层的只读查询复用过来[RoutePrefix(api/news)] public class NewsApiController : ApiController { private readonly INewsQueryService _query; public NewsApiController(INewsQueryService query) { _query query; } [HttpGet] [Route({id})] public IHttpActionResult Detail(int id) { var item _query.GetDetail(id); if (item null) { return NotFound(); } return Ok(item); } }逻辑说明RoutePrefix定义了接口的根路径Route里的{id}是URL参数GetDetail返回的视图模型里不包含内部字段比如后台标记直接序列化Json输出。改造的时候要注意把Service层的返回对象换成DTO别把实体整个扔给前端否则内幕字段泄露事小循环引用导致序列化报错才是真坑。如果你不想拆API另一种改法是给现有MVC加入Redis缓存把内存缓存换成分布式的好处是多个Web服务器可以共享缓存。改动集中在NewsService里把_cache的Remove和Get换成Redis客户端调用就行业务代码基本不用动。验证改造是否成功我会先跑一遍接口冒烟测试用命令行工具请求/api/news/1确认返回200和JSON结构再改一下文章标题重新请求确认返回的是新标题最后看一眼数据库连接数有没有异常增长。接口改造虽然动作不大但能让你以后对接任何前端都不慌。这套系统给我最大的教训是老项目改造最忌推翻重来在原有结构上做最小侵入的加法既保住了线上稳定又给未来留了条路。希望你在这个项目上少走我走过的弯路跑起来是第一要务改起来心里有数。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑