ASP.NET Core MVC + SQL Server 商城开发:模型设计、扣库存与避坑实践 简介这是一份基于ASP.NET Core MVC与SQL Server 2012的商城系统项目源码面向.NET学习者、在校学生和初级开发人员适合作为课程设计、毕业设计或电商项目实战的入门参考。项目完整展示了商城常见业务闭环从用户登录、商品浏览、加入购物车、下单结算到订单管理每一步都可在代码中对应查看。资源包为RAR压缩格式共413个文件容量约31.31MB。文件构成较为完整包含C#源代码35个、Razor视图页面17个、JavaScript脚本18个、CSS样式24个以及DLL依赖库56个、JSON配置14个另有大量JPG、WebP、PNG商品图片与界面素材并附带SQL Server数据库文件、运行日志和项目缓存方便整体还原项目开发环境。项目包含首页、商品列表、商品详情、购物车、下单页面、订单管理和登录等模块后端基于SQL Server 2012结合依赖注入、Razor视图与jQuery交互等典型写法适合边读边改为后续扩展成完整商城系统打下基础。目前已有1075人学习下载适合需要一套可运行、可参考的ASP.NET Core MVC商城示例来提升实际开发能力。1. 为什么「ASP.NET Core MVC SQL Server 商城系统」这个组合值得做你在搜「ASP.NET Core MVC SQL Server 商城系统」时通常不是来学语法的而是手里已经有一个要交付的商城商品展示、购物车、下单扣库存、订单查询工期往往按周算。这个组合在 .NET 技术栈里属于最不容易翻车的一条正路MVC 把页面和逻辑分开SQL Server 负责库存和订单这类不能出错的数据EF Core 在两者之间做映射。它的好处是开发链路短从建表到页面跑通不需要引入额外中间件适合中小团队、内部商城和外包交付项目。别一上来就规划微服务先把商品、购物车、订单这一条闭环跑通比什么都重要。2. 数据模型与分层设计先落好三张核心表再写控制器做商城的实际顺序和直觉相反不是先画页面而是先定表和模型。页面随时能调订单表一旦上线就改不起。商品Product、购物车项CartItem、订单Order这三张表把「逛」和「买」两条链路分开逛看 Product买读 CartItem下单写 Order 并扣 Product.Stock。我见过不少从视图开始做的项目最后控制器里塞满了临时改数据的 SQL问题往往出在模型没定边界而不是页面难看。2.1 商城的最小四层与存储选型为什么用 EF Core 而不是手写 ADO.NET一个能按期交付的商城项目我一般只拆四层Controllers 接 HTTP 请求、Services 放业务规则、Repositories 或直接 DbContext 访问数据、Views 只做渲染。四层不是给老板看的架构图而是为了你改需求时少改一套代码。商品价格、库存、运费规则属于业务层谁在什么时候把库存扣了属于数据层。两者混在一个控制器里调试 Session 和扣库存问题时你会同时面对页面和数据库两个黑匣子。数据访问默认用 Entity Framework Core而不是手写 ADO.NET原因很实际第一商城页面大多数是「按条件查列表、点开看详情、提交后写订单」CRUD 占八成EF Core 能让你少写一半样板代码第二EF Core 的参数化查询天然防 SQL 注入新手不容易把字符串拼进 WHERE第三模型改字段后用迁移脚本同步数据库比拿着 SSMS 手工改表可回滚。至于性能担心EF Core 的查询是延迟执行配合 AsNoTracking、分页和原生 SQL 出口已经够撑起中小商城的日常流量。如果你团队里有熟手坚持用 Dapper也不是不行。只是这个标题下的常规路线是 EF Core而且后续无论是分页、关联查询还是迁移EF Core 的坑更少教程也更多。选型的底线是不要在控制器里直接拼 SQL 字符串去更新库存这个问题我们到第 4 章专门讲。2.2 Product、CartItem、Order三个核心实体的最少字段先把三个实体的代码写出来这是整个商城的骨架。字段能减就减能不加就不加订单表尤其如此。public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public string? Description { get; set; } public decimal Price { get; set; } public int Stock { get; set; } public string? Category { get; set; } public bool IsOnSale { get; set; } public byte[] RowVersion { get; set; } Array.Emptybyte(); } public class CartItem { public int Id { get; set; } public string UserKey { get; set; } string.Empty; // 未登录时用临时标识 public int ProductId { get; set; } public Product? Product { get; set; } public int Quantity { get; set; } } public class Order { public int Id { get; set; } public string UserKey { get; set; } string.Empty; public decimal Total { get; set; } public string Status { get; set; } Pending; // Pending/Paid/Shipped/Cancelled public DateTime CreatedAt { get; set; } }注意几个字段的用意。Price 用 decimal 而不是 float金额精度不能靠浮点数碰运气这在 SQL Server 那边也要一致。Stock 用 int别用 double库存只有整数。Order.Status 用字符串而不是 int查数据库时「Pending」比「0」可读后续加状态枚举也方便。UserKey 是我个人习惯未登录用户用 Guid 放在 Cookie 里登录后换成用户 Id这样购物车不至于强迫用户先注册才能加购。接着是 DbContext 和连接字符串这是模型和 SQL Server 之间的桥public class ShopDbContext : DbContext { public ShopDbContext(DbContextOptionsShopDbContext options) : base(options) { } public DbSetProduct Products SetProduct(); public DbSetCartItem CartItems SetCartItem(); public DbSetOrder Orders SetOrder(); }{ ConnectionStrings: { Shop: Serverlocalhost;DatabaseShopDb;User Idshop_user;Password你的密码;TrustServerCertificateTrue;MultipleActiveResultSetsTrue } }连接字符串里有三个参数值得解释。TrustServerCertificateTrue 是给本地开发和自签证书用的生产环境换成正式证书后应设为 False 或去掉否则会有证书信任风险。MultipleActiveResultSetsTrue 允许同一个连接上同时打开多个结果集页面里一边遍历商品一边查关联数据时能省事但性能敏感接口一般不依赖它。密码用占位符写了实际项目里别硬编码在 appsettings.json应该放到 user secrets 或环境变量里。2.3 用 Fluent API 把数据库规则固化精度、唯一索引与行版本实体定义了还不够有些规则必须落到数据库层面代码里防止换个人写代码就漏掉。我用 Fluent API 在 OnModelCreating 里集中声明比散落各处的 Data Annotation 好维护protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityProduct(entity { entity.ToTable(Products); entity.Property(p p.Name).IsRequired().HasMaxLength(200); entity.Property(p p.Price).HasColumnType(decimal(18,2)); entity.HasIndex(p p.Category); entity.Property(p p.RowVersion).IsRowVersion(); }); modelBuilder.EntityCartItem(entity { entity.ToTable(CartItems); entity.HasIndex(ci new { ci.UserKey, ci.ProductId }).IsUnique(); }); }这段配置解决三件事。decimal(18,2) 保证金额在数据库里也是两位小数不会出现 EF 映射成 decimal(18,0) 导致价格被截断成整数的问题。HasIndex 给 Category 加上普通索引商品列表按分类筛选时不用全表扫描。CartItems 的联合唯一索引保证同一个用户同一个商品在购物车表里只有一行重复加购只是把 Quantity 加一而不是插两条脏数据。RowVersion 是 SQL Server 的行版本列每次有 UPDATE 语句改到这行值会自动变。这是做并发控制的后手扣库存时拿着旧版本号去更新影响行数为 0 就说明数据被人改过可以立刻重试或报错。EF Core 里的 HasRowVersion 配置会和 SQL Server 的 rowversion 类型对应建表时自动生成。写完后执行迁移命令不长但必须记住dotnet ef migrations add InitShopSchema dotnet ef database update第一条命令把当前模型和数据库的差异生成迁移文件第二条命令把迁移真正应用到 SQL Server。你可以在迁移文件里检查生成的 SQL 是否符合预期别直接闭眼 update。我见过有人把 Price 配成 decimal(18,2)迁移脚本里却因为数据库已有表而只改了列名没改精度最后对账差了三分钱这种问题查起来非常痛苦。3. 控制器与视图把商品列表、购物车和下单串成可点击的流程模型定好后下一步是让页面能点。控制器的核心原则是薄HTTP 参数解析、调用数据访问、返回视图别的都别干。下单时的库存扣减和事务属于业务层购物车的合并逻辑也属于业务层控制器里出现超过十行的业务代码就该考虑抽 Service 了。我见过把整个下单流程 200 行塞进一个 Action 的项目后来加一个「优惠券分摊运费」的需求改得头皮发麻。3.1 控制器如何拆、依赖注入如何配控制器按业务边界拆而不是按页面拆。商品相关的列表、详情、搜索放 ProductController购物车的加购、改数量放 CartController订单的创建和查询放 OrderController首页简单展示给 HomeController。这样拆的好处是路由清晰、职责单一测试时不用为了测下单而去请求商品详情页。依赖注入方面Program.cs 里把 DbContext 和数据库服务配好var builder WebApplication.CreateBuilder(args); builder.Services.AddDbContextShopDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Shop))); builder.Services.AddControllersWithViews(); var app builder.Build(); app.UseStaticFiles(); app.UseRouting(); app.MapControllerRoute( name: default, pattern: {controllerHome}/{actionIndex}/{id?}); app.Run();AddDbContext 默认注册为 Scoped也就是一次 HTTP 请求内拿到同一个 DbContext 实例这对商城是合适的。页面里先查商品再查库存两次查询共享同一个上下文不会再产生「DbContext 已被释放」的报错。AddControllersWithViews 是 MVC 模式的总开关没它路由和视图渲染都不工作。注意默认路由模板{controllerHome}/{actionIndex}/{id?}id 是可选参数。商城商品的详情地址会自然生成/Product/Detail/10购物车结算地址会是/Cart/Checkout/10。大多数页面用这套约定路由就够了不用每个 Action 都写特性路由但下一节有个例外。3.2 商品列表与分页搜索的 IQueryable 组合写法商品列表是商城流量最大的页面搜索、分类、分页都是绕不开的。这里的关键是在数据库里完成过滤和分页不要先把全部商品 ToList 到内存再筛选。下面是一个标准的 ProductController.Index 写法public class ProductController : Controller { private readonly ShopDbContext _db; public ProductController(ShopDbContext db) { _db db; } public async TaskIActionResult Index(string? q, string? category, int page 1, int pageSize 12) { var query _db.Products.AsNoTracking().Where(p p.IsOnSale); if (!string.IsNullOrWhiteSpace(q)) { query query.Where(p p.Name.Contains(q) || (p.Description ! null p.Description.Contains(q))); } if (!string.IsNullOrWhiteSpace(category)) { query query.Where(p p.Category category); } var total await query.CountAsync(); var items await query .OrderBy(p p.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); var viewModel new ProductListViewModel { Products items, Page page, PageSize pageSize, Total total, Q q, Category category }; return View(viewModel); } }这段代码有四个关键点。AsNoTracking() 表示这些实体只读不追踪列表页没有写操作省掉变更追踪的开销。CountAsync 和 ToListAsync 分开执行先拿总数用于分页条再拿当前页数据。Skip/Take 是分页的标准写法pageSize 设为 12商城列表一屏放 12 个商品比较合适用户翻页压力小。Contains 在 SQL Server 里会被翻译成 LIKE数据库列上有索引时还能用但要注意 LIKE 的匹配开销商品量上了十万级后要观察执行计划。视图这边很简单form methodget asp-actionIndex input typetext nameq valueModel.Q placeholder搜商品 / select namecategory option value全部分类/option option value手机手机/option option value配件配件/option /select button typesubmit搜索/button /form ul foreach (var product in Model.Products) { li a asp-actionDetail asp-route-idproduct.Idproduct.Name/a spanproduct.Price.ToString(C)/span /li } /ul表单用 methodget 而不是 post搜索参数会拼到 URL 上用户可以复制搜索结果链接刷新页面也不会触发重新提交弹窗。asp-action 和 asp-route-id 是 Tag Helper 的写法它会根据路由模板自动生成链接你改了路由模板视图不用跟着改。3.3 特殊路由的指定Attribute Route 与一个路由被吞的现场默认约定路由能覆盖九成页面但有几种情况必须用特性路由比如想让购物车结算地址更短更语义化或者某个接口不需要 controller 前缀。ASP.NET Core 里给某个方法指定特殊路由很直接[HttpGet(cart/checkout/{orderId})] public IActionResult Checkout(int orderId) { return View(); }这和 Spring MVC 里的 RequestMapping 是一个套路把 HTTP 动词和路由模板直接写在方法上。好处是 URL 好看、和控制器名解耦坏处是如果你在控制器类上写了[Route(shop)]那么这个控制器下所有方法的默认路由全被覆盖没写特性路由的 Action 直接 404。这个坑我踩过一次血泪经验给 ProductController 加了个类级特性路由[Route(shop)]想统一品牌前缀结果原来的/Product/Index变成/shop/Index首页导购链接全部失效排查了半天才意识到是控制器级路由把约定路由挤掉了。所以我的习惯是类上不用特性路由只在个别的 Action 上做局部指定如果团队确实要控制器级前缀那每个 Action 都得配上完整的 Route 模板别一半靠约定一半靠特性。4. SQL Server 端设计建表、索引与不会超卖的扣库存事务模型和控制器都动起来后真正的底线在数据库。商城系统里商品可以少展示几个库存和订单绝对不能错。SQL Server 在这种场景下的角色不只是存储而是最后一道数据守门员。下面这些建表脚本和事务写法是照着 EF Core 迁移生成的手工等价版目的是让你清楚数据库层面到底发生了什么。4.1 Products 与 Orders 的建表脚本约束和索引要落到 DDLEF Core 的迁移能生成表但手工看一眼 DDL 能让你知道字段约束和索引确实建上了。商品表和订单表的最小脚本如下CREATE TABLE dbo.Products ( Id INT IDENTITY(1,1) NOT NULL, Name NVARCHAR(200) NOT NULL, Description NVARCHAR(MAX) NULL, Price DECIMAL(18,2) NOT NULL, Stock INT NOT NULL, Category NVARCHAR(50) NULL, IsOnSale BIT NOT NULL CONSTRAINT DF_Products_IsOnSale DEFAULT(1), RowVersion ROWVERSION NOT NULL, CONSTRAINT PK_Products PRIMARY KEY CLUSTERED (Id), CONSTRAINT CK_Products_Stock_NonNegative CHECK (Stock 0) ); CREATE INDEX IX_Products_Category ON dbo.Products(Category);这里的 CHECK 约束CK_Products_Stock_NonNegative值得多说一句它保证 Stock 字段永远不能小于 0一旦应用层的扣库存 SQL 写漏了条件SQL Server 会直接拒绝更新而不是让库存变成负数。这是最后一道闸应用层可以有自己的判断但数据库防线必须存在。订单表同样需要约束Status 字段用 CHECK 限定取值范围避免程序 bug 写入脏状态CREATE TABLE dbo.Orders ( Id INT IDENTITY(1,1) NOT NULL, UserKey NVARCHAR(64) NOT NULL, Total DECIMAL(18,2) NOT NULL, Status NVARCHAR(20) NOT NULL, CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_Orders_CreatedAt DEFAULT(SYSUTCDATETIME()), CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (Id), CONSTRAINT CK_Orders_Status CHECK (Status IN (Pending, Paid, Shipped, Cancelled)) );主键默认是聚集索引Id 自增列做聚集索引在商城场景下没问题因为订单写入是递增的页分裂少。Category 上的普通索引是非聚集索引覆盖按分类筛选的列表查询。Order 表按用户查订单的场景也常见可以加一个 UserKey 的索引但订单表数据量上来之前不必过度设计。4.2 扣库存不超卖用一条 UPDATE 的条件更新完成并发控制商城上线后第一个被骂的 bug 通常是超卖明明库存只有 10 件却同时卖出去 15 单。根因是很多团队写了「先查库存、再判断、后更新」的三步流程两个并发请求同时读到库存 10都判断足够然后都把库存改成 9最后库里剩 8 还是负的完全看运气。正确做法是让数据库的 UPDATE 语句自己完成判断和更新这是一个原子操作public async TaskIActionResult Buy(int productId, int quantity) { var affected await _db.Database .ExecuteSqlInterpolatedAsync( $UPDATE dbo.Products SET Stock Stock - {quantity} WHERE Id {productId} AND Stock {quantity}); if (affected 0) { return BadRequest(库存不足或商品已下架); } // 库存扣减成功后再创建订单并把两步放进同一个事务 return RedirectToAction(Checkout, new { id productId }); }这条 UPDATE 的原理是条件更新Stock quantity作为 WHERE 条件SQL Server 执行时会锁定这一行并发请求里只有一个能修改成功其他请求的影响行数返回 0。用 affected 判断是否成功比先查再改可靠得多。ExecuteSqlInterpolatedAsync 是 EF Core 提供的原生 SQL 出口注意用的是插值语法而不是拼接字符串参数会被自动参数化不会引入 SQL 注入。下单不能只扣库存还要写订单表和订单明细。这两步必须包在同一个事务里否则可能库存扣了、订单没建钱货两空的投诉就来了await using var transaction await _db.Database.BeginTransactionAsync(); try { // 执行上面的 UPDATE 扣库存 // 插入 Orders 表记录 // 插入 OrderItems 明细 await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }事务的范围越小越好只在扣库存和写订单这几步之间包事务别把用户输入校验、图片上传这类几十毫秒的操作也塞进去事务持有锁的时间越长并发冲突概率越大。4.3 连接字符串与登录方式从 Windows 身份验证到 SQL 登录的取舍连接字符串的选择直接影响你本地开发和上线的排错体验。本地开发常用 Windows 身份验证调试方便不用管密码策略Serverlocalhost\SQLEXPRESS;DatabaseShopDb;Trusted_ConnectionTrue;TrustServerCertificateTrue部署到服务器后应用池身份往往不是 Windows 域账号更常见的是创建一个最小权限的 SQL Server 登录账号给应用用。两种方式对比如下登录方式适用场景注意点Windows 身份验证本地开发、内网单机连接字符串不带密码但发布到其他机器要处理账号映射SQL Server 登录开发、测试、生产均可需要启用混合验证模式密码单独管理定期轮换企业里经常遇到「已成功与服务器建立连接但是在登录前握手阶段失败」或用户名密码正确却登不上的情况多数是 TCP/IP 协议没启用或者 SQL Server 只开了 Windows 验证模式。这个坑我放到下一章的避坑清单里详细拆。5. 上线前避坑清单连接、路由、精度与并发五连坑这部分是我整理的实际踩坑记录每一条都是「现象→原因→解决」的结构。做商城项目时照着排查一遍能省下不少凌晨改代码的时间。5.1 连接故障已成功与服务器建立连接但是在登录前被断开现象连接字符串和账号密码都核对过SSMS 能连上但 ASP.NET Core 程序启动时报「已成功与服务器建立连接但是在登录前握手阶段发生错误」或者直接报 18456 登录失败。原因两个最常见。第一SQL Server 网络配置里的 TCP/IP 协议没有启用SSMS 通过共享内存或命名管道能连但应用通过 TCP 的 1433 端口连不上。第二服务器只开了 Windows 身份验证模式SQL Server 登录账号完全不可用程序用 User Id 和 Password 去连自然失败。解决打开 SQL Server 配置管理器找到实例的「SQL Server 网络配置」把 TCP/IP 设为启用重启 SQL Server 服务再用 SSMS 登录服务器右键服务器属性把身份验证模式切成「SQL Server 和 Windows 身份验证模式」。给应用账号重新设一个符合密码策略的密码连接字符串保持稳定。这一套做完绝大多数连接问题就消失了。提示改完 SQL Server 网络配置后必须重启实例只改配置不重启连接情况没有变化。重启会踢掉现有连接选业务低峰期操作。5.2 数据精度与路由遮蔽decimal 被截断和特殊路由吞页面现象一商品价格在列表页显示正常但提交订单后总金额差了几分钱甚至某些商品价格变成了整数。原因数据库里的 Price 列被建成了 decimal(18,0)小数部分直接四舍五入没了。这通常是手写建表脚本时没写精度或者 EF Core 迁移前数据库里已存在旧表迁移只加了列没改精度。解决PRICE 列统一用 decimal(18,2)EF Core 一侧的 Fluent API 也要写成 HasColumnType(decimal(18,2))两边对齐后重新生成迁移脚本。上线前的验证方法是找几个价格带小数的商品从前台下单走到对账金额必须一分不差。现象二给某个方法加了特殊路由后原来的商品列表页和详情页全部 404。原因控制器类上加了[Route(shop)]之类类级特性路由导致该控制器下所有 Action 的默认约定路由全部失效只有写了独立 Route 模板的方法才可访问。解决类上不要加特性路由只在个别 Action 上用[HttpGet(cart/checkout/{orderId})]这种写法。如果非要控制器级统一前缀就必须给控制器下每一个 Action 都补充路由模板这是义务不是选项。5.3 并发超卖与会话丢失库存变成负数和购物车被清空现象活动开始一小时内卖出了超过库存数量的订单后台看到库存变成负数或者数据库里 CHECK 约束直接把更新语句拒绝前端用户看到报错。原因代码走了「先 SELECT Stock再 if 判断最后 UPDATE」的流程并发过来后多个请求读到同一个旧库存判断都通过最后更新互相覆盖。解决把扣库存改成一条条件 UPDATE也就是第 4 章写的SET Stock Stock - quantity WHERE Id productId AND Stock quantity用影响行数判断是否扣减成功再把写订单包进同一事务。如果希望支持重试可以再用 RowVersion 做版本校验失败后重新读取库存重试一次。现象用户把商品加进购物车过了一段时间再打开购物车空了。尤其是重新发布站点或应用池回收之后。原因购物车数据存到了内存 Session 里IIS 应用池回收或进程重启后内存清空用户会话就丢了。解决商城系统里购物车应该持久化到数据库用 CartItems 表保存 UserKey 和 ProductId用户每次进页面实时读库。开发阶段用内存 Session 图省事可以生产环境要么把 Session 存到 SQL Server要么直接走数据库购物车后者更符合商城的业务语义用户换设备购物车也还在。6. 本地跑通整套方案的最小路线与并发自测技巧从空目录到一个能下单的商城我习惯的本地验证路线是这样。先建项目再补模型和配置然后迁移数据库最后跑一个并发脚本验证扣库存逻辑。dotnet new mvc -o ShopDemo cd ShopDemo dotnet add package Microsoft.EntityFrameworkCore.SqlServer写好第 2 章的实体、ShopDbContext 和连接字符串后执行迁移命令数据库里就有了 Products、CartItems、Orders 三张表。启动站点访问商品列表页手动加购一件商品走完下单流程这是功能层面的验收。真正的并发自测用一个 PowerShell 脚本就能完成。假设商品 Id 为 1初始库存 20 件同时发 20 个请求各买 1 件1..20 | ForEach-Object -Parallel { Invoke-WebRequest -Uri http://localhost:5000/Product/Buy?productId1quantity1 -UseBasicParsing | Out-Null } -ThrottleLimit 10跑完后查询库存SELECT Id, Stock FROM dbo.Products WHERE Id 1;如果扣库存逻辑正确Stock 应该正好是 0。如果你用的是「先查再改」的老写法结果大概率会出现负数或者看到部分请求返回库存不足而实际库存还有很多剩余这就是并发条件竞争的直接证据。这个自测脚本我每接一个商城项目都会跑一遍成本只有几分钟却能验证整套方案的底线。我最早做商城时没做这个并发验证上线第二天运营发来一张库存负数的截图我当时还在怀疑是 UI 显示问题查了一整天才意识到是扣库存 SQL 少写了Stock quantity条件。那个晚上改完代码我在项目里立了一个规矩所有涉及库存、余额、优惠券扣减的接口上线前必须过一遍并发脚本。希望你不用经历我那次熬夜这篇文章里的连接配置、路由写法、事务边界和并发扣库存清单能帮你在第一周就把这些坑填平把精力留给真正的前端展示和用户体验。本文还有配套的精品资源点击获取