ASP.NET Core 8.0家具管理系统实战:Razor Pages与EF Core核心设计 简介基于ASP.NET Core 8.0打造的现代化家具管理系统是一套面向电商场景的完整后端参考实现主要面向希望掌握Web API开发、清洁架构和第三方服务集成的.NET工程师与开发团队。系统覆盖家具商品浏览、购物车与订单处理、库存与用户管理、促销活动配置等核心链路同时集成JWT与OAuth登录、Stripe支付、AI智能搜索、实时通知及评分机制功能相当完整。资源包共245个文件体积仅326KB以207个C#源码文件为主体另有21个SVG图标、6个JSON配置、4个csproj工程文件以及Dockerfile、数据库脚本和解决方案文件便于按模块阅读和部署。内容预览中的多次数据库迁移、邮件模板服务与支付字段演进直观展示了真实项目的迭代痕迹。目前已有74人学习下载适合作为学习电商API设计、支付集成与容器化部署的参考样例整体轻量且脉络清晰。1. 为什么家具管理系统先要考虑 ASP.NET Core 8.0 而不是堆功能如果你在小微家具厂或者经销门店做过内部管理系统就会知道需求其实非常固定建商品档案沙发、床、书桌记库存出入库跟订单状态再生成几个报表。早年大家习惯用 Excel 或老的 WinForms 程序撑着一旦门店数量超过两三家数据合不拢、权限管不住的问题就会集体爆发。一套基于 ASP.NET Core 8.0 构建的现代化家具管理系统正是冲着这个阶段来的。它用内置的依赖注入和 EF Core 8.0 把数据层和服务层理清楚让几个人照样能维护。这套技术方案适合中小企业也适合想从传统 WebForms 或 .NET Core 3.1 阶段往新框架迁移的团队。下面按一条可运行的路径拆开讲。2. 搭建 ASP.NET Core 8.0 项目骨架模板选型与最小系统项目创建是最容易卡住的地方主要不是 dotnet 命令不会敲而是模板选错后面每步都别扭。家具管理系统这类内部工具页面量可能在二十到五十个之间技术选型不需要追新只需要考虑长期维护成本。先讲选型再讲创建步骤最后说依赖注入注册方式。2.1 选型对比Razor Pages / MVC / Blazor家具管理系统选哪个ASP.NET Core 8.0 的项目模板里你打开 Visual Studio 2022 或执行 dotnet new 时会看到多个 Web 模板。常见的选择集中在 Razor Pageswebapp、MVCmvc、Blazor Serverblazorserver三者之间。它们共享同一个运行时和 DI 容器区别在于组织页面和渲染交互的方式。维度Razor PagesMVCBlazor Server学习曲线低中中页面组织每个页面自包含Controller View 分离组件化适合场景表单 数据展示复杂业务分层高交互仪表盘网络依赖无无SignalR 长连接对后台模板的适配高中中家具管理后台多数页面是“列表 → 编辑 → 保存”的 CRUD 循环还要处理订单详情和库存流水的嵌套列表Razor Pages 正好能把每个页面变成一个独立端点不用像 MVC 那样在 Controller 和 View 之间来回跳。如果你确定要做一个强交互的驾驶舱页面比如门店经营数据大屏那么局部用 Blazor Server 组件也常见但整体不建议一上来就选 Blazor Server。门店网络不稳定时SignalR 长连接一旦断开组件状态恢复要写额外逻辑这是不值当的复杂度。“现代化”在这里不是指必须上 Blazor而是指这套系统可维护、可升级、代码边界清楚。2.2 用 dotnet new 创建项目并替换默认页面先敲下面这组命令创建并验证最小系统能跑dotnet new webapp -n FurnitureManager -f net8.0 cd FurnitureManager dotnet run-n指定项目名-f指定框架版本为 net8.0。浏览器访问http://localhost:5xxx看到模板默认的欢迎页就说明环境正常。默认模板自带 Home、Privacy 页面、共享布局和 Bootstrap对家具管理系统来说可以直接复用布局但要删掉用不上的 Privacy 页面避免后面生成一堆无主文件。rm Pages/Privacy.cshtml Pages/Privacy.cshtml.cs打开Pages/Index.cshtml把欢迎页替换成一个简单的家具列表占位页通过 Razor 语法循环输出商品数据。注意页面顶部保留page指令这是 Razor Pages 成为端点的关键。page model IndexModel h1家具库存/h1 table classtable thead trthSKU/thth名称/thth分类/thth库存/th/tr /thead tbody foreach (var item in Model.Furnitures) { trtditem.Sku/tdtditem.Name/tdtditem.Category/tdtditem.Stock/td/tr } /tbody /table对应代码文件Pages/Index.cshtml.cs里继承PageModel在OnGetAsync中查库并赋值给Furnitures属性。建议在模型类的属性上直接标注[BindProperty]回传时能省不少手动取参的代码。再看Program.cs新模板把所有配置集中在这里。默认生成的内容已经做好最小中间件顺序下面是最常见的起始结构var builder WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); var app builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler(/Error); app.UseHsts(); } app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.MapRazorPages(); app.Run();CreateBuilder会自动加载 appsettings.json 和环境变量UseExceptionHandler在非开发环境返回统一错误页MapRazorPages注册所有带page的页面路由。如果项目是从 .NET Core 3.1 迁移过来的要注意老模板有 Startup.cs 两段式结构新模板合并到了 Program.cs照着旧文章抄会找不到ConfigureServices方法。2.3 依赖注入生命周期注册为什么订单服务要用 Scoped 而不是 Singleton项目里但凡涉及业务逻辑都应该通过接口暴露并在容器里注册。注册方式决定实例存活时间最常见的是这三种Singleton 全程一个实例Scoped 每次 HTTP 请求一个实例Transient 每次解析都创建新实例。错误的生命周期选择会在并发场景下埋雷。builder.Services.AddRazorPages(); builder.Services.AddScopedIOrderService, OrderService(); builder.Services.AddDbContextFurnitureContext(options options.UseSqlite(builder.Configuration.GetConnectionString(FurnitureDb)));AddDbContext默认把 DbContext 注册为 Scoped因此订单服务也必须注册为 Scoped。这样同一个 HTTP 请求内OrderService 和它依赖的 FurnitureContext 是同一个实例事务边界不会串。如果图省事把 OrderService 注册成 Singleton容器会在解析时因为“无法从根提供程序解析作用域服务”直接抛异常即使侥幸跑起来多个请求共享同一个 DbContext 实例SaveChanges会互相干扰产生的内存状态错乱比报错更难排查。在后台任务或测试代码里解析 Scoped 服务时不要从根容器直接拿要先用CreateScope()创建子作用域using var scope app.Services.CreateScope(); var db scope.ServiceProvider.GetRequiredServiceFurnitureContext();这套逻辑在后面的种子数据和集成测试里都会用到。DI 容器替你把对象的创建和销毁管起来你只需要保证注册的生命周期符合依赖树的要求。3. 用 EF Core 8.0 设计家具数据模型代码优先建库、迁移与种子数据数据层是一切功能的前提。家具系统核心数据无非商品、订单、库存流水三块。用 EF Core 8.0 的代码优先方式先定义实体类再用迁移生成数据库表这样表结构进入版本控制后续加字段也能走迁移文件升级而不是靠人工改数据库。3.1 从 SQLite 起步的选型前提家具管理系统的并发和体量都不会太大单门店一天可能几十个订单库存几万行。SQLite 单文件、零配置、备份就是复制文件非常适合先跑起来。连接串写在 appsettings.json 里{ ConnectionStrings: { FurnitureDb: Data Sourcefurniture.db } }这里有一个明确的边界SQLite 的写锁是整个数据库粒度的两人同时提交订单可能出现database is locked。开发阶段感受不明显部署到门店并开始多人使用时就会遇到。所以提前约定好如果门店数增长或并发上来了把UseSqlite换成UseNpgsql或UseSqlServer实体类完全不用动数据库 Provider 包换掉后重新生成一次迁移即可。我一般会在项目 README 里写清楚这个切换路径避免后来接手的人以为只能绑死在 SQLite 上。3.2 三个核心实体的代码与 Fluent API 配置在 Models 文件夹里建三个实体。Furniture 是商品档案Order 是订单头OrderItem 是订单行StockLog 是库存流水。public class Furniture { public int Id { get; set; } public string Sku { get; set; } string.Empty; // 商品唯一编码 public string Name { get; set; } string.Empty; // 商品名 public string Category { get; set; } string.Empty; // 沙发/床/书柜 public decimal Price { get; set; } public int Stock { get; set; } } public class Order { public int Id { get; set; } public string OrderNo { get; set; } string.Empty; public string CustomerName { get; set; } string.Empty; public string Status { get; set; } Pending; // 待付款/待发货/已完成/已取消 public DateTime CreatedAtUtc { get; set; } public ListOrderItem Items { get; set; } new(); } public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public Order? Order { get; set; } public string Sku { get; set; } string.Empty; public string FurnitureName { get; set; } string.Empty; // 下单时冗余商品名防止商品改名影响订单 public int Quantity { get; set; } public decimal UnitPrice { get; set; } // 快照单价 } public class StockLog { public int Id { get; set; } public int FurnitureId { get; set; } public int ChangeQty { get; set; } // 正数入库负数出库 public string? SourceOrderNo { get; set; } public DateTime CreatedAtUtc { get; set; } }订单行里冗余FurnitureName和UnitPrice是刻意的家具商品改价、改名后历史订单仍然要保留下单时的快照。这个设计在财务报表里价值很大否则每次查旧订单都要反查商品表当前值。接着建 DbContext 并用 Fluent API 明确必要的约束public class FurnitureContext : DbContext { public FurnitureContext(DbContextOptionsFurnitureContext options) : base(options) { } public DbSetFurniture Furnitures SetFurniture(); public DbSetOrder Orders SetOrder(); public DbSetStockLog StockLogs SetStockLog(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityFurniture(e { e.ToTable(Furnitures); e.HasKey(x x.Id); e.Property(x x.Sku).HasMaxLength(64).IsRequired(); e.HasIndex(x x.Sku).IsUnique(); // SKU 全局唯一 e.Property(x x.Price).HasColumnType(decimal(18,2)); }); modelBuilder.EntityOrder(e { e.ToTable(Orders); e.HasKey(x x.Id); e.Property(x x.OrderNo).HasMaxLength(32).IsRequired(); e.HasIndex(x x.OrderNo).IsUnique(); // 订单号唯一幂等检查的兜底 e.Property(x x.Status).HasMaxLength(20).IsRequired(); e.HasMany(x x.Items) .WithOne(x x.Order) .HasForeignKey(x x.OrderId) .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.EntityStockLog(e { e.ToTable(StockLogs); e.HasKey(x x.Id); e.HasIndex(x x.SourceOrderNo); // 流水按订单号反查 }); } }Sku唯一索引和OrderNo唯一索引是两个关键约束它们让数据库层面的数据完整性和业务代码互相呼应。decimal(18,2)保证小数精度不会丢。订单删除时订单行级联删除这是合理的因为订单行本身没有独立生命周期。3.3 迁移命令与种子数据第一次 run 就能看到沙发和床先安装依赖和全局工具再执行迁移dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Design dotnet tool install --global dotnet-ef dotnet ef migrations add InitialCreate dotnet ef database updateDesign包是给dotnet-ef在设计时读取 DbContext 用的不装会报“Build failed”或提示找不到 DbContext。迁移执行后项目根目录出现 furniture.db 文件。数据库表结构从此由迁移文件管理之后每次改模型执行dotnet ef migrations add Xxx再dotnet ef database update就能同步。为了让第一次运行就有可看的数据在 Program.cs 中app.Run()之前加一段种子逻辑using (var scope app.Services.CreateScope()) { var db scope.ServiceProvider.GetRequiredServiceFurnitureContext(); db.Database.Migrate(); // 启动时自动应用未执行的迁移 if (!db.Furnitures.Any()) // 已有数据则跳过 { db.Furnitures.AddRange( new Furniture { Sku SF-001, Name 北欧三人布艺沙发, Category 沙发, Price 3299m, Stock 12 }, new Furniture { Sku BD-002, Name 实木双人床 1.8m, Category 床, Price 4599m, Stock 6 } ); db.SaveChanges(); } }db.Database.Migrate()和dotnet ef database update效果类似但放在启动代码里有个好处部署到门店后少敲一条命令。风险也很明显多实例同时启动时会有迁移竞争。稳妥做法是把迁移交给部署脚本代码里只保留种子数据判断。另外种子判断用Any()而非Count() 0前者遇到空表时不遍历全部记录大数据量下性能更好。4. 库存扣减与订单状态机家具系统核心业务落地的两个关键点家具系统的核心业务逻辑集中在两处下单时扣库存并记录流水以及订单状态的合法流转。这两块看似简单做错就会直接造成财务对账差异或库存负数必须把约束写进服务层和数据库结构里。4.1 扣库存的幂等设计用订单号做天然去重最常见的翻车场景是前端提交订单时用户多点了一次按钮或者网络超时后前端重试结果同一笔订单被创建两次库存被扣两次。解决办法是让服务层天然幂等用业务订单号做去重。public interface IOrderService { TaskOrder CreateOrderAsync(CreateOrderRequest request); } public class CreateOrderRequest { public string OrderNo { get; set; } string.Empty; public string CustomerName { get; set; } string.Empty; public ListOrderItemRequest Items { get; set; } new(); } public class OrderItemRequest { public string Sku { get; set; } string.Empty; public int Quantity { get; set; } }实现类里先做幂等检查再开事务扣库存最后建订单并提交public class OrderService : IOrderService { private readonly FurnitureContext _db; public OrderService(FurnitureContext db) { _db db; } public async TaskOrder CreateOrderAsync(CreateOrderRequest request) { // 1. 幂等检查。数据库里 OrderNo 有唯一索引这是最后的防线。 var exists await _db.Orders.AnyAsync(o o.OrderNo request.OrderNo); if (exists) throw new InvalidOperationException($订单号 {request.OrderNo} 已存在); // 2. 显式事务扣库存和建订单要么一起成功要么一起回滚。 await using var tx await _db.Database.BeginTransactionAsync(); var items new ListOrderItem(); foreach (var item in request.Items) { var furniture await _db.Furnitures .FirstOrDefaultAsync(f f.Sku item.Sku); if (furniture null) throw new InvalidOperationException($SKU {item.Sku} 不存在); if (furniture.Stock item.Quantity) throw new InvalidOperationException($库存不足{furniture.Name}); furniture.Stock - item.Quantity; _db.StockLogs.Add(new StockLog { FurnitureId furniture.Id, ChangeQty -item.Quantity, SourceOrderNo request.OrderNo, CreatedAtUtc DateTime.UtcNow }); items.Add(new OrderItem { Sku furniture.Sku, FurnitureName furniture.Name, Quantity item.Quantity, UnitPrice furniture.Price }); } var order new Order { OrderNo request.OrderNo, CustomerName request.CustomerName, Status Pending, CreatedAtUtc DateTime.UtcNow, Items items }; _db.Orders.Add(order); await _db.SaveChangesAsync(); await tx.CommitAsync(); return order; } }代码里每一步都值得细说。幂等检查放在事务外避免每次重复提交都开启一个事务再回滚。扣库存前先检查Stock Quantity防止出现负库存。库存流水StockLog记录每条业务单据的变动明细月底对账时可以直接按SourceOrderNo汇总。事务要尽量短不要在事务内调用外部 HTTP 接口或发短信否则数据库锁会一直持有。如果订单行数很多比如一个订单有二十种商品循环里逐条查库会产生 N 次查询。更高效的做法是一次WHERE SKU IN (...)查出全部商品再做内存匹配但数据量小时逐条查更直观可读性优先。4.2 状态机约束订单从待付款到已取消订单状态直接定义成字符串容易失控比如把待付款订单直接改成已完成或者把已取消订单改成运输中。用状态机把所有合法转移集中在一处再用一个方法统一更新这样业务规则不会散落在各个页面代码里。public static class OrderStatus { public const string Pending Pending; // 待付款 public const string Paid Paid; // 待发货 public const string Shipping Shipping; // 运输中 public const string Completed Completed; // 已完成 public const string Cancelled Cancelled; // 已取消 private static readonly HashSet(string, string) AllowedTransitions new() { (Pending, Paid), (Pending, Cancelled), (Paid, Shipping), (Paid, Cancelled), (Shipping, Completed), }; public static bool CanTransit(string from, string to) AllowedTransitions.Contains((from, to)); }更新状态时先校验再改属性public async Task UpdateOrderStatusAsync(string orderNo, string newStatus) { var order await _db.Orders.FirstOrDefaultAsync(o o.OrderNo orderNo) ?? throw new InvalidOperationException(订单不存在); if (!OrderStatus.CanTransit(order.Status, newStatus)) throw new InvalidOperationException( $状态不允许从 {order.Status} 变更为 {newStatus}); order.Status newStatus; await _db.SaveChangesAsync(); }用元组集合定义合法转移比一串 if 判断清爽新增状态时只改这一处。碰到像“已取消订单要恢复为待付款”这种需求先让业务确认是不是允许再在AllowedTransitions里加一行。这比直接放开所有状态流转安全得多也是这套系统“现代化”的一个体现规则显性化。5. ASP.NET Core 8.0 家具管理系统避坑五个翻车现场的排查记录这一章写项目中最常踩的五个坑按“现象 → 原因 → 解决”来记录。每一个都来自真实调试场景你迟早会遇到其中一两个。5.1 dotnet ef 报错Build succeeded 却找不到 DbContext现象执行dotnet ef migrations add InitialCreate后控制台输出 Build succeeded接着却提示No DbContext was found。原因最常见的是 DbContext 所在的项目没有安装Microsoft.EntityFrameworkCore.Design包或者 DbContext 没有注册到 DI 容器。dotnet-ef 从已注册的服务里找 DbContext找不到就直接放弃。如果项目分了类库比如实体和数据层放单独类库而启动项目是 Web 项目需要显式指定两个项目参数。解决在 Data 项目安装 Design 包并指定--project和--startup-projectdotnet ef migrations add InitialCreate \ --project src/FurnitureManager.Data \ --startup-project src/FurnitureManager.Web--project指向包含 DbContext 的项目--startup-project指向能启动的 Web 项目。这样工具会从 Web 项目的 DI 容器中解析 DbContext再读取其模型生成迁移文件。5.2 构造函数里做异步初始化导致页面卡死或抛异常现象页面打开后长时间空白日志出现An asynchronous module or handler completed while an asynchronous operation was still pending。原因构造函数不能声明为 async有人为了在页面加载前拿数据用.Result或.Wait()去同步等待异步查询。一旦线程池繁忙等待的线程占满线程池后续请求全部排不上形成死锁式卡顿。这属于把 async 用错位置。解决构造函数只放同步赋值数据加载放到 Razor Pages 的OnGetAsyncpublic class IndexModel : PageModel { private readonly FurnitureContext _db; public ListFurniture Furnitures { get; set; } new(); public IndexModel(FurnitureContext db) { _db db; } public async Task OnGetAsync() { Furnitures await _db.Furnitures.AsNoTracking().ToListAsync(); } }列表页用AsNoTracking()因为只读展示不需要实体跟踪能减少 EF Core 的内存开销并略微提升查询速度。这也是查询大型列表时的第一优先级优化手段。5.3 SQLite 并发写报错SQLite Error 5 database is locked现象两个店员同时提交订单第二个请求在SaveChangesAsync时抛出SQLite Error 5: database is locked。原因SQLite 的写锁是数据库级锁事务提交期间其他写事务不能进入。默认没有设置 busy timeout 时遇到锁就直接报错而不是等待。解决连接串加上超时与共享缓存参数Data Sourcefurniture.db;CacheShared;Default Timeout30Default Timeout30给 SQLite 30 秒等待锁释放时间CacheShared让多个连接共享同一缓存减少锁冲突。同时要缩短事务持有时间事务内部只做数据库操作不调用第三方接口不在事务里发通知。如果门店数量多、订单并发明显上升尽早切 PostgreSQL这类问题会在数据库层面自然消失。5.4 时间字段混用 DateTime.Now月底对账差 8 小时现象库存流水和订单创建时间与门店实际时间不一致月初对账误差正好几个小时。原因代码里有人用DateTime.Now写入时间有人用DateTime.UtcNow而服务器或容器时区可能不是东八区。DateTime.Now存的是服务器本地时间容器时区一变数据就偏移。解决统一约定所有实体时间属性命名为CreatedAtUtc、UpdatedAtUtc代码里只允许写入DateTime.UtcNow。展示层需要本地时间时再做转换order.CreatedAtUtc.ToLocalTime().ToString(yyyy-MM-dd HH:mm:ss);测试和业务代码中如果依赖当前时间建议通过注入TimeProvider获取方便单元测试时替换时间源。这套约定没有技术门槛但要在项目初期就写进规范否则后期改存量数据非常痛苦。5.5 中间件顺序错了登录后授权不生效或无限重定向现象给订单页面加了[Authorize]或页面授权约定后未登录用户仍然可以直接打开或者登录后反复被重定向到登录页。原因中间件注册顺序不对。ASP.NET Core 的请求管道是按代码顺序执行UseRouting必须在UseAuthorization之前授权中间件才能看到路由匹配结果。反过来授权中间件执行时还没路由数据就不知道当前请求对应哪个页面授权逻辑形同虚设。解决保持模板默认顺序并在需要时插入认证中间件app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapRazorPages();这个顺序是模板已经替你做好的但当你加自定义中间件时容易打乱。原则是静态文件 → 路由 → 认证 → 授权 → 端点执行。Razor Pages 项目添加认证后UseAuthentication才会出现。6. 给家具系统上一道保险集成测试、健康检查与性能基线功能写完只是开始真正衡量系统能不能上线看的是有没有把核心业务钉死的集成测试、有没有可监控的健康检查入口、以及有没有可复现的性能基线。这一章讲这三件事怎么快速落地。6.1 用 WebApplicationFactory 钉住库存扣减集成测试的价值在于它直接调用真实的 DI 容器和数据库能测出服务能否跑通以及数据是否一致。先建一个测试项目并引用 Web 项目dotnet new xunit -n FurnitureManager.Tests然后安装Microsoft.AspNetCore.Mvc.Testing。为了让测试项目能访问 Program 类需在 Web 项目 csproj 中加入ItemGroup InternalsVisibleTo IncludeFurnitureManager.Tests / /ItemGroup因为 Program 类用顶层语句默认是 internal不开放访问的话测试项目无法使用WebApplicationFactoryProgram。测试工厂里替换数据库为独立的测试库文件public class FurnitureApiFactory : WebApplicationFactoryProgram { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services { var descriptor services.SingleOrDefault( d d.ServiceType typeof(DbContextOptionsFurnitureContext)); if (descriptor ! null) services.Remove(descriptor); services.AddDbContextFurnitureContext(o o.UseSqlite(Data Sourcetest-furniture.db)); }); } }然后写最核心的幂等测试[Fact] public async Task CreateOrderAsync_DuplicateOrderNo_Throws() { await using var factory new FurnitureApiFactory(); using var scope factory.Services.CreateScope(); var service scope.ServiceProvider.GetRequiredServiceIOrderService(); var req new CreateOrderRequest { OrderNo ORD-2024-001, CustomerName 测试客户, Items new ListOrderItemRequest { new() { Sku SF-001, Quantity 1 } } }; await service.CreateOrderAsync(req); await Assert.ThrowsAsyncInvalidOperationException(() service.CreateOrderAsync(req)); }第二次提交同名订单号必须抛异常这个测试通过才能证明重复点击不会扣两次库存。订单服务是家具系统里最值得测试的一段代码因为它是钱和物的交汇点。6.2 健康检查与性能基线给 Program.cs 加两行系统就具备健康检查端点builder.Services.AddHealthChecks(); app.MapHealthChecks(/health);部署后定时访问/health200 说明进程活着。进阶做法是在健康检查里加数据库连通性探测但对 SQLite 这类轻量库没必要频繁查库否则反成压力。性能方面先建立基线再谈优化。发布后用 Release 模式启动dotnet run --configuration Release用 curl 测首页首字节耗时curl -o /dev/null -s -w time_total: %{time_total}s\n http://localhost:5xxx/首次请求包含 JIT 和静态资源加载几百毫秒正常后续请求应该在几十毫秒内。如果明显变慢先排查页面内循环查询。响应压缩是性价比很高的优化手段尤其在包含较多文本表格的后台页面builder.Services.AddResponseCompression(options { options.EnableForHttps true; options.Providers.AddBrotliCompressionProvider(); }); app.UseResponseCompression();注意UseResponseCompression要放在UseStaticFiles之前这样静态文件也能被压缩。我最早做的一套家具进销存没有写集成测试上线第三周被财务发现某天库存和流水对不上最后定位到是前端提交订单时重复点击接口又没做幂等。从那以后CreateOrderAsync的重复提交测试成了每个版本必跑的第一条。健康检查和首包耗时在部署前记录下来下次优化才有基准。这些习惯不复杂但能把翻车概率压到最低。希望帮到你。本文还有配套的精品资源点击获取