☰
ASP.NET Core电商网站实战:从购物车到库存与权限的完整开发指南
2026/10/14 16:16:08 网站建设 项目流程

简介:这是一份基于ASP.NET Web Forms构建的电子商务网站学生项目,适合初学.NET的开发者作为期末作业或入门实践参考。项目围绕商品展示、购物车、用户注册登录、订单处理等典型电商模块展开,完整呈现了小型网站从页面到数据库的搭建思路。资源共59个文件,包含aspx页面、cs后台逻辑、ascx用户控件、mdb数据库文件以及gif/jpg图片和css样式,压缩包整体约400KB,体量不大但代码与界面素材齐全。已有143人学习浏览,口碑较稳。通过这份项目源码,可以实际看到ASP.NET中ADO.NET数据库交互、身份验证与授权、Session购物车状态管理、母版页统一布局等关键技术的落地写法。结合附带的Access数据库与页面文件对照学习,能快速理解一个电商系统的完整架构,也可在此基础二次开发,是非常实用的起步模板。

1. asp.net 电子商务网站:从「能跑」到「能上线」,卡点从来不在页面上

asp.net 电子商务网站这类项目,绝大多数开发者的第一个误区是以为工作量在页面展示上——商品图、购物车按钮、结算表单,这些确实重要,但真正耗时间的永远在看不见的地方:购物车状态怎么存、并发下单怎么不超卖、Session 为什么一重启就没数据、后台权限怎么和前台用户隔离。这篇文章不绕弯子,直接按一条能交付的生产链路来拆:用 ASP.NET Core MVC 搭前台交易,用 Identity 做用户与角色,用 EF Core 管商品和订单,把最容易翻车的地方逐个说透。适合正在做毕业设计或课程项目的同学,也适合小团队要快速交付电商 MVP 的从业者。目标只有一个:照着做,能把一个能下单、能管理商品、能扛住基本并发的 ASP.NET 电商站跑起来。

2. 先把技术栈定死:ASP.NET Core MVC 与 EF Core 的组合,为什么不需要纠结

2.1 新项目用 Core MVC 而不是 Web Forms:不是新旧之争,是维护成本之争

只要是还在维护的老电商系统,你大概率会碰到 Web Forms 的项目,这我不否认。但如果你是新起一个 asp.net 电子商务网站,从零选型,我的建议非常明确:用 ASP.NET Core MVC,别碰 Web Forms。

原因不是「Web Forms 技术老」这么简单。电商站点和普通后台管理系统不一样,它同时有面向消费者的多页面流程(商品浏览、购物车、结算、订单详情)和面向运营的后台页面(商品管理、库存调整、订单处理)。MVC 的 Controller-Action-View 结构天然适合这种多入口场景:一个商品列表页对应一个 Action,购物车结算对应一组独立的 Controller,后台管理再挂一套带 Admin 前缀的路由,各模块之间的边界非常清晰。

Razor Pages 也值得提一句,但我不推荐在电商主流程上用它。Razor Pages 适合「每个页面自带处理逻辑」的轻量工具站,比如留言板、表单提交。电商的商品详情页往往需要复用同一个商品组件,购物车要跨页面共享状态,这些用 MVC 的 ViewModel 和 PartialView 组织起来更顺手。一句话:用 Core MVC,前端页面的自由度更高;用 Razor Pages,写小工具更快。电商选前者。

2.2 dotnet new mvc 创建项目,连接 SQL Server 的最小配置

确认本机装了 .NET SDK 后,打开终端,创建项目。这一步没有任何玄学,命令就是下面这几行,跑完用浏览器打开首页看一眼,再继续后面的内容。

dotnet new mvc -n ShopDemo cd ShopDemo dotnet run

第一行创建名为 ShopDemo 的 MVC 项目,模板自带 Controllers、Views、Models 三个目录和 Program.cs 入口;第二行进到项目目录;第三行启动 Kestrel 开发服务器。默认地址一般是 https://localhost:5001,能看到首页和模板自带的隐私政策页面就说明骨架没问题。

接下来装 EF Core 的 SQL Server 驱动包。我一般用 dotnet CLI 操作,在项目目录下执行:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools

第一个包是 EF Core 操作 SQL Server 的数据库提供程序,第二个包提供迁移命令(migrations)。装完在 appsettings.json 里加连接字符串:

{ "ConnectionStrings": { "ShopContext": "Server=(localdb)\\MSSQLLocalDB;Database=ShopDemo;Trusted_Connection=True;MultipleActiveResultSets=true" } }

