尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

.NET Core MVC电商项目进阶:依赖注入、订单状态机与安全防护

.NET Core MVC电商项目进阶:依赖注入、订单状态机与安全防护 很多人在做 .NET Core MVC 电商项目时前两个阶段都跑得很顺能搭项目、能建实体、能写仓储、能做出注册登录。但到了第三个阶段突然开始卡壳——购物车、订单状态、支付回调、库存扣减、并发冲突、权限校验一股脑全涌过来原来那套“给视图套 Model”的线性思路瞬间不够用了。这个阶段的分水岭不在于你会不会写某个功能而在于你有没有完成一次思维切换从“CRUD 怎么写完”变成“这个环节的状态、边界、安全和失败回滚该怎么设计”。如果这本书或这套课程看到 Part 3 并开始做电商核心链路那这篇文章的价值就是帮你在动手之前先把 Part 3 真正要面对的技术地图铺清楚。1. 这篇文章真正要解决的问题先说结论Part 3 不是“再写几个页面”而是进入“业务复杂度管理”的阶段。很多初学者最大的误判是把 MVC 电商项目当成一堆独立页面的集合商品列表页、详情页、购物车页、结算页、订单列表页。如果只按页面去理解代码写到后面必然会出现三种典型问题Controller 越来越臃肿一个 Action 里塞了几十行业务逻辑状态管理混乱购物车数据一会儿放 Session一会儿放数据库订单状态靠 if else 到处判断安全和异常处理缺位支付回调、库存扣减这种高风险操作完全没做幂等和回滚设计。这篇文章要重点讲的不是“怎样再写一个页面”而是 Part 3 阶段最关键的五个技术主题MVC 架构下的请求流转、依赖注入DI与构造器注入的实例化逻辑、购物车与订单状态机的设计、EF Core/Dapper 下的 SQL 注入防御以及生产环境的工程化建议。如果你正在跟着 .NET Core MVC 电商教程做到购物车和订单模块或者工作中第一次从简单的管理后台转向电商交易链路这篇文章值得收藏后按步骤走一遍。2. .NET Core MVC 核心概念与 Part 3 技术地图先统一基础认知。MVC 不是三个文件放在一起那么简单它是 ASP.NET Core 提供的一种 Web 应用组织模式核心思想是把请求处理拆成 Model、View、Controller 三个关注点Model承载业务数据和业务规则比如 Product、Cart、Order、OrderItemView只负责展示从 Controller 拿到数据后渲染 HTMLController接收请求、处理输入、调用业务服务、决定返回哪个视图或 JSON。在 Part 3 之前你写的代码大多可以简化成“Controller 直接操作 EF Core 上下文”的路径。这也是当时最顺手的写法。public class ProductController : Controller { private readonly AppDbContext _db; public ProductController(AppDbContext db) { _db db; } public IActionResult Index() { var products _db.Products.ToList(); return View(products); } }但到了 Part 3这种方式会暴露出两个问题。第一职责混乱。Controller 不应该关心库存扣减的完整过程更不应该在 Action 里把 SQL 和事务逻辑全部写完。它应该做的事情是验证输入、调用一个更高层的服务、拿到结果后组织响应。第二测试困难。如果所有逻辑都长在 Controller 里你没法单独测试业务规则只能通过启动整个 Web 应用去点页面验证。这在订单状态、支付回调这种复杂逻辑面前效率极低。所以 Part 3 的第一个转型动作就是引入 Service 层并把依赖关系交给 DI 容器管理。2.1 依赖注入与构造器注入的实例化逻辑依赖注入Dependency Injection简称 DI在 .NET Core 中并不是一个可以选装的功能而是框架内置的基础能力。它的核心目标是解决“对象之间的依赖由谁创建”的问题。传统写法里如果你要在 Controller 里使用 CartService你会在内部直接new CartService()。这意味着 Controller 和 CartService 形成了强耦合一旦 CartService 的构造函数变化所有使用它的地方都要改。.NET Core 的 DI 容器把“创建对象”这件事统一接管。你在Program.cs里注册类型builder.Services.AddScopedICartService, CartService();然后在使用方只需要声明构造函数参数容器就会自动找到对应实现并实例化public class CartController : Controller { private readonly ICartService _cartService; public CartController(ICartService cartService) { _cartService cartService; } }这里最容易让新手困惑的就是“注入控制构造实现实例化”这句话。其实翻译过来就是你不是自己 new 依赖对象而是把依赖写在构造函数参数里让 DI 容器在创建 Controller 时自动把对应的实例传进来。.NET Core 提供了三种生命周期生命周期注册方式典型场景TransientAddTransient每次获取都创建新实例适合无状态且轻量的服务ScopedAddScoped每个请求范围内共用同一个实例适合 DbContext、UnitOfWorkSingletonAddSingleton整个进程共用一个实例适合配置服务、缓存服务电商项目里AppDbContext通常用AddDbContext注册它默认是 Scoped 生命周期ICartService访问数据库推荐 Scoped只做纯计算的折扣服务可以考虑 Transient。理解 DI 不只是为了通过面试。Part 3 阶段你的 Controller、Service、Repository、DbContext 之间依赖关系会越来越深如果不会配置生命周期最常见的报错就是InvalidOperationException: Cannot consume scoped service xxx from singleton yyy.这个错误的本质就是你把一个 Scoped 服务注入到了 Singleton 服务中导致请求级对象的生命周期被外部拉长。遇到这个问题请回来看上面的表格而不是盲目把注册方式改成 Singleton。2.2 Part 3 涉及的核心模块Part 3 在电商项目里通常是交易链路试点。对应到模块我的判断是至少包含购物车模块Session/Redis/数据库三种存储方案选型订单模块订单创建、状态机流转、超时关闭支付回调签名校验、重复回调幂等库存模块并发扣减与超卖控制权限模块区域隔离、登录用户才能下单、管理员才能处理发货。下面几个章节我会围绕这个链路给出可运行的代码实现与设计思路。3. 环境准备与项目结构约定开始写代码之前先把环境说清楚。本文示例采用的是 .NET 8 时代以来的 ASP.NET Core Web 应用默认结构也就是WebApplication.CreateBuilder这种最小托管模型。如果你使用的是 .NET Core 3.1 或更早版本的教程Program.cs 的写法会不同但核心概念是通用的。建议环境操作系统Windows 10/11、macOS 或主流 Linux 发行版均可SDK.NET 8 SDK 或更高 LTS 版本版本请以官方长期支持版本为准IDEVisual Studio 2022、Visual Studio Code C# 扩展、JetBrains Rider 都可以数据库SQL Server LocalDB 或 Docker 中的 SQL Server也可以用 PostgreSQL关键是要能跑 EF Core 迁移Redis本地安装或用 Docker 启动用于会话或购物车缓存包管理工具NuGet。创建项目时推荐使用最小化的空模板然后逐步引入包这比使用整体模板更容易理解每个环节的依赖来源。dotnet new mvc -n EShopPart3 cd EShopPart3 dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools code .项目结构可以约定为EShopPart3/ ├── Controllers/ ├── Views/ ├── Models/ │ ├── Entities/ │ └── ViewModels/ ├── Services/ │ ├── ICartService.cs │ ├── CartService.cs │ ├── IOrderService.cs │ └── OrderService.cs ├── Data/ │ └── AppDbContext.cs ├── Program.cs └── appsettings.jsonPart 3 阶段我建议你在 Service 和 Controller 之间至少隔一层接口也就是引入ICartService、IOrderService。这会让后面的单元测试、替换实现都方便很多。虽然接口多写几个文件看起来麻烦但订单、支付这种业务值得这个成本。4. 核心业务模块设计购物车与订单状态机4.1 购物车方案怎么选购物车是电商项目里第一个真正需要“状态共享”的地方。用户加购数据存储在哪常见有三种方案方案优点缺点适合场景Session实现简单默认内存存储服务重启丢失不能横向扩展演示项目、单机部署Cookie不占服务端内存有大小限制数据暴露给客户端匿名购物车、轻量场景数据库 Redis 缓存持久化、可扩展、支持跨设备实现成本高需要考虑缓存一致真实电商系统如果你是跟着 Part 3 教程学习第一版用 Session 是最容易跑通的。但你在理解时要清楚真实项目中购物车几乎不会长期放在内存 Session 里更常见的方案是未登录时把购物车数据模糊存到 Redis 或 cookie用户登录后合并到数据库购物车表。演示阶段我们可以先做一个基于 Session 的购物车服务但把它封装在 ICartService 接口后面后续切换成 Redis 或数据库实现时Controller 层不需要改动。4.2 订单状态机不要再用 if else订单模块比购物车难很多的地方在于状态管理。一个订单一般是已创建 - 待支付 - 已支付 - 已发货 - 已完成。还可能包含已取消、退款中、已退款。很多新手习惯用一张表里的枚举字符串然后在各个地方写if (order.Status Pending input.Action Pay) { order.Status Paid; }这种写法在状态少的时候没问题一旦支付回调、用户取消、超时关单同时出现逻辑会越来越难以追踪。更稳妥的做法是引入“订单状态机”在领域层限制哪些状态到哪些状态的转换是合法的。下面是一个简单的状态机定义public enum OrderStatus { Pending 1, Paid 2, Shipped 3, Completed 4, Cancelled 5 } public class Order { public int Id { get; set; } public string OrderNumber { get; set; } public string UserId { get; set; } public decimal TotalAmount { get; set; } public OrderStatus Status { get; set; } public DateTime CreatedAt { get; set; } public DateTime? PaidAt { get; set; } public bool TryTransitTo(OrderStatus target) { var allowed Status switch { OrderStatus.Pending target OrderStatus.Paid || target OrderStatus.Cancelled, OrderStatus.Paid target OrderStatus.Shipped || target OrderStatus.Cancelled, OrderStatus.Shipped target OrderStatus.Completed, _ false }; if (!allowed) { return false; } Status target; return true; } }思路很简单状态转换规则只写在 Order 实体里任何 Service 要改订单状态统一走TryTransitTo。支付回调、用户取消、后台发货都调用同一个方法就能避免各处随意赋值。我在实际项目中会把这种状态机做得很克制不会引入重型工作流引擎。对于 MVC 电商项目来说一个方法加一组枚举已经能覆盖大部分需求。真正需要工作流引擎时通常是业务流程里要有人工审批、自动化动作编排而不是订单这点状态转换。5. 完整示例基于 DI 的 Service 层与购物车实现下面进入核心实操。我们用一个最小可运行链路购物车添加商品 - 显示购物车 - 结算生成待支付订单。所有代码都建立在 DI 与 Service 模式之上。5.1 先写购物车服务为了方便演示 Session 存储我们先定义购物车条目模型// Models/ViewModels/CartItemViewModel.cs public class CartItemViewModel { public int ProductId { get; set; } public string ProductName { get; set; } public decimal UnitPrice { get; set; } public int Quantity { get; set; } public decimal LineTotal UnitPrice * Quantity; }然后定义接口// Services/ICartService.cs public interface ICartService { ListCartItemViewModel GetItems(); void AddItem(int productId, string productName, decimal unitPrice, int quantity); void RemoveItem(int productId); void Clear(); int GetCount(); }实现类需要访问 IHttpContextAccessor原因是 Session 的读写离不开当前 HTTP 上下文而 Service 层不应该直接依赖 Controller 自带属性必须显式注入IHttpContextAccessor。// Services/CartService.cs using System.Text.Json; using Microsoft.AspNetCore.Http; public class CartService : ICartService { private readonly IHttpContextAccessor _httpContextAccessor; private const string SessionKey CartItems; public CartService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor httpContextAccessor; } public ListCartItemViewModel GetItems() { var json _httpContextAccessor.HttpContext?.Session.GetString(SessionKey); if (string.IsNullOrEmpty(json)) { return new ListCartItemViewModel(); } return JsonSerializer.DeserializeListCartItemViewModel(json) ?? new ListCartItemViewModel(); } public void AddItem(int productId, string productName, decimal unitPrice, int quantity) { var items GetItems(); var existing items.FirstOrDefault(i i.ProductId productId); if (existing ! null) { existing.Quantity quantity; } else { items.Add(new CartItemViewModel { ProductId productId, ProductName productName, UnitPrice unitPrice, Quantity quantity }); } SaveItems(items); } public void RemoveItem(int productId) { var items GetItems(); items.RemoveAll(i i.ProductId productId); SaveItems(items); } public void Clear() { _httpContextAccessor.HttpContext?.Session.Remove(SessionKey); } public int GetCount() { return GetItems().Sum(i i.Quantity); } private void SaveItems(ListCartItemViewModel items) { var json JsonSerializer.Serialize(items); _httpContextAccessor.HttpContext?.Session.SetString(SessionKey, json); } }这里真正容易踩坑的地方是IHttpContextAccessor是 Singleton 生命周期但CartService是 Scoped。如果你把CartService注册成 Singleton才会出现作用域冲突。正确的注册方式是builder.Services.AddHttpContextAccessor(); builder.Services.AddScopedICartService, CartService();5.2 在 Program.cs 中注册服务与数据库我们在Program.cs里完成所有依赖注册。.NET Core MVC项目的启动入口在这个文件里DI 容器和中间件管道也在这里配置。// Program.cs using EShopPart3.Data; using EShopPart3.Services; using Microsoft.EntityFrameworkCore; var builder WebApplication.CreateBuilder(args); var connectionString builder.Configuration.GetConnectionString(DefaultConnection); builder.Services.AddDbContextAppDbContext(options { options.UseSqlServer(connectionString); }); builder.Services.AddDistributedMemoryCache(); builder.Services.AddSession(options { options.IdleTimeout TimeSpan.FromMinutes(30); options.Cookie.HttpOnly true; options.Cookie.IsEssential true; }); builder.Services.AddHttpContextAccessor(); builder.Services.AddScopedICartService, CartService(); builder.Services.AddScopedIOrderService, OrderService(); builder.Services.AddControllersWithViews(); var app builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler(/Home/Error); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseSession(); app.UseAuthorization(); app.MapControllerRoute( name: default, pattern: {controllerHome}/{actionIndex}/{id?}); app.Run();这里需要注意三点UseSession()必须放在UseRouting()之后、MapControllerRoute()之前顺序错了 Session 在 Controller 里取不到值AddControllersWithViews()是 MVC 功能注册入口它包含了很多隐式服务绑定如果以后把购物车从 Session 换成 Redis只需要把AddDistributedMemoryCache()换成AddStackExchangeRedisCache()然后修改 Session 的存储CartService基本不用改。5.3 创建订单服务订单服务需要访问数据库所以它直接注入AppDbContext。这也是 DI 构造器注入的典型用法。// Services/IOrderService.cs public interface IOrderService { TaskOrder CreateOrderAsync(string userId, ListCartItemViewModel items); Taskbool PayOrderAsync(string orderNumber); Taskbool CancelOrderAsync(string orderNumber); } // Services/OrderService.cs using EShopPart3.Data; using EShopPart3.Models.Entities; using Microsoft.EntityFrameworkCore; public class OrderService : IOrderService { private readonly AppDbContext _db; public OrderService(AppDbContext db) { _db db; } public async TaskOrder CreateOrderAsync(string userId, ListCartItemViewModel items) { var order new Order { OrderNumber DateTime.Now.ToString(yyyyMMddHHmmssfff) Random.Shared.Next(100, 999), UserId userId, TotalAmount items.Sum(i i.LineTotal), Status OrderStatus.Pending, CreatedAt DateTime.Now }; _db.Orders.Add(order); await _db.SaveChangesAsync(); return order; } public async Taskbool PayOrderAsync(string orderNumber) { var order await _db.Orders.FirstOrDefaultAsync(o o.OrderNumber orderNumber); if (order null) { return false; } return order.TryTransitTo(OrderStatus.Paid); } public async Taskbool CancelOrderAsync(string orderNumber) { var order await _db.Orders.FirstOrDefaultAsync(o o.OrderNumber orderNumber); if (order null) { return false; } return order.TryTransitTo(OrderStatus.Cancelled); } }注意PayOrderAsync方法里如果返回 true还需要继续SaveChangesAsync否则状态没有落库。为了简洁我在示例中可以合并为if (order.TryTransitTo(OrderStatus.Paid)) { order.PaidAt DateTime.Now; await _db.SaveChangesAsync(); return true; } return false;这里真正要传达的思想是状态转换的合法性由实体自己保证数据库只负责持久化。这样支付回调、用户取消、后台退款都走同一套规则不会有逻辑分支不一致的问题。5.4 Controller 层使用注入的服务Controller 的写法会非常薄这也是 Part 3 阶段帮助你避免 Controller 膨胀的关键。// Controllers/CartController.cs using EShopPart3.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; public class CartController : Controller { private readonly ICartService _cartService; private readonly IOrderService _orderService; public CartController(ICartService cartService, IOrderService orderService) { _cartService cartService; _orderService orderService; } public IActionResult Index() { var items _cartService.GetItems(); ViewData[Total] items.Sum(i i.LineTotal); return View(items); } [HttpPost] public IActionResult Add(int productId, string productName, decimal unitPrice, int quantity 1) { _cartService.AddItem(productId, productName, unitPrice, quantity); return RedirectToAction(Index); } [HttpPost] public IActionResult Remove(int productId) { _cartService.RemoveItem(productId); return RedirectToAction(Index); } [Authorize] [HttpPost] public async TaskIActionResult Checkout() { var userId User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId)) { return Challenge(); } var items _cartService.GetItems(); if (items.Count 0) { return RedirectToAction(Index); } var order await _orderService.CreateOrderAsync(userId, items); _cartService.Clear(); return RedirectToAction(Success, Order, new { orderNumber order.OrderNumber }); } }这段代码里加入[Authorize]是一个很好的时机。Part 3 之后结算、下单、查看订单都应该是登录用户行为。ASP.NET Core Identity 的 Cookie 认证会拦截未登录请求User.FindFirst(...)可以从当前登录身份中读取用户标识。Challenge()表示未登录时跳转到登录页。如果你没有配置登录路径默认会跳转到/Account/Login可以在 Program.cs 中显式配置builder.Services.ConfigureApplicationCookie(options { options.LoginPath /Account/Login; options.AccessDeniedPath /Account/AccessDenied; });6. 防 SQL 注入与安全加固从 EF Core 到 Dapper电商项目涉及资金和数据隐私SQL 注入是不可回避的安全话题。你做 MVC 项目时数据库操作通常有两条路线EF Core 或 Dapper。6.1 EF Core 下的防注入写法EF Core 默认把 LINQ 翻译成参数化 SQL所以下面这种写法是安全的var product await _db.Products .FirstOrDefaultAsync(p p.Slug slug);但如果有人用 FromSqlRaw 拼接字符串风险就会立刻出现。比如var sql SELECT * FROM Products WHERE Name keyword ; var products await _db.Products.FromSqlRaw(sql).ToListAsync();一旦 keyword 里含有单引号或注释符号SQL 语句就会变味。更稳妥的写法是使用 FromSqlRaw 的参数重载var products await _db.Products .FromSqlRaw(SELECT * FROM Products WHERE Name LIKE {0}, % keyword %) .ToListAsync();这里的{0}会被 EF Core 转成SqlParameter不会直接拼接进 SQL。6.2 Dapper 下的参数化语法如果你的项目选择了 Dapper同样的原则依然成立。Dapper 允许你写原生 SQL但推荐使用DynamicParameters或匿名对象传参// 安全示例参数化查询 var sql SELECT * FROM Products WHERE Name Name AND Status Status; var product await connection.QueryFirstOrDefaultAsyncProduct(sql, new { Name keyword, Status 1 }); // 危险示例字符串拼接 var badSql SELECT * FROM Products WHERE Name keyword ; var product await connection.QueryFirstOrDefaultAsyncProduct(badSql);参数化查询的本质是让数据库引擎把用户输入当作“值”而不是“可执行代码”来对待。这个原理对 MVC、Web API、桌面应用都适用。你在热搜词里看到“spring mvc 如何防止 sql 注入”其实思路完全一致。6.3 防御性编程清单除了 SQL 注入Part 3 电商项目至少要检查下面几个安全点风险点防御手段购物车表单提交时篡改单价服务端根据 ProductId 重新查询价格不能信任前端传的 UnitPrice未登录用户访问结算页使用[Authorize]特性配置 Cookie 认证跨站请求伪造CSRFMVC 默认在 Form 标签生成请求令牌Ajax 场景要手动加RequestVerificationToken支付回调伪造验签、验金额、验订单归属回调接口做幂等处理敏感配置泄露连接字符串、密钥使用 User Secrets 或环境变量不要提交到 git订单号可预测订单号使用时间戳加随机数或 GUID 片段避免纯自增开发支付功能时不要自己去实现签名算法而是使用支付宝、微信支付等官方 SDK。重点放在“回调验签 回调幂等 订单金额二次校验”上而不是重新发明安全机制。7. 运行结果与效果验证代码写完后需要验证链路能跑通。先用命令行启动项目dotnet build dotnet run默认情况下ASP.NET Core 会监听https://localhost:5001和http://localhost:5000。在浏览器打开首页后按下面流程验证进入商品详情页点击“加入购物车”打开顶部购物车图标确认商品数量、金额正确连续刷新购物车页面确认 Session 数据不丢失点击“去结算”未登录时跳转到登录页登录后完成结算跳转到订单成功页查看数据库 Orders 表确认订单状态为1Pending金额与购物车合计一致。如果第 4 步没有跳转登录说明[Authorize]没有生效请检查是否在 Program.cs 中注册了认证中间件builder.Services.AddAuthentication(); builder.Services.AddAuthorization();如果 Session 里始终取不到购物车最可能的原因是UseSession()的位置不对或者没有调用AddSession()。如果第 6 步数据库里没有生成订单请检查是否执行了 EF Core 迁移。在项目目录下运行dotnet ef migrations add InitialCreate dotnet ef database update8. 常见问题与排查思路问题现象可能原因排查方式解决方案Cannot consume scoped service from singleton生命周期注册错误查看 Startup/Program.cs 中的 AddScoped/AddSingleton把上层依赖改为 Scoped或把被依赖项改为 SingletonSession 始终为 nullAddSession 未注册或 UseSession 顺序错误检查 Program.cs 中间件顺序先 AddSession再 UseSession且放在 UseAuthorization 前[Authorize] 不跳转登录页未配置认证或登录路径检查 Program.cs 是否有 AddAuthentication配置ConfigureApplicationCookie的 LoginPathdotnet ef 命令找不到未安装 dotnet-ef 或未添加 Tools 包执行dotnet tool install --global dotnet-ef安装对应版本的全局工具3ds max 等桌面软件提示未正确安装 .NET Core 版本 8系统缺少对应 .NET 运行时或版本不匹配打开“设置-应用”查看已安装的 .NET 运行时从官方地址下载并安装对应 SDK/Runtime而非修改 MVC 项目支付回调反复执行订单状态异常回调未做幂等状态机不允许重复流转检查订单表状态变化记录回调接口先按 OrderNumber 查订单已支付则直接返回成功购物车单价被篡改Controller 信任前端传入价格检查 Add Action 是否使用前端传的 UnitPrice服务端根据 ProductId 重查商品表价格这里特别想提一下“3ds max 检测到未正确安装所需的 .NET Core 版本 8”这个问题。它其实和 MVC 开发没有直接关系属于运行环境问题。很多桌面软件会依赖 .NET 运行时当你本机装了太新或太旧的运行时安装程序就会报这个错。解决方法是直接安装官方对应版本的 .NET Runtime而不是去修改桌面软件本身。这条经验也适用 .NET 开发者自己发布应用时遇到的运行时缺失问题。9. 最佳实践与工程建议代码能跑通只是第一步。Part 3 之后的代码会越来越难改。下面几条建议来自实际项目中的经验建议在开发早期就遵守。9.1 保持 Controller 薄、Service 厚Controller 里不要出现 EF Core 的_db直接操作。如果一段逻辑超过三行并且会被多个 Action 复用就放进 Service。如果一段逻辑是完整业务规则即使只用一处也建议放进 Service方便独立测试。9.2 配置不要硬编码连接字符串、Redis 地址、支付密钥不要写在代码里。开发环境用appsettings.Development.json或 User Secrets生产环境用环境变量或配置中心。至少做到{ ConnectionStrings: { DefaultConnection: Server.;DatabaseEShopPart3;Trusted_ConnectionTrue;TrustServerCertificateTrue; }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } } }注意TrustServerCertificateTrue这个配置通常用于本地开发生产环境要确保数据库连接加密不能为了省事直接禁用证书验证。9.3 日志与异常追踪在 Program.cs 中配置日志使用结构化日志记录关键操作builder.Services.AddLogging(logging { logging.AddConsole(); logging.AddDebug(); });在 Service 里记录订单创建、支付回调、库存变更等关键动作便于线上排查。日志里不要记录用户密码、完整支付单号等敏感信息。9.4 事务与并发控制订单创建和库存扣减不是两个独立操作它们需要放在同一个数据库事务里。EF Core 的SaveChangesAsync默认自带事务但跨多个SaveChanges的使用场景需要显式控制await using var transaction await _db.Database.BeginTransactionAsync(); try { _db.Orders.Add(order); await _db.SaveChangesAsync(); foreach (var item in items) { var product await _db.Products.FindAsync(item.ProductId); if (product null || product.Stock item.Quantity) { throw new InvalidOperationException($商品 {item.ProductId} 库存不足); } product.Stock - item.Quantity; } await _db.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }库存扣减还要考虑并发场景。最简单的方法是使用乐观并发控制在 Product 实体上加并发令牌public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int Stock { get; set; } [Timestamp] public byte[] RowVersion { get; set; } }这样如果两个请求同时修改同一商品库存EF Core 会抛出DbUpdateConcurrencyException你再根据业务逻辑决定重试或提示用户。不要天真地认为if (product.Stock quantity)就安全在并发下它会被多个请求同时通过。9.5 支付回调与幂等支付回调必须做到同一笔订单的重复通知不会产生重复业务操作。推荐的幂等方案有两种数据库状态判断回调处理前先查询订单如果已经是“已支付”直接返回成功唯一约束在支付流水表里给OrderNumber ChannelTransactionId建唯一索引重复插入直接失败。第一种方案更简单适合 MVC 电商教学项目第二种方案更适合生产环境资金类业务。10. 总结与后续学习方向Part 3 阶段你应该已经能感受到 MVC 项目的复杂度不在“页面”而在“业务规则的边界”。通过这篇文章我希望你至少理解了四件事DI 构造器注入如何控制实例化Service 层如何分离 Controller 和业务逻辑购物车与订单状态机为什么需要显式设计以及 SQL 注入和支付回调安全为什么不能靠“第三方库自动搞定”。下一步建议你把购物车从 Session 换成 Redis 存一遍给订单状态机补充取消、退款、超时关闭三个转换规则写一个针对 OrderService 的单元测试验证非法状态转换会被拒绝。这三件事做完你对 .NET Core MVC 的理解会超过大部分停留在 CRUD 阶段的开发者。如果你正在跟教程做 Part 4 或后续模块推荐重点观察作者如何把 Controller 层拆薄、如何写仓储层、如何做分页筛选。这些模式本质上都是在回答同一个问题当业务继续膨胀时代码结构如何保持可维护。愿你顺利跑通整个电商链路也建议把这篇文章收藏备用尤其是第八节的排查清单遇到“注入报错”“Session 失效”“回调重复”这类问题可以直接回来对照。
返回列表