简介:一份基于ASP.NET(C#)的新闻发布系统完整源码包,面向需要课程设计或项目实战参考的开发者,尤其适合正在学习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.cshtml |
| News.BLL | 业务逻辑、状态流转、缓存清理 | Services/NewsService.cs, Services/CategoryService.cs |
| News.DAL | EF数据上下文、仓储实现 | Repository/NewsRepository.cs, DbContext/NewsDbContext.cs |
| News.Models | 实体、视图模型、枚举 | Entities/News.cs, ViewModels/NewsDetailViewModel.cs |
| News.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,里面就三个DbSet:News、Category、Tag,外加一个文章的按条件分页查询方法。这套系统的分页不在数据库层面写存储过程,而是用EF的Skip/Take,配合一个自定义的PagedResult 返回总记录数和当前页数据。这里有个约定:所有列表查询都必须走同一个分页方法,禁止各自写Skip,否则统计总数和列表条件不一致,页面上就会出现“显示第2页却没有第1页单数”的诡异问题。
仓储层长这样(简化版):
public class NewsRepository : INewsRepository { private readonly NewsDbContext _db; public NewsRepository(NewsDbContext db) { _db = db; } public PagedResult<News> 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 PagedResult<News>(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?id=123,这是专门配置的路由。全局路由注册里加了一条约束,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的params:url里写路径模式,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 文章发布与状态流转:草稿、审核、发布、下线
新闻系统最不敢写叉的就是状态机。这套系统的写法是:保存草稿时Status=0,点“提交审核”时Status=1,审核通过后Status=2,手动下线置为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 class="news-detail"> <h1>@Model.Title</h1> <div class="meta"> <span>@Model.CategoryName</span> <span>@Model.PublishedAt.ToString("yyyy-MM-dd HH:mm")</span> <span>@Model.ViewCount 次阅读</span> </div> <div class="content"> @Html.Raw(Model.SanitizedContent) </div> <div class="pager"> @for (var i = 1; i <= Model.PageCount; i++) { <a href="/news/@Model.Id/@i">@i</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,密码文件里写的是Admin@123,启动前第一件事是改成自己的密码。数据库连接字符串默认指本机的SQL Server实例,如果你的实例命名不是默认的,就需要动手改web.config。
4.2 配置项逐一说明:改之前先弄清楚是干什么的
web.config是整个项目的“总闸”,里面有几个键值得花两分钟看懂。
<connectionStrings> <add name="NewsDb" connectionString="Server=.;Database=NewsDb;User Id=sa;Password=yourpwd;MultipleActiveResultSets=true" providerName="System.Data.SqlClient" /> </connectionStrings> <appSettings> <add key="UploadRoot" value="~/Uploads/News/" /> <add key="PageSize" value="10" /> <add key="EnableStaticHtml" value="false" /> <add key="StaticHtmlRoot" 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 Dictionary<string, string> SortMap = new Dictionary<string, 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结构;再改一下文章标题,重新请求,确认返回的是新标题;最后看一眼数据库连接数有没有异常增长。接口改造虽然动作不大,但能让你以后对接任何前端都不慌。这套系统给我最大的教训是:老项目改造最忌推翻重来,在原有结构上做最小侵入的加法,既保住了线上稳定,又给未来留了条路。希望你在这个项目上少走我走过的弯路,跑起来是第一要务,改起来心里有数。
本文还有配套的精品资源,点击获取