这段配置里的Server=(localdb)\\MSSQLLocalDB指向 Visual Studio 自带的本地数据库实例,适合开发调试;Trusted_Connection=True表示用 Windows 身份认证连接,本地开发不用管账号密码;MultipleActiveResultSets=true允许同一个连接上同时进行多个查询,EF Core 的延迟加载和分页查询会用到,建议保留。

2.3 商品与订单模型:字段先定清楚,后面少加班

电商网站的数据模型有几个固定成员:商品(Product)、订单(Order)、订单明细(OrderItem)、用户(User)。我建议把前三者先建出来,因为后面所有页面和接口都围绕它们转。下面是常见的模型定义,可以直接抄进 Models 目录。

public class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public decimal Price { get; set; } public string ImagePath { get; set; } = string.Empty; public int Stock { get; set; } public bool IsActive { get; set; } public DateTime CreatedAt { get; set; } } public class Order { public int Id { get; set; } public string UserId { get; set; } = string.Empty; public DateTime OrderDate { get; set; } public decimal TotalAmount { get; set; } public string Status { get; set; } = "Pending"; public List<OrderItem> Items { get; set; } = new(); } public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public int ProductId { get; set; } public string ProductName { get; set; } = string.Empty; public decimal UnitPrice { get; set; } public int Quantity { get; set; } }

几个字段是踩过坑才确定的:价格用decimal而不是double,后面订单金额的精度全靠这个;IsActive是逻辑删,电商商品不能物理删除,因为历史订单外键还指着它;OrderItem里冗余存了一份ProductName和UnitPrice,这样订单生成后即使商品改名、调价,历史订单依然能还原当时的成交信息。这三点都属于「当时觉得没必要、上线后才发现必须有」的典型。

3. 把核心交易链路打通:商品列表、购物车、订单与库存

3.1 商品列表页分页:不排序的分页等于随机分页

商品列表是所有电商的流量入口,也是第一个容易翻车的地方。很多人一上来写query.Skip((page - 1) * pageSize).Take(pageSize)就完事了,结果商品数量一多,翻到第二页会出现第一页重复的商品,或者某几个商品永远翻不出来。原因很简单:数据库不保证无排序下的返回顺序稳定,分页必须配一个确定的排序字段。

下面是商品列表 Action 的常见写法,我在控制器里按产品 ID 升序分页。

public async Task<IActionResult> Index(int page = 1) { const int PageSize = 12; var query = _context.Products .Where(p => p.IsActive) .OrderBy(p => p.Id); var total = await query.CountAsync(); var items = await query .Skip((page - 1) * PageSize) .Take(PageSize) .ToListAsync(); var viewModel = new ProductListViewModel { Products = items, CurrentPage = page, TotalPages = (int)Math.Ceiling(total / (double)PageSize) }; return View(viewModel); }

这段代码里,OrderBy(p => p.Id)是分页的定海神针,只要排序键稳定,Skip 和 Take 的结果就稳定;CountAsync单独查一次总数,和ToListAsync分开执行,避免在大分页时把不必要的数据也查出来;Math.Ceiling用来计算总页数,注意要转成 double 再除,否则整数除法会直接截断。如果你想让商品按价格或销量排序,把OrderBy换成OrderByDescending(p => p.Price)之类的表达式即可。

3.2 购物车存 Session 还是 Cookie:我选择 Session 加 JSON

购物车的存储方案有三种常见选择:Session、Cookie、数据库表。三者的边界我在实际项目里摸得很清楚:Cookie 适合存储量小、不敏感的数据,但购物车动辄十几个商品,全塞进 Cookie 会让每次请求的 Header 变大;数据库购物车最稳,但要求用户登录后才能落库,游客购物车要先在本地攒着;Session 是这两者的中间态,可以存任意大小的对象,但要解决 Session 丢失的问题。

我一般用 Session 存购物车 JSON 字符串,键名固定为cart。下面是 CartService 的简化实现,展示加购逻辑:

public class CartService { private readonly IHttpContextAccessor _accessor; public CartService(IHttpContextAccessor accessor) { _accessor = accessor; } public void Add(int productId, int quantity) { var session = _accessor.HttpContext!.Session; var cartJson = session.GetString("cart") ?? "{}"; var cart = JsonSerializer.Deserialize<Dictionary<int, int>>(cartJson); cart[productId] = cart.GetValueOrDefault(productId) + quantity; session.SetString("cart", JsonSerializer.Serialize(cart)); } }

