简介:这是一套面向C#初学者与Web开发入门者的网上商城购物系统源码,基于ASP.NET技术栈实现,适合用于课程设计、毕业设计或自学练手,帮助理解电商类管理系统的整体架构与业务逻辑。压缩包共205个文件,约1.51MB,其中43个cs文件承载核心业务逻辑,30个aspx页面构成前台展示与后台管理界面,6个ascx用户控件负责导航、购物车、帮助等公共模块复用,另有71个gif与28个jpg提供界面素材,12个db及mdf、ldf文件为数据库支撑,配合少量css、config与sln解决方案文件,结构完整可直接运行。系统具备良好的人机界面,支持打印功能,注重易维护性与易操作性,运行稳定安全。目前已有676人学习下载,读者可从中获取完整的商城购物流程实现、订单管理与商品展示模块的代码组织方式,以及数据库设计与页面控件的分层思路,适合对照源码梳理电商系统的开发脉络。
1. 网上商城购物系统(C#版):从商品列表到订单落库,一条能跑通的链路
很多同学做网上商城购物系统(C#版)时,卡住的地方往往不是“不会写代码”,而是不知道一条完整的购物链路到底要串起哪些东西。商品展示、购物车、下单、库存扣减、订单查询,这五步在演示里看着简单,真动手就会遇到“购物车数据放哪”“库存什么时候扣”“订单号怎么生成”这类具体问题。这篇笔记面向的是想用 C# 把商城系统真正跑起来的开发者,不管你是拿它做课程设计、练手项目,还是给小型业务做原型,下面这套思路都能直接复现。我会按“先能跑,再能扛”的顺序,把每个环节的选型和参数讲清楚。
2. 先定架构:C# 商城系统用什么技术栈不翻车
2.1 三层架构还是 MVC,先看你的部署场景
做网上商城购物系统(C#版),第一个要拍板的就是架构。常见做法有三种:ASP.NET Core MVC、ASP.NET Core Web API + 前端分离、以及 WinForms/WPF 桌面端。如果你只是本地演示或者课程答辩,MVC 最省事,视图和控制器都在一个项目里,调试链路短。如果要做成前后端分离或者以后接小程序,那就选 Web API,前端用 Razor Pages 或者独立页面都行。桌面端现在比较少用于商城,除非是内部收银工具。
我一般会这样判断:需要浏览器访问就选 Web API 或 MVC;需要离线单机跑就选 WPF。对于大多数想练手的开发者,ASP.NET Core Web API + Entity Framework Core + SQLite 是最小可行组合,不用装 SQL Server,文件即数据库,拷走就能跑。
选型理由说清楚:EF Core 负责对象和表的映射,SQLite 负责零配置存储,Web API 负责把商品、购物车、订单暴露成 HTTP 接口。这三件套在 .NET 6/7/8 上都稳定,社区资料多,遇到问题好搜。
2.2 数据库选 SQLite 还是 SQL Server,看并发量说话
SQLite 适合单机、低并发、演示环境。它的写操作是串行的,同一时间只能有一个写入者。如果你的商城系统只是自己测试或者几个人用,完全够。SQL Server Express 适合多用户、需要事务隔离和复杂查询的场景,但部署麻烦一些。
一个折中方案:开发阶段用 SQLite,生产环境换 SQL Server,EF Core 的 DbContext 配置改一下连接字符串就行。但要注意,SQLite 和 SQL Server 在自增主键、日期类型、字符串比较上有些差异,换库时最好跑一遍集成测试。
下面是一个 DbContext 的最小配置,用 SQLite:
// Program.cs 中注册 DbContext builder.Services.AddDbContext<ShopContext>(options => options.UseSqlite(builder.Configuration.GetConnectionString("Default"))); // appsettings.json 中的连接字符串 // "ConnectionStrings": { "Default": "Data Source=shop.db" }逻辑说明:AddDbContext 把 ShopContext 注册到依赖注入容器,UseSqlite 指定提供程序。连接字符串里的 Data Source 就是数据库文件路径,相对路径会生成在运行目录下。参数方面,如果你换成 SQL Server,把 UseSqlite 改成 UseSqlServer,连接字符串换成 Server=localhost;Database=Shop;Trusted_Connection=True; 即可。
2.3 项目目录怎么分,别把控制器写成流水账
我见过很多商城项目,所有逻辑都堆在 Controller 里,一个文件两千行,后面加个优惠券功能就崩了。推荐按职责分目录:Controllers 放接口,Services 放业务逻辑,Data 放 DbContext 和迁移,Models 放实体和 DTO,Repositories 可选。
实体设计上,至少要有 Product、Category、CartItem、Order、OrderItem、User 这几张表。Product 和 Category 是一对多,Order 和 OrderItem 是一对多,CartItem 关联 User 和 Product。User 表如果只是练手,可以先用一个简单的 Id 和用户名,不做完整认证。
目录分好之后,每个 Service 只做一件事。比如 CartService 只负责购物车的增删改查,OrderService 只负责下单和订单查询。这样后面加日志、加缓存、加事务都好下手。
3. 商品与购物车:用 EF Core 把增删改查写扎实
3.1 商品列表接口:分页、筛选、排序一个不能少
商品列表是商城最基础的接口,但很多人只写一个 GetAll,数据一多就卡。正确的做法是分页加筛选。下面是一个带分页和关键字搜索的查询:
public async Task<PagedResult<ProductDto>> GetProductsAsync( string? keyword, int page, int pageSize) { var query = _context.Products.AsQueryable(); // 关键字过滤,注意用 EF.Functions.Like 兼容不同数据库 if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(p => EF.Functions.Like(p.Name, $"%{keyword}%")); var total = await query.CountAsync(); // 排序放在分页之前,否则分页结果不稳定 var items = await query .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price, Stock = p.Stock }) .ToListAsync(); return new PagedResult<ProductDto>(items, total, page, pageSize); }逻辑说明:AsQueryable 保证后续的 Where 和 OrderBy 翻译成 SQL 而不是在内存里过滤。EF.Functions.Like 是 EF Core 提供的数据库无关的模糊查询方法。Skip 和 Take 实现分页,page 从 1 开始。参数方面,pageSize 建议限制在 20 到 100 之间,太大容易拖慢响应。total 用于前端计算总页数。
注意:如果排序字段不唯一,分页时可能出现重复或遗漏。稳妥做法是加一个次级排序,比如 ThenByDescending(p => p.Id)。
3.2 购物车存 Session 还是存数据库,我选后者
购物车数据放哪里,是新手最容易纠结的点。放 Session 简单,但重启服务就丢,而且多台服务器没法共享。放数据库多一张表,但可靠、可查询、可持久化。我一般会建一张 CartItem 表,字段包括 Id、UserId、ProductId、Quantity、CreatedAt。
添加购物车的逻辑要处理“已存在则累加”的情况:
public async Task AddToCartAsync(int userId, int productId, int quantity) { // 先查是否已有该商品 var existing = await _context.CartItems .FirstOrDefaultAsync(c => c.UserId == userId && c.ProductId == productId); if (existing != null) { existing.Quantity += quantity; } else { _context.CartItems.Add(new CartItem { UserId = userId, ProductId = productId, Quantity = quantity, CreatedAt = DateTime.UtcNow }); } await _context.SaveChangesAsync(); }逻辑说明:FirstOrDefaultAsync 查已有记录,有就累加数量,没有就新建。这里没有做库存校验,因为购物车阶段不锁库存,下单时才检查。参数方面,quantity 要做边界检查,比如不能小于 1,不能大于 100,防止恶意刷单。
3.3 购物车合并与清理:登录前后数据怎么接
如果用户未登录时加了购物车,登录后要把匿名购物车合并到用户购物车。常见做法是前端用 localStorage 存匿名购物车,登录后调一个合并接口,把商品逐条 AddToCart。合并时要注意数量累加,不要覆盖。
清理购物车一般在下单成功后执行,删除该用户所有 CartItem。也可以提供手动删除单个商品的接口。这里要注意并发:两个请求同时删同一条记录,EF Core 会抛 DbUpdateConcurrencyException,可以用乐观并发令牌或者直接捕获异常忽略。
4. 下单与库存:事务、并发和订单号生成
4.1 下单流程拆解:五步顺序不能乱
下单是整个商城系统最容易出 bug 的地方。我把它拆成五步:校验购物车非空、校验库存、扣减库存、创建订单和订单项、清空购物车。这五步必须在一个事务里,否则会出现“库存扣了订单没生成”或者“订单生成了库存没扣”的脏数据。
下面是一个简化的下单服务:
public async Task<OrderResult> PlaceOrderAsync(int userId) { using var transaction = await _context.Database.BeginTransactionAsync(); try { // 1. 取购物车 var cartItems = await _context.CartItems .Where(c => c.UserId == userId) .ToListAsync(); if (!cartItems.Any()) return OrderResult.Fail("购物车为空"); // 2. 校验库存并扣减 foreach (var item in cartItems) { var product = await _context.Products .FirstOrDefaultAsync(p => p.Id == item.ProductId); if (product == null || product.Stock < item.Quantity) return OrderResult.Fail($"商品 {item.ProductId} 库存不足"); product.Stock -= item.Quantity; } // 3. 创建订单 var order = new Order { UserId = userId, OrderNo = GenerateOrderNo(), TotalAmount = cartItems.Sum(c => c.Quantity * c.UnitPrice), Status = OrderStatus.Pending, CreatedAt = DateTime.UtcNow }; _context.Orders.Add(order); await _context.SaveChangesAsync(); // 4. 创建订单项 foreach (var item in cartItems) { _context.OrderItems.Add(new OrderItem { OrderId = order.Id, ProductId = item.ProductId, Quantity = item.Quantity, UnitPrice = item.UnitPrice }); } // 5. 清空购物车 _context.CartItems.RemoveRange(cartItems); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return OrderResult.Ok(order.OrderNo); } catch (Exception ex) { await transaction.RollbackAsync(); return OrderResult.Fail("下单失败:" + ex.Message); } }逻辑说明:BeginTransactionAsync 开启事务,所有 SaveChanges 都在事务内,最后 CommitAsync 提交。任何一步失败就 RollbackAsync。参数方面,OrderNo 用 GenerateOrderNo 生成,后面会讲。TotalAmount 从购物车项计算,不要信前端传的价格。
注意:SQLite 对事务的支持没问题,但并发写会锁库。如果压测时出现 database is locked,可以启用 WAL 模式,或者换 SQL Server。
4.2 库存扣减的并发坑:超卖是怎么发生的
超卖的经典场景:两个用户同时买最后一件商品,都查到库存为 1,都扣减成功,结果库存变成 -1。解决办法有三种:悲观锁、乐观锁、原子更新。
悲观锁用 SELECT ... FOR UPDATE,但 SQLite 不支持,SQL Server 可以。乐观锁加一个 RowVersion 字段,更新时检查版本号。原子更新最直接:UPDATE Products SET Stock = Stock - @qty WHERE Id = @id AND Stock >= @qty,然后看受影响行数是否为 1。
在 EF Core 里,可以用 ExecuteUpdate 实现原子扣减:
var affected = await _context.Products .Where(p => p.Id == productId && p.Stock >= quantity) .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - quantity)); if (affected == 0) return OrderResult.Fail("库存不足");逻辑说明:ExecuteUpdateAsync 直接生成 UPDATE 语句,不加载实体,避免并发覆盖。Where 条件里带 Stock >= quantity,保证不会扣成负数。参数方面,affected 返回受影响行数,0 表示库存不够或商品不存在。
4.3 订单号生成:别用时间戳,会重复
订单号要求唯一、可读、不要太长。常见做法是“日期 + 随机数 + 序列号”。纯时间戳在并发下会重复,纯 GUID 太长不好看。我一般用 yyyyMMddHHmmss + 4 位随机数 + 4 位用户 Id 后四位。
private static readonly Random _random = new(); private static string GenerateOrderNo() { var timestamp = DateTime.Now.ToString("yyyyMMddHHmmss"); var random = _random.Next(1000, 9999); return $"{timestamp}{random}"; }逻辑说明:时间戳精确到秒,随机数补足同一秒内的并发。如果并发量再高,可以加一个内存计数器或者用数据库序列。参数方面,Random 不是线程安全的,高并发下建议用 RandomNumberGenerator 或者加锁。
5. 避坑与排查:商城系统上线前必须过的五道坎
5.1 坑一:EF Core 迁移报错 “Unable to create database”
现象:第一次运行 Add-Migration 或 Update-Database 时提示无法创建数据库。原因通常是连接字符串路径不对,或者没有安装对应的数据库提供程序包。解决:检查 appsettings.json 里的 Data Source 是否指向可写目录,SQLite 需要 Microsoft.EntityFrameworkCore.Sqlite 包,SQL Server 需要 Microsoft.EntityFrameworkCore.SqlServer 包。如果是权限问题,把数据库文件放到用户目录下。
5.2 坑二:购物车数量累加后价格没更新
现象:用户改了购物车数量,总价还是旧的。原因:前端只更新了数量,没有重新计算总价,或者后端返回的 DTO 里没有包含最新单价。解决:每次修改数量后重新查询购物车并计算总价,或者在前端用单价乘以数量实时算。注意单价要从数据库取,不要用前端传的。
5.3 坑三:下单时库存扣了但订单没生成
现象:数据库里库存少了,但订单表没有记录。原因:事务没有正确回滚,或者 SaveChanges 分多次提交,中间失败。解决:把所有写操作放在同一个事务里,用 BeginTransactionAsync 和 CommitAsync 包住。如果用了多个 DbContext 实例,事务不共享,要确保用同一个实例。
5.4 坑四:并发下单导致订单号重复
现象:两个订单的 OrderNo 一模一样。原因:时间戳精确到秒,同一秒内多个请求生成了相同随机数。解决:把随机数范围扩大,或者用数据库唯一索引兜底,插入失败就重试。更稳妥的是用雪花算法或者数据库序列。
5.5 坑五:SQLite 并发写报 database is locked
现象:压测时出现 “database is locked” 异常。原因:SQLite 默认的 journal 模式在写时会锁整个库,多个写请求排队超时。解决:启用 WAL 模式,执行 PRAGMA journal_mode=WAL;,或者把连接字符串的 Cache 设为 Shared。如果并发量真的高,换 SQL Server 或 PostgreSQL。
6. 进阶技巧:用最小代价给商城加一个可验证的库存预警
前面五章把主链路跑通了,最后一章说一个我实际会用的技巧:库存预警。很多商城系统等到用户下单才发现库存不足,体验很差。我的做法是在商品表加一个 LowStockThreshold 字段,每次扣减库存后检查是否低于阈值,低于就写一条预警记录或者发通知。
先加字段和迁移:
dotnet ef migrations add AddLowStockThreshold dotnet ef database update然后在扣减库存的代码后面加检查:
// 扣减成功后检查预警 var product = await _context.Products.FindAsync(productId); if (product != null && product.Stock <= product.LowStockThreshold) { _context.StockAlerts.Add(new StockAlert { ProductId = productId, CurrentStock = product.Stock, CreatedAt = DateTime.UtcNow }); await _context.SaveChangesAsync(); }逻辑说明:FindAsync 按主键查商品,比 FirstOrDefault 快。LowStockThreshold 默认可以设 10,根据商品销量调整。StockAlert 表记录预警历史,方便后续查询。参数方面,阈值不要设太高,否则预警泛滥;也不要设太低,否则来不及补货。
验证方法:手动把某个商品的库存改成 5,阈值设 10,然后下一单,看 StockAlert 表是否新增记录。如果没新增,检查 SaveChanges 是否被事务包裹但没提交,或者阈值判断条件写反了。
这个技巧的价值在于:它不改变主链路,只是旁路记录,出问题也不影响下单。我一般会在预警表上加一个 IsHandled 字段,后台可以标记已处理。如果你想把预警做得更完整,可以加一个定时任务,每天扫描一次低库存商品,汇总发邮件。但那是另一个话题了。
血泪经验:别在扣库存的同一个事务里发邮件或调外部接口,一旦超时整个下单就回滚了。预警记录写库可以,外部通知一定要异步。我吃过这个亏,后来把所有通知都改成后台队列处理,下单接口的响应时间从 800ms 降到 120ms。
希望帮到你。
本文还有配套的精品资源,点击获取