公司动态
ASP.NET Core MVC 企业建站实战:从初始化到安全部署
简介本资源是一个基于ASP.NET Core MVC框架构建的企业级内容管理系统KWCMS完整示例面向Web开发初学者与中级.NET开发者解决企业官网、产品展示及内容管理类网站的快速搭建问题。项目采用标准MVC三层架构集成Entity Framework Core Code First模式支持MySQL与SQL Server双数据库配置并涵盖用户认证、权限控制、内容发布等典型CMS功能模块。压缩包共2000个文件含54个C#业务逻辑文件、50个Razor视图cshtml、884个前端脚本js、269个样式文件css及320张图片资源整体17.13MB结构清晰、模块分离度高便于理解前后端协同与数据库迁移流程。已有921人学习下载读者可直接运行调试、参考完整目录组织方式、复用控制器与模型设计范式并借鉴ASP.NET Core与EF Core在多数据库场景下的适配实践。 接手企业建站那类需求的时候团队里经常争框架。一边是 WordPress 和各类建站系统部署快、后台现成另一边是 React/Vue 前后端分离灵活但前期工作量大。后来我用 asp.net core mvc 完整搭过几个企业官网包括集团站、设备制造厂站和一家做外贸的工厂站算是把这条路踩熟了。说实话在既要 SEO 友好、又要后期维护可控、还要能随时加定制功能这几个条件下ASP.NET Core MVC 是一个被低估但非常顺手的选择。这篇文章不打算写成那种翻来覆去讲概念的教程而是直接按我从零搭建企业网站的流程走一遍从为什么选它、项目怎么初始化、数据库怎么设计到请求是怎么在 MVC 里流转的再到联系表单、产品列表、远程验证、SQL 注入防护和部署上线。所有代码都是 .NET 9 时代的最新写法但你如果用的是 .NET 6/8迁移成本也极低差异我会在对应位置标注。1. 为什么企业网站值得选 ASP.NET Core MVC1.1 企业网站的真实需求决定了技术选型先想清楚企业网站到底要什么。一个典型的企业官网核心页面不外乎公司介绍、产品展示、新闻动态、联系我们。听起来简单但有几个隐性需求很要命第一页面要能被搜索引擎收录要求服务端渲染返回完整 HTML第二后期要能加各种定制功能比如在线询价、经销商登录、产品参数对比第三非技术员工要能更新内容所以后台不能太简陋第四安全和性能不能拖后腿。这四条一摆很多技术方案就出局了。纯前端 SPA 在 SEO 上有先天劣势虽然可以用预渲染弥补但每次加页面都要多一个步骤不够省心。WordPress 这类 CMS 倒是后台现成可一旦要深度定制业务逻辑PHP 插件生态的复杂度会让你怀疑人生。而 ASP.NET Core MVC 天然就是服务端渲染控制器输出完整 HTML搜索爬虫拿到的就是成品页面同时它又保留了你随时写任意业务代码的能力——这是它适合企业站的根本原因。1.2 和 Razor Pages、Blazor 怎么选如果你去搜 .NET 做网站会看到 Razor Pages、Blazor 甚至传统的 Web Forms 也在选项里。我的建议是做企业官网优先 MVC。Razor Pages 适合页面逻辑简单、每个页面相对独立的场景比如内部工具、报表页面。但是企业站会有很多共享的页面结构统一的导航和页脚、产品列表和详情的联动、多语言切换MVC 的 Controller 更擅长处理这类复杂交互。Blazor 则更适合后台管理系统它重度依赖 SignalR 维持实时连接用在面向公众的官网上会平白增加服务器压力而且首屏加载体验在弱网环境下不如服务端渲染。为了说得更直观我整理过一张选型对照表方案渲染方式SEO 友好度适合场景学习曲线MVC服务端渲染 Razor 视图高企业官网、内容站、电商前台中等Razor Pages服务端渲染页面模型高表单页、内部工具较低Blazor Server实时双向通信中后台管理、高交互应用偏高Blazor WebAssembly浏览器端运行需预渲染复杂业务前端偏高我最早也试过用 Blazor 搭企业站前端做到一半放弃了——不是不能做而是杀鸡用牛刀。访客打开首页需要的只是内容不是实时的双向通信。MVC 把请求→处理→返回 HTML这条链路压缩到最简对官网这种读多写少的场景恰到好处。1.3 版本选择直接上 .NET 9文章标题提到 asp.net core 9这是 .NET 9 时代的 ASP.NET Core。它的 LTS 版本是 .NET 8而 .NET 9 是 STS标准期限支持主打性能优化和新 API。对企业站来说两者都完全可用我的建议是全新项目、想长期稳定用 .NET 8 LTS支持到 2026 年 11 月想体验最新特性、不介意 18 个月后升级用 .NET 9。本文示例基于 .NET 9代码用到的最小 API、路由、EF Core 9 等特性在 .NET 8 中同样存在只有少数 API 签名有细微差别。无论选哪个核心的 MVC 开发模式不会有变化。2. 项目初始化与数据库建模先把这个地基打牢2.1 创建项目和分层思路企业网站虽说不复杂但绝不建议把所有代码塞进一个项目里。我用的是最经典的三层结构在同一个解决方案里拆成三个项目MyCompany.sln ├── src/MyCompany.Web # ASP.NET Core MVC 项目表现层 ├── src/MyCompany.Core # 领域模型、接口定义核心层 └── src/MyCompany.Infrastructure # EF Core、仓储实现基础设施层对于小型企业站也许三层有点重但考虑到后期大概率要加微信支付、对接物流、集成 ERP分层越早越好。改了数据库表UI 层不至于跟着大改。创建命令如下dotnet new mvc -n MyCompany.Web -f net9.0 dotnet new classlib -n MyCompany.Core -f net9.0 dotnet new classlib -n MyCompany.Infrastructure -f net9.0 dotnet add MyCompany.Web reference MyCompany.Core MyCompany.Infrastructure dotnet add MyCompany.Infrastructure reference MyCompany.Core然后给 Infrastructure 项目安装 EF Core 的数据库驱动。这里我推荐用 Pomelo.EntityFrameworkCore.MySql 连接 MySQL或者 Npgsql.EntityFrameworkCore.PostgreSQL 连 PostgreSQL。纯微软生态里 SQL Server 也可以但企业站如果部署在 Linux 服务器上MySQL/PostgreSQL 更省钱也更容易运维。dotnet add MyCompany.Infrastructure package Pomelo.EntityFrameworkCore.MySql dotnet add MyCompany.Web package Microsoft.EntityFrameworkCore.Design2.2 企业网站的数据库应该有哪些表企业官网的库不需要搞得很复杂但基本的业务边界要清楚。我通常从这几张表起步表名用途关键字段Companies企业基本信息一个站点一般一条记录名称、简介、Logo、联系方式Products产品展示名称、编号、描述、图片、价格可空、是否上架ProductCategories产品分类名称、别名、排序News新闻动态标题、正文、发布时间、是否发布Messages联系表单/留言姓名、电话、邮箱、内容、处理状态Users后台管理账号用户名、密码哈希、角色其中 Products 和 ProductCategories 是一对多关系Products 和 News 都建议加一个是否显示字段这样后台下架和前台读取之间有一个明确的开关而不是直接删数据。下面是一个简化的实体定义放在 Core 项目里namespace MyCompany.Core.Entities; public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public string? Code { get; set; } // 产品编号 public decimal? Price { get; set; } public string? Description { get; set; } public string? ImagePath { get; set; } public bool IsPublished { get; set; } public int CategoryId { get; set; } public ProductCategory? Category { get; set; } public DateTime CreatedAt { get; set; } DateTime.Now; } public class ProductCategory { public int Id { get; set; } public string Name { get; set; } string.Empty; public string? Slug { get; set; } // 用于 URL 别名如 /products/industrial-equipment public int SortOrder { get; set; } public ICollectionProduct Products { get; set; } new ListProduct(); }这里有个细节容易踩坑Price一定要用decimal?而不是double。企业产品价格可能涉及分、厘的精度double的浮点误差在金额计算上是不能接受的。另外Slug字段用在小公司官网可能被忽略但一旦产品分类多起来URL 是否语义化会直接影响 SEO这个字段是给搜索引擎看的不是给人看的。2.3 DbContext 与初始数据Infrastructure 项目里的 DbContext 写法using Microsoft.EntityFrameworkCore; using MyCompany.Core.Entities; namespace MyCompany.Infrastructure.Data; public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetProduct Products SetProduct(); public DbSetProductCategory ProductCategories SetProductCategory(); public DbSetNews News SetNews(); public DbSetMessage Messages SetMessage(); public DbSetCompanyInfo CompanyInfos SetCompanyInfo(); public DbSetUser Users SetUser(); }在 Program.cs 里注册服务builder.Services.AddDbContextAppDbContext(options { var connectionString builder.Configuration.GetConnectionString(DefaultConnection); options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)); });然后在 appsettings.json 里配置连接字符串。这里有个实用建议ServerVersion.AutoDetect会连数据库探测版本每次启动多一次往返。如果你确定 MySQL 版本直接写死更好比如new MySqlServerVersion(new Version(8, 0, 36))省去探测开销。生产环境尤其建议这样写。初始化数据用 EF Core 的HasData种子数据或者写一个简单的DbInitializer在启动时执行。我倾向于后者因为企业站上线后运营人员会通过后台改数据而种子数据只是保证首次运行有内容可看不必和代码强绑定。3. 一次完整请求的走读路由、控制器、视图如何协作3.1 从浏览器输入 URL 到 HTML 返回中间发生了什么MVC 的架构图常见但很多人画完就忘。我第一次独立排错时也被绕晕过所以这里用一次实际请求来走一遍。假设用户访问的是/products/industrial-equipment这个地址表达的意思很明确产品分类别名为 industrial-equipment 的页面。请求进入 Kestrel 后首先经过的是中间件管道然后是路由中间件。默认情况下ASP.NET Core 会在 Program.cs 中注册app.MapControllerRoute(...)它使用的是传统路由而不是属性路由。默认模板通常是app.MapControllerRoute( name: default, pattern: {controllerHome}/{actionIndex}/{id?});这个模板的含义是URL 第一段是控制器名第二段是 action 名第三段可选参数 id。那么/products/industrial-equipment会映射到ProductsController的IndustrialEquipmentaction 吗显然不会因为传统路由只有三段这里第三段只会被当作 id 处理。要让 URL 更语义化就要在 Controller 上用属性路由或者定义自定义路由模板。我在产品分类页面用的是属性路由[Route(products/{slug})] public IActionResult Category(string slug) { var category _db.ProductCategories .Include(c c.Products.Where(p p.IsPublished)) .FirstOrDefault(c c.Slug slug); if (category null) { return NotFound(); } return View(category); }这里就是 MVC 的核心逻辑路由把 URL 拆成 controller、action 和参数模型绑定再根据slug参数名从路由数据里取值然后传给 action。action 从数据库取数据最后把数据交给视图渲染。3.2 控制器基类与依赖注入实际企业站里Controller 不会像上面那个例子那么单纯。通常我会给前台业务抽一个BaseController统一处理 ViewBag、站点基本信息、页面标题等公共数据。每个控制器通过构造函数注入 DbContext 或仓储服务而不是用new去创建依赖。这一点是 ASP.NET Core 里最基础也最重要的思想依赖注入贯穿整个请求管道保证每个请求拿到独立的 DbContext 实例同时方便测试。我在做集团站时发现一个很值得固化的做法把站点配置这类每个页面都要展示的数据放到一个全局过滤器里读取一次然后缓存在分布式缓存里。而不是每个 action 都查一次库。具体做法是自定义一个IAsyncActionFilter在 action 执行前把CompanyInfo塞到ViewBag.SiteInfo。这样所有视图都能直接ViewBag.SiteInfo.SiteName不用每个方法重复查库。public class SiteInfoFilter : IAsyncActionFilter { private readonly AppDbContext _db; public SiteInfoFilter(AppDbContext db) { _db db; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var siteInfo await _db.CompanyInfos.AsNoTracking().FirstOrDefaultAsync(); if (context.Controller is Controller controller) { controller.ViewBag.SiteInfo siteInfo; } await next(); } }然后在 Program.cs 里注册为全局过滤器builder.Services.AddControllersWithViews(options { options.Filters.AddSiteInfoFilter(); });这个模式强烈推荐。企业站的页脚、导航栏、SEO 标题几乎每页都要用如果每个 action 手动传一次代码会重复到你不想维护。3.3 Razor 视图与布局系统MVC 的视图是服务器端 Razor 模板和很多人的认知不同Razor 不是HTML 里嵌 C#而是C# 里用 标记切换到 HTML。它有一个非常实用的功能叫布局页Layout相当于一个页面骨架。企业站的_Layout.cshtml里我会放head里的 meta、title、CSS 引用顶部导航栏中间RenderBody()页脚全局 JS每个页面视图只需要写核心内容布局页自动套用。另一个实用技巧是用RenderSection(Scripts, required: false)在布局页末尾留一个脚本区域让子页面可以追加自己的 JS而不必全部堆在_Layout.cshtml。!DOCTYPE html html langzh-CN head meta charsetutf-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleViewData[Title] - ViewBag.SiteInfo?.SiteName/title link relstylesheet href~/css/site.css asp-append-versiontrue / /head body header nav classnavbar.../nav /header main RenderBody() /main footer.../footer script src~/js/site.js asp-append-versiontrue/script await RenderSectionAsync(Scripts, required: false) /body /html注意标题里的ViewData[Title]和ViewBag.SiteInfo.SiteName。每个子页面只要在视图开头设置ViewData[Title] 公司简介;浏览器标签页和 SEO 标题就自动变成了公司简介 - 某某公司这是一个很简单但很容易被忽视的细节。4. 企业站三个高频功能块的落地实现4.1 产品列表分页、筛选和 IQueryable 的正确姿势产品列表是企业站的重头戏。假如公司有两百个产品一次全查出来再渲染页面会非常慢用户体验也差。正确做法是服务端分页。public async TaskIActionResult Index(int page 1, int pageSize 12, int? categoryId null) { IQueryableProduct query _db.Products .Where(p p.IsPublished); if (categoryId.HasValue) { query query.Where(p p.CategoryId categoryId.Value); } var total await query.CountAsync(); var items await query .OrderByDescending(p p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); var viewModel new ProductListViewModel { Products items, CurrentPage page, TotalPages (int)Math.Ceiling(total / (double)pageSize), CategoryId categoryId }; return View(viewModel); }这里的关键是IQueryable的延迟执行Where、OrderBy、Skip、Take都只是在拼接表达式树直到CountAsync和ToListAsync才真正把 SQL 发到数据库。我见过有同事图省事先ToList()再在内存里分页数据量一上去就明显卡顿。另外分页时不要直接手写a href/?page2MVC 自带 Tag Helperasp-route-page(item)可以帮你在服务端把 URL 拼好既简洁又不会拼错参数。在视图里分页组件建议做成一个 PartialView企业站多个页面产品列表、新闻列表都复用同一个分页 UI。4.2 产品详情路由参数、404 和缓存陷阱产品详情页更简单核心就三件事根据 id 或 slug 查产品没有就返回 404有就渲染视图。[Route(product/{id:int})] public async TaskIActionResult Detail(int id) { var product await _db.Products .Include(p p.Category) .FirstOrDefaultAsync(p p.Id id p.IsPublished); if (product null) { return NotFound(); } return View(product); }注意我用了{id:int}约束这样/product/abc这种非法请求不会进入这个 action而是直接 404省得在代码里做类型判断。这里有个我自己踩过的坑给产品详情页加缓存时千万别把整个响应缓存住因为产品价格、上下架状态一变缓存页还是旧数据。如果一定要缓存建议用ResponseCache设置很短的过期时间或者在后台修改产品时主动清除相关缓存键。企业站改产品频率不高但一旦改了用户看到旧价格是很严重的信任危机。4.3 联系表单模型验证、PRG 模式和防重复提交联系表单是企业站最典型的用户交互。用户填写姓名、电话、邮箱、内容提交后存到数据库然后后台人员去处理。看起来简单但细节多得吓人。首先是模型定义public class ContactViewModel { [Required(ErrorMessage 请填写姓名)] [StringLength(50)] public string Name { get; set; } string.Empty; [Required(ErrorMessage 请填写联系电话)] [Phone(ErrorMessage 电话格式不正确)] public string Phone { get; set; } string.Empty; [Required(ErrorMessage 请填写邮箱)] [EmailAddress(ErrorMessage 邮箱格式不正确)] public string Email { get; set; } string.Empty; [Required(ErrorMessage 请填写留言内容)] [StringLength(500, MinimumLength 10, ErrorMessage 留言内容请控制在10-500字之间)] public string Content { get; set; } string.Empty; }控制器里的标准写法是 PRGPost-Redirect-Get模式用户提交表单 - 服务器验证 - 保存数据 - 重定向回成功页面。这个模式可以避免用户刷新页面导致重复提交。[HttpPost] [ValidateAntiForgeryToken] public async TaskIActionResult Contact(ContactViewModel model) { if (!ModelState.IsValid) { return View(model); } _db.Messages.Add(new Message { Name model.Name, Phone model.Phone, Email model.Email, Content model.Content, CreatedAt DateTime.Now, IsProcessed false }); await _db.SaveChangesAsync(); TempData[Message] 提交成功我们会尽快与您联系。; return RedirectToAction(nameof(Contact)); }视图里有两样东西不能少form asp-actionContact methodpost Html.AntiForgeryToken() !-- 表单字段 -- /formasp-actionTag Helper 会自动把表单 action 指向Contact这个 action省去硬编码 URL 的维护成本。AntiForgeryToken是防御 CSRF 攻击的基础配合 Controller 上的[ValidateAntiForgeryToken]特性一起使用。这一点对安全至关重要我在第六部分会详细说明。从用户体验角度建议在表单下方加一个asp-validation-summary显示所有错误而不是让错误分散在各个字段里。企业站的访客很多是普通用户看到一大片红字很容易直接关掉页面。5. 远程验证Remote企业站表单里那把隐形校验尺5.1 远程验证解决了什么问题前面的表单验证都是客户端和服务端分开的jQuery Validation 在浏览器端做格式校验提交后服务端再校验一遍。但有些校验必须查数据库才知道比如这个产品编号是否已存在这个用户名是否被注册这个分类别名是否重复。传统做法是用户填完 - 点提交 - 服务端校验 - 把错误信息带回表单。来回刷新一次体验很差。远程验证Remote Validation就是为了解决这个痛点它让 [Remote] 特性在用户离开输入框时自动向服务器发一个 Ajax 请求用数据库数据做校验然后把结果直接显示在字段下方整个过程页面不需要刷新。企业站后台管理、会员注册、产品录入这些场景非常值得用。5.2 一个真实例子验证产品编号是否唯一假设后台新增产品时要求产品编号不能重复。以往的做法是提交后服务端校验发现重复让用户改。现在用远程验证用户在产品编号输入框一失焦就立刻知道结果。先写校验 action。注意它不在某个具体的业务 Controller 里而是一个专门用来响应用户输入的 JSON 端点[AcceptVerbs(GET, POST)] public async TaskIActionResult VerifyProductCode(string code, int? id) { var exists await _db.Products .AnyAsync(p p.Code code p.Id ! id); if (!exists) { return Json(true); // 校验通过 } return Json($产品编号 {code} 已存在请更换。); }这里id参数用于编辑场景当编辑某个产品时它自己的编号自然不在重复范围内所以用p.Id ! id排除自身。刚开始用远程验证的人很容易漏掉这个参数导致编辑保存时永远报编号已存在。然后在视图模型属性上加[Remote]public class ProductEditViewModel { public int Id { get; set; } [Required] [Remote(action: VerifyProductCode, controller: Products)] public string Code { get; set; } string.Empty; }视图里这个输入框的写法不用改因为[Remote]特性会自动生成>input>var exists id.HasValue ? await _db.Products.AnyAsync(p p.Code code p.Id ! id.Value) : await _db.Products.AnyAsync(p p.Code code);我第一次写这个逻辑时直接用了p.Id ! id调试了很久才意识到 EF Core 会把 nullable 参数翻译成奇怪的 SQL 条件。5.4 其他适合远程验证的场景远程验证不只适用于唯一性校验。我还在后台的 SEO 别名、表单的自定义字段上用过。比如产品分类的 Slug 字段我希望用户填的英文别名在 URL 中保持可读性和唯一性就可以给 Slug 也加一个[Remote][Remote(VerifyCategorySlug, ProductCategories)] public string Slug { get; set; } string.Empty;校验方法里除了查重复还可以顺手检查格式是否合法是否包含空格、中文、特殊字符。逻辑就变成了一个组合校验这样的体验比 提交后再告诉你 Slug 格式不对 好得多。6. 安全基线SQL 注入、XSS、CSRF一个都别留6.1 SQL 注入EF Core 为什么帮你挡了但没完全挡很多人谈 SQL 注入色变实际上在 ASP.NET Core MVC 里只要规范使用 EF Core绝大部分注入攻击都打不进来。原因很简单EF Core 的 LINQ 查询在生成 SQL 时所有变量值都会参数化。比如这段代码var products await _db.Products .Where(p p.CategoryId categoryId p.Name.Contains(name)) .ToListAsync();无论categoryId和name来自哪里EF Core 都会把它们的值放进 SQL 参数。数据库收到的是一个参数化查询不是拼接后的字符串所以哪怕name是1; DROP TABLE Products;--数据库也不会执行它只会把这个字符串当作匹配条件。但真正危险的是FromSqlRaw和ExecuteSqlRaw。这两个 API 允许你直接写 SQL 字符串如果字符串里拼接了用户输入就等于把 SQL 注入的钥匙送到了攻击者手里。我需要特别提醒企业站的报表、统计、动态排序等需求很容易让人图方便用原始 SQL 去实现。如果你必须在 EF Core 里执行原始 SQL请务必用FromSqlInterpolated而不是FromSqlRaw。看这个对比// 危险SQL 注入风险 var products _db.Products .FromSqlRaw($SELECT * FROM Products WHERE Name LIKE %{search}%) .ToList(); // 安全参数化查询 var products _db.Products .FromSqlInterpolated($SELECT * FROM Products WHERE Name LIKE CONCAT(%, {search}, %)) .ToList();FromSqlInterpolated会把插值参数自动转换成SqlParameter从机制上避免了注入。这也是我在项目里给团队的硬性约定禁止在 Web 层使用 FromSqlRaw一律用 Interpolated 或 LINQ。6.2 XSSRazor 的编码机制和过滤器的正确用法XSS跨站脚本攻击的核心问题是用户输入的内容被浏览器当作 HTML/JavaScript 执行了。ASP.NET Core MVC 的 Razor 视图里输出默认是 HTML 编码的。也就是说如果某个产品描述是scriptalert(xss)/scriptproduct.Description会原样输出为lt;scriptgt;...浏览器只会把它显示成文本不会执行。但有几个地方要注意Html.Raw 要谨慎如果你用Html.Raw(model.Content)输出富文本就绕过了编码。此时必须确保这个值是后台可信管理员录入的。如果允许任意用户在前台录入内容并直接展示风险就非常大。富文本内容要做白名单过滤在后台上传新闻或产品介绍时用 Markdown 或经过白名单过滤的 HTML而不是直接接受所有标签。类似 Ganss.Xss 这样的库可以在服务器端清洗 HTML只保留p、img、a等安全标签去掉script、iframe、on*事件属性。URL 校验如果你的页面允许用户提交链接比如企业官网的产品外链在视图里用Url.Content()或链接标签的 Tag Helper 会自动做Url编码但href上的javascript:协议要额外防范。建议使用Uri.TryCreate检查协议必须是http或https。6.3 CSRFAntiForgeryToken 为什么不能去掉MVC 的表单默认要求ValidateAntiForgeryToken这其实是防护跨站请求伪造CSRF的关键。攻击者可以构造一个恶意页面诱导用户点击向你的服务器发送一个伪造的 POST 请求。如果没有防伪令牌服务器无法区分这个请求是用户自愿发的还是被恶意页面强迫发出的。所以我在 4.3 的联系表单里特别加了Html.AntiForgeryToken()和[ValidateAntiForgeryToken]。这一对组合的工作原理是服务器生成一个加密的 token 放在表单里同时通过 cookie 保存一份。提交时后端会比较两者是否匹配。攻击者的恶意页面拿不到这个 token所以伪造的请求会被直接拒绝。如果你开发的是前后端分离的 API这套机制需要换成 JWT 或自定义请求头。但在 MVC 为服务端渲染的项目里AntiForgeryToken 是最简单有效的方案。6.4 安全响应头企业网站的点缀但必须做许多安全检测工具会对企业网站的安全响应头做检查。虽然这些头不能百分之百防住攻击但它们是成本最低的防线。我的做法是在中间件管道中统一添加app.Use(async (context, next) { context.Response.Headers[X-Content-Type-Options] nosniff; context.Response.Headers[X-Frame-Options] DENY; context.Response.Headers[Referrer-Policy] strict-origin-when-cross-origin; context.Response.Headers[Content-Security-Policy] default-src self; img-src self data:; style-src self unsafe-inline; await next(); });X-Frame-Options: DENY能防止网站在 iframe 里被嵌入减少点击劫持风险。Content-Security-Policy是最复杂的也是最容易被搞坏的——如果配置太严网站自己的 CDN、内联脚本会被拦截配置太松就形同虚设。我给出的这个策略是相对保守的基本适合普通企业站。如果你的页面引入了第三方统计脚本或在线客服组件记得把对应域名加进script-src。部署到生产环境前建议用脚手架工具比如 NWebsec自动生成中间件或者用app.UseSecurityHeaders()这种现成包省心不少。7. 发布上线从开发机到服务器的最后一个坑7.1 使用 dotnet publish 而不是直接传源代码开发环境跑得好好的一上服务器就挂这类问题多半出在发布方式上。企业站的正确姿势是在开发机上执行 publish产出可部署的文件再把文件传到服务器而不是把整个源代码目录拖到服务器上。dotnet publish MyCompany.Web -c Release -o ./publish-site这条命令会做编译、生成 Razor 视图、复制静态资源等多个步骤输出一个包含所有依赖的目录。然后在服务器上只需要有 .NET 运行时或者直接用自包含发布连运行时都不用装。如果目标服务器是 Linux我推荐用自包含发布省去服务器装运行时的麻烦dotnet publish MyCompany.Web -c Release -r linux-x64 --self-contained true -o ./publish-linux自包含发布体积大不少但换来的是服务器环境无关性。我在客户的 CentOS 服务器上部署过多次这种方式几乎从没遇到过运行时版本不匹配的问题。7.2 IIS 部署web.config 和 AspNetCoreModuleV2Windows 服务器上ASP.NET Core 应用通常由 IIS 托管。项目构建后会在发布目录自动生成web.config里面配置了AspNetCoreModuleV2和进程管理方式。核心内容大致是configuration location path. inheritInChildApplicationsfalse system.webServer handlers add nameaspNetCore path* verb* modulesAspNetCoreModuleV2 resourceTypeUnspecified / /handlers aspNetCore processPathdotnet arguments.\MyCompany.Web.dll stdoutLogEnabledfalse hostingModelinprocess / /system.webServer /location /configuration这里有一个非常关键的判断hostingModelinprocess还是outofprocess。默认是 inprocess意思是 ASP.NET Core 应用直接运行在 IIS 的工作进程里性能最好。但如果你遇到和某些模块冲突导致应用无法启动可以临时改为 outofprocess 调试IIS 会把请求转发给独立的 Kestrel 进程。不过生产环境建议还是 inprocess差别在马鞍型压测下非常明显。另外发布目录如果放在系统盘的inetpub\wwwroot下一定记得给 IIS 应用程序池账户设置目录读取权限否则启动时会报无法访问文件或者直接 502.3。7.3 Linux Nginx 反向代理企业站的省钱方案Linux 服务器上用 Nginx 做反向代理在前面把 80/443 端口的请求转发给 Kestrel是当前非常主流的企业站部署方式。为什么要加一层 Nginx 而不是让 Kestrel 直接对外原因有三个Nginx 处理静态文件和高并发连接的效率更高它天然负责 TLS 证书和 HTTP/2它在应用崩溃和重启时可以做缓冲避免用户直接看到连接失败。Nginx 配置核心如下server { listen 80; server_name yourcompany.com; location / { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }同时在 Program.cs 里请务必加上转发的头处理不然 HTTPS 环境下生成的所有 URL 都还是 http 协议builder.Services.ConfigureForwardedHeadersOptions(options { options.ForwardedHeaders ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; }); app.UseForwardedHeaders();这是我部署 HTTPS 时踩过最深的一个坑Nginx 已经把请求从 HTTPS 转成了到 Kestrel 的 HTTPKestrel 不知道原始协议是 HTTPS于是返回的响应里所有链接都是 http://。加上UseForwardedHeaders之后它才能读取 Nginx 传过来的X-Forwarded-Proto头生成正确的 HTTPS 链接。7.4 .NET 9 应用在服务器上的进程守护在 Linux 生产环境Kestrel 进程如果崩溃了Nginx 不会自动拉起它。所以我一般会用 systemd 守护进程。写一个.service文件[Unit] DescriptionMyCompany Website [Service] WorkingDirectory/var/www/mycompany ExecStart/usr/bin/dotnet /var/www/mycompany/MyCompany.Web.dll Restartalways RestartSec10 KillSignalSIGINT EnvironmentASPNETCORE_ENVIRONMENTProduction [Install] WantedBymulti-user.target启用并启动服务sudo systemctl enable mycompany.service sudo systemctl start mycompany.serviceRestartalways保证即使 Kestrel 因未处理异常退出10 秒后也会自动重启。配合 Nginx 的缓冲机制用户感知几乎为零。7.5 上线前的最后检查清单发布上线不只是敲几条命令。根据我多次部署企业站的经验整理一个检查清单每次照着过一遍检查项说明appsettings.Production.json确认生产数据库连接字符串、关闭开发者异常页数据库迁移执行dotnet ef database update或发布时自动迁移HTTPS 证书确认 Nginx/IIS 已配置证书并强制跳转 HTTPS静态资源缓存给图片、CSS、JS 加上缓存头减轻服务器压力日志路径确认日志写入目录存在且有写权限防火墙端口只留 80/443Kestrel 的端口不要对外暴露定时备份配置数据库自动备份任务验证远程验证接口生产环境确认[Remote]的 action 可访问避免 404最后一个检查项很容易被忽略因为开发环境一切正常但上线后如果发布目录没有把新的 Controller 编译进去或者路由被 Nginx 的 location 规则拦截远程验证会静默失败。此时用户提交表单时看不到错误提示但实际上服务端校验从未触发。结尾我做了这么多企业站的 ASP.NET Core MVC 项目最深的体会是这个框架把快速出活和留足扩展空间平衡得很好。它不像纯前端方案那样要写一堆胶水代码也不像传统 CMS 那样遇到定制需求就抓狂。你只要在项目初期把分层、数据库、依赖注入这些骨架搭好后面每加一个功能模块都是在既有轨道上添车厢而不是重新造一辆车。最后分享一个我个人的习惯不管项目多小上线前一定要做一次全站 URL 冒烟测试把前台所有页面和后台所有操作走一遍尤其要注意远程验证这类 Ajax 功能在生产环境是否正常。企业网站不像 C 端产品可以快速迭代试错访客点进来看到一个 500 页面或者提交表单没反应对品牌的影响会被放大很多倍。把这条链路在发布前跑通你才能在后续的日子里安心地睡个好觉。本文还有配套的精品资源点击获取