这段代码的核心是 HttpContextAccessor,它让服务类在任何地方都能拿到当前请求的 Session,而不是非得在控制器里操作;GetString("cart") ?? "{}"处理首次加购时 Session 为空的情况;字典的 key 是商品 ID,value 是数量,后续购物车页面展示时再用商品 ID 批量查数据库。

注意,这个方案的前提是 Session 存储可用。开发环境下默认的进程内 Session 没问题,但部署到生产后必须换成分布式存储,否则应用一重启,用户的购物车全部清空。常见做法是加一个 Redis 包并注册 Session:

builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "localhost:6379"; options.InstanceName = "ShopDemo"; }); builder.Services.AddSession(options => { options.IdleTimeout = TimeSpan.FromMinutes(30); }); builder.Services.AddHttpContextAccessor();

IdleTimeout是会话空闲超时时间,购物车场景建议 30 分钟起步,太短用户挑个商品回来就丢了;AddHttpContextAccessor必须注册,否则 CartService 构造时拿不到 IHttpContextAccessor。生产环境请根据实际部署修改 Configuration 的连接地址。

3.3 下单扣库存:必须先锁库存再写订单,少一步都会超卖

下单是整个电商链路里最需要谨慎的环节。想象一个场景:商品只剩 1 件,两个用户同时点击「立即购买」,如果代码是「先读库存、判断够不够、够了再扣」,两个请求都读到了库存 1,都认为够卖,然后各写一张订单,库存变成负数,这就是超卖。

解决思路是把「检查库存」和「扣减库存」合并成一个原子操作。下面是我常用的下单方法,放在事务里执行:

public async Task<bool> PlaceOrderAsync(string userId, List<CartLine> lines) { await using var transaction = await _context.Database.BeginTransactionAsync(); try { foreach (var line in lines) { var product = await _context.Products.FindAsync(line.ProductId); if (product == null || product.Stock < line.Quantity) { throw new InvalidOperationException($"商品 {line.ProductId} 库存不足"); } product.Stock -= line.Quantity; } var order = new Order { UserId = userId, OrderDate = DateTime.UtcNow, Items = lines.Select(l => new OrderItem { ProductId = l.ProductId, ProductName = l.ProductName, UnitPrice = l.UnitPrice, Quantity = l.Quantity }).ToList(), TotalAmount = lines.Sum(l => l.UnitPrice * l.Quantity) }; _context.Orders.Add(order); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return true; } catch { await transaction.RollbackAsync(); return false; } }

这段代码的关键点有三个。BeginTransactionAsync把扣库存和建订单放进同一个数据库事务,要么都成功,要么都回滚;product.Stock -= line.Quantity的读写是 EF Core 追踪的,当SaveChangesAsync执行时,数据库会锁定这一行直到提交;TotalAmount直接用UnitPrice * Quantity计算,前提是 UnitPrice 是 decimal,而不是 float。

事务只是第一道锁,严谨的系统还会在数据库层面做限制,比如在 Product 表加CHECK (Stock >= 0)的约束,以及给订单表加一个「同用户同商品未支付订单唯一」的索引,双保险。在我见过的一次真实翻车里,某开发者只用事务判断库存,结果高并发下 SQL Server 的隔离级别导致两个事务都读到旧库存,最后还是靠 CHECK 约束兜住才没让库存变负。所以我的习惯是:代码防业务逻辑,数据库防最后底线。

4. 用户体系与后台权限:Identity 集成和 Admin 角色

4.1 直接集成 ASP.NET Core Identity,别自己造用户表

很多初学者第一次做 asp.net 电子商务网站,习惯性地自己建一张 User 表,存用户名和「加密」后的密码。这个思路在非生产环境能跑通,但你迟早要在这些功能上重新投入:密码哈希算法迭代、登录失败锁定、Cookie 过期刷新、角色权限。这些全是安全敏感项,自己写到第三个月就会后悔。

ASP.NET Core Identity 是官方提供的身份框架,注册用户、登录、密码重置、角色管理都内置了。集成方式很简单,先装包:

dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore

然后在 Program.cs 里注册服务。

builder.Services.AddDbContext<ShopContext>(options => options.UseSqlServer( builder.Configuration.GetConnectionString("ShopContext"))); builder.Services.AddDefaultIdentity<IdentityUser>() .AddRoles<IdentityRole>() .AddEntityFrameworkStores<ShopContext>();

AddDefaultIdentity<IdentityUser>注册了默认的用户体系,包含注册、登录、Cookie 认证;.AddRoles<IdentityRole>()启用角色系统,这是后台管理员权限的基础;.AddEntityFrameworkStores<ShopContext>()告诉 Identity 用哪个数据库上下文来建用户表、角色表。做完这些,Identity 会自动在库里创建 Users、Roles、UserRoles 等表,不需要你手动建。

4.2 后台管理员的种子数据与 [Authorize(Roles = "Admin")]

用户表有了,接下来要解决的是:第一个管理员从哪来。在开发环境,我习惯用一个种子方法在应用启动时创建管理员账号和 Admin 角色。下面是最小实现:

public static async Task SeedAdminAsync(IServiceProvider services) { var roleManager = services.GetRequiredService<RoleManager<IdentityRole>>(); var userManager = services.GetRequiredService<UserManager<IdentityUser>>(); if (!await roleManager.RoleExistsAsync("Admin")) { await roleManager.CreateAsync(new IdentityRole("Admin")); } var adminEmail = "admin@shopdemo.local"; if (await userManager.FindByEmailAsync(adminEmail) == null) { var admin = new IdentityUser { UserName = adminEmail, Email = adminEmail }; var result = await userManager.CreateAsync(admin, "Admin@123456"); if (result.Succeeded) { await userManager.AddToRoleAsync(admin, "Admin"); } } }

在 Program.cs 里用using (var scope = app.Services.CreateScope())调用这个方法,应用启动时就会自动创建角色和账号。需要注意:这个种子密码只是开发用的,交付前必须改成随机强密码,或者强制管理员第一次登录改密。如果你不执行这一步,后面你在任何 Controller 上写[Authorize(Roles = "Admin")],整个项目里都不会有人能进入后台。

控制器上的用法很简单:

[Authorize(Roles = "Admin")] public class AdminController : Controller { public IActionResult Index() => View(); }

只要在这行特性上挂了 Admin 角色的 Controller,普通登录用户访问会直接被拒,返回 403。商品管理的增删改、订单状态修改这类后台操作,都放在这个区域里,和前台前台用户隔离。注意这里有个细节:[Authorize(Roles = "Admin")]是写进路由的,不是写在你自己的逻辑里,所以权限判断在请求进入 Controller 前就完成了,不需要在每个 Action 里重复写if (!User.IsInRole("Admin"))这种代码。

5. ASP.NET 电商开发的 5 个高频翻车点:现象、原因与解法

5.1 迁移后运行时报「对象名无效」:表根本不在数据库里

  • 现象:启动应用,访问商品列表页,抛异常Invalid object name 'Products',但明明在代码里定义了 DbSet。
  • 原因:你只执行了dotnet ef migrations add Init,生成了迁移文件,但没执行dotnet ef database update把迁移应用到数据库;或者连接字符串指向了另一个数据库实例。
  • 解决:先dotnet ef database update确认执行成功;再检查 appsettings.json 里的连接字符串和实际目标库是否一致。如果是在启动时用Database.EnsureCreated()建的库,之后又加了迁移文件,两套初始化机制会冲突,建议统一走 migrations 流程,把 EnsureCreated 去掉。

5.2 购物车一重启就清空:进程内 Session 的局限

  • 现象:开发环境加购商品后,修改代码触发应用重启,刷新页面购物车空了,用户投诉「东西还在,购物车没了」。
  • 原因:开发默认的 Session 存在 Kestrel 进程内存里,进程一停,数据全丢。你在本地单机跑感觉不到,但部署到生产后,应用池回收、版本发布、服务器重启都会造成 Session 丢失。
  • 解决:按前面第 3.2 节的方式接 Redis 或数据库 Session 存储。如果项目规模小到不想引 Redis,SQL Server 自带的AddDistributedSqlServerCache也能做持久化,但性能上不如 Redis。这个坑我翻过一次——把站点部署到某共享主机后,购物车时好时坏,查了半天才发现主机的应用池每 20 分钟回收一次,Session 被一次次清空。接上分布式存储后,问题彻底消失。

5.3 订单金额对不上:float 和 double 的分文误差

  • 现象:一件商品单价 19.9,买 3 件,显示合计 59.699999999999996,保存到数据库成了 59.7,但不同环境的计算结果偶尔四舍五入不一致。
  • 原因:float 和 double 用二进制近似表示十进制小数,0.1 这类数字在二进制里是无限循环的。电商订单涉及金额计算,任何一步用 double,后续的对账、退款都会出问题。
  • 解决:所有价格、合计字段一律用decimal,EF Core 映射成 SQL Server 的decimal(18,2)。计算时也只用 decimal 类型做运算,不要把 decimal 和 double 混着算。严格一点的做法是金额全用「分」为单位存 long 类型整数,展示时再除以 100,彻底绕开浮点误差。

5.4 上传的商品图片显示 404:路径写错是头号嫌疑

  • 现象:后台可以成功上传图片,字节也写了本地磁盘,但商品页面的 img 标签永远 404。
  • 原因:最典型的是保存路径用了相对路径(比如"images/products/img1.jpg"),当前工作目录和应用根目录不一样;或者把文件写到了wwwroot之外,而静态文件中间件只服务wwwroot下的内容。
  • 解决:在代码里通过IWebHostEnvironment.WebRootPath获取绝对路径,再拼文件名保存。同时确认上传目录在 wwwroot 之下,路由访问名和文件名大小写要一致。如果你把图片存到数据库的 byte 数组里,则要把 Controller 写成 FileResult 来返回图片内容,但那样每次页面加载都要查库,比静态文件性能差不少,不推荐。

5.5 AJAX 提交表单返回 401:防伪令牌没带过去

  • 现象:登录状态下,页面表单提交正常,但用 fetch 或 Axios 发 JSON 请求给后台,返回 401 Unauthorized。
  • 原因:ASP.NET Core MVC 默认启用防伪令牌校验,表单提交时会自动带上隐藏字段,但 AJAX 请求如果没在请求头里带令牌,校验就过不去。
  • 解决:在 Razor 视图里用@Html.AntiForgeryToken()生成一个隐藏字段,然后把令牌值读出来,AJAX 请求时通过X-XSRF-TOKEN请求头发送。同时需要在启动代码里配置反伪造服务的头部名称:
builder.Services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN");

这样后台的[ValidateAntiForgeryToken]就会从请求头读取令牌。这个问题我在某个模拟项目里卡了一个下午,明明认证了却始终 401,最后翻开浏览器的请求头才想起来少了这个 token。

6. 交付前最后一公里:商品缓存、部署配置与性能体检

6.1 商品详情页用 IMemoryCache 缓存热点数据

商品详情是电商站点被访问最多的页面之一。每次打开详情页都从头查一次数据库,对 SQL Server 的查询压力不小。我一般会在 Repository 上加一层内存缓存,缓存粒度和失效策略是关键。

public async Task<Product?> GetProductAsync(int id) { var cacheKey = $"product:{id}"; return await _cache.GetOrCreateAsync(cacheKey, async entry => { entry.SlidingExpiration = TimeSpan.FromMinutes(10); return await _context.Products.FindAsync(id); }); }

SlidingExpiration表示 10 分钟内没人访问这个 key 才会被清除,适合电商这种访问量随时波动的场景;如果商品信息经常修改,你需要在后台编辑保存后主动移除对应缓存,否则用户会看到旧价格。缓存键加了product:前缀,是为了防止和订单、购物车的 key 撞车。

6.2 部署到 IIS 之前要调的 3 个参数

生产环境用的不再是用开发机自带的 localdb,我一般习惯列出三个必改项。第一,连接字符串从(localdb)\\MSSQLLocalDB换成生产 SQL Server 的地址,并且用账号密码认证;第二,启动环境变量ASPNETCORE_ENVIRONMENT设为Production,确保错误页面不向用户展示堆栈信息;第三,如果网站跑在 IIS 上,应用池的 CLR 版本要设为「无托管代码」,由 IIS 反向代理到 Kestrel。这三个配置漏掉任何一个,线上部署都会出现本地一致、上线就出岔子的窘境。

6.3 上线前先查一次数据库索引:慢查询全都露馅

页面卡顿最常见的原因不是代码写得差,而是查询没有索引。商品列表页按IsActive过滤再按Id排序时,我给这两列建了联合索引;订单查询按UserId过滤,也要建索引。上线前把 SQL Server Profiler 打开,跑一轮核心流程,看哪些查询耗时超过 200 毫秒,给表加上索引后重新跑一次,数据量从几千跳到几十万,差别非常明显。

我的习惯是每次做电商项目,都把「下单流程」当作验收底线。先把商品列表、详情、加购、下单、库存扣减这五步跑通,再回过来做美化页面和性能优化,顺序反了,最后很容易发现自己把大量时间消耗在了一个还没验证的购物车上。这个顺序问题,是我带过的同学踩得最多的一个坑,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询