简介:这是一套面向C#初学者与.NET课程设计者的网上商城购物系统源码,基于ASP.NET Web Forms开发,适合用于毕业设计、课程实训或二次开发练手。系统涵盖商品浏览、购物车、订单管理与后台维护等核心模块,界面友好并支持打印,强调易维护与运行稳定。压缩包共205个文件,约1.51MB,其中43个cs文件承载业务逻辑,30个aspx页面与1个master母版构成前端结构,6个ascx用户控件复用导航、购物车等公共模块,另有71个gif与28个jpg提供界面素材,12个db及mdf、ldf文件为数据库支撑,并附doc说明与sln解决方案。已有676人学习下载,读者可借此理清Web Forms分层组织方式、控件复用思路与订单流程实现,快速搭建可运行的商城原型并在此基础上扩展功能。
1. 网上商城购物系统(C#版):为什么“能跑通”和“能上线”是两回事
很多开发者第一次接触网上商城购物系统(C#版),都是从一个能跑起来的 Demo 开始的:商品列表能显示、购物车能加加减减、下单按钮点下去数据库里多一条记录,于是觉得“商城不过如此”。但真正把它放到真实业务里跑一周,问题就全冒出来了——库存扣成负数、订单金额和支付金额对不上、并发下单同一件商品卖出十几份。这些不是玄学,是电商系统绕不开的一致性、并发和状态机问题。
这篇笔记面向两类人:一是正在用 C# 做课程设计或练手项目、想把商城从“能演示”推到“能扛住基本业务”的开发者;二是已经写过增删改查、但对订单、库存、支付这条链路心里没底的工程师。我会按“先立住模型、再动手实现、最后讲坑”的顺序,把网上商城购物系统(C#版)从技术选型到关键代码讲透,让你看完能自己复现一套结构清晰、边界明确的商城骨架,而不是又一个跑完就扔的 Demo。
2. 先把领域模型立住:商品、库存、订单到底谁管谁
在写第一行 C# 代码之前,最该花时间的是把领域模型想清楚。商城系统的复杂度不在界面,而在“一件商品从被浏览到被签收”这条链路上,各个对象之间的职责边界。模型立错了,后面加多少层 Service 都是在补窟窿。
2.1 商品、SKU、库存为什么必须拆成三层
新手最容易犯的错,是把“商品”当成一个类搞定所有事:名称、价格、库存数量全塞进去。结果一上规格就崩了——同一款手机有 128G 和 256G,价格不同、库存独立,你总不能建两条商品记录吧。常见做法是拆成三层:
- Product(商品/SPU):描述“这是什么”,比如“某型号手机”,管标题、描述、品牌、类目。
- Sku(库存单元):描述“具体哪个可卖版本”,管规格组合、销售价、对应的库存。
- Inventory(库存):单独一张表管可售数量、锁定数量,和 Sku 一对一但独立更新。
拆开的核心原因是更新频率和并发压力完全不同。商品描述一天改不了几次,库存却可能每秒被扣几十次。把高频更新的库存和低频的商品信息放一张表,行锁会互相拖累。我一般会让 Inventory 独立成表,只保留 SkuId、Available、Locked 三个核心字段,扣减时只锁这一行。
// 领域模型:三层拆分,职责单一 public class Product { public long Id { get; set; } public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public long CategoryId { get; set; } // 注意:这里不放价格和库存,价格属于 Sku,库存属于 Inventory } public class Sku { public long Id { get; set; } public long ProductId { get; set; } public string SpecJson { get; set; } = "{}"; // 如 {"颜色":"黑","容量":"256G"} public decimal SalePrice { get; set; } } public class Inventory { public long SkuId { get; set; } public int Available { get; set; } // 可售 public int Locked { get; set; } // 已锁定未支付 public byte[] RowVersion { get; set; } = Array.Empty<byte>(); // 乐观锁版本 }这段代码里最关键的是Inventory上的RowVersion。它是并发控制的抓手,后面扣库存时会用到。SpecJson用 JSON 存规格,是为了避免为每种类目建一堆规格表——练手项目够用,正式项目可以再拆规格维度表。
2.2 订单状态机:别用一堆 bool 字段描述订单
第二个必须提前定的是订单状态。我见过太多项目用IsPaid、IsShipped、IsCanceled三个 bool 描述订单,结果出现“已支付且已取消”这种自相矛盾的数据。正确做法是用一个枚举表达状态机,状态之间的流转只能走指定路径。
public enum OrderStatus { Pending = 0, // 待支付 Paid = 1, // 已支付 Shipped = 2, // 已发货 Completed = 3, // 已完成 Canceled = 4, // 已取消 Refunding = 5, // 退款中 Refunded = 6 // 已退款 } // 合法流转表:只允许这些迁移,其他一律拒绝 public static class OrderStateMachine { private static readonly Dictionary<OrderStatus, OrderStatus[]> Allowed = new() { [OrderStatus.Pending] = new[] { OrderStatus.Paid, OrderStatus.Canceled }, [OrderStatus.Paid] = new[] { OrderStatus.Shipped, OrderStatus.Refunding }, [OrderStatus.Shipped] = new[] { OrderStatus.Completed }, [OrderStatus.Refunding] = new[] { OrderStatus.Refunded }, }; public static bool CanTransit(OrderStatus from, OrderStatus to) => Allowed.TryGetValue(from, out var targets) && targets.Contains(to); }把流转规则集中到一张表里,好处是任何地方想改状态都得先过CanTransit,杜绝了“随手把状态改成已完成”的野路子。参数上注意:Pending只能去Paid或Canceled,意味着未支付订单不能直接发货;Paid能去Shipped或Refunding,覆盖了发货和退款两条路。这套状态机是后面所有订单操作的护栏。
2.3 用 EF Core 把模型映射到数据库
模型定好后,用 EF Core 做映射。选 EF Core 而不是裸 ADO.NET,是因为练手和中小项目里,它的迁移、关系映射、乐观锁支持能省大量胶水代码。但要注意:高并发扣库存这种场景,EF Core 的默认行为不够,得手动控制并发令牌。
public class ShopDbContext : DbContext { public DbSet<Product> Products => Set<Product>(); public DbSet<Sku> Skus => Set<Sku>(); public DbSet<Inventory> Inventories => Set<Inventory>(); public DbSet<Order> Orders => Set<Order>(); protected override void OnModelCreating(ModelBuilder mb) { mb.Entity<Inventory>(e => { e.HasKey(x => x.SkuId); // 把 RowVersion 配成并发令牌,SaveChanges 时自动带上版本条件 e.Property(x => x.RowVersion).IsRowVersion(); }); mb.Entity<Sku>() .HasOne<Product>() .WithMany() .HasForeignKey(x => x.ProductId); } }IsRowVersion()这一行是重点。配置之后,EF Core 在更新 Inventory 时会自动生成WHERE SkuId=@id AND RowVersion=@old这样的条件,如果版本对不上就抛DbUpdateConcurrencyException。这就是乐观锁的落地方式,比手写 SQL 省心,也比悲观锁更适合读多写少的商城场景。
3. 下单链路的最小可用实现:从购物车到订单落库
模型立住之后,进入真正的核心链路:用户点“提交订单”那一刻,系统要做什么。这条链路是商城最容易翻车的地方,因为它同时涉及库存扣减、订单创建、金额计算三件事,任何一件没处理好都会产生脏数据。
3.1 购物车该放内存还是放数据库
先说购物车。很多教程把购物车放 Session 或内存,理由是“购物车是临时的”。这个判断在单机练手时没错,但只要涉及多实例部署或用户换设备,购物车就丢了。我的建议是:登录用户的购物车落库,游客购物车放 Cookie 或本地存储,登录时合并。
public class CartItem { public long Id { get; set; } public long UserId { get; set; } public long SkuId { get; set; } public int Quantity { get; set; } public DateTime UpdatedAt { get; set; } } // 加入购物车:存在则累加,不存在则插入 public async Task AddToCartAsync(long userId, long skuId, int qty) { if (qty <= 0) throw new ArgumentException("数量必须为正"); var item = await _db.CartItems .FirstOrDefaultAsync(x => x.UserId == userId && x.SkuId == skuId); if (item == null) { _db.CartItems.Add(new CartItem { UserId = userId, SkuId = skuId, Quantity = qty, UpdatedAt = DateTime.UtcNow }); } else { item.Quantity += qty; item.UpdatedAt = DateTime.UtcNow; } await _db.SaveChangesAsync(); }参数上注意qty <= 0的校验,别小看它——前端传负数导致购物车数量变负、进而算出负金额的案例我见过不止一次。购物车落库后,下单时直接从库里读,金额以服务端重算为准,绝不信任前端传来的总价。
3.2 扣库存:乐观锁 + 重试是练手项目的最优解
下单最核心的一步是扣库存。这里有三条路:悲观锁(SELECT ... FOR UPDATE)、乐观锁(版本号)、分布式锁。练手和中小项目我强烈推荐乐观锁 + 有限重试,因为它不占数据库连接、不引入额外中间件,冲突时重试即可。
public async Task<bool> DeductStockAsync(long skuId, int qty, int maxRetry = 3) { for (int i = 0; i < maxRetry; i++) { var inv = await _db.Inventories.FirstOrDefaultAsync(x => x.SkuId == skuId); if (inv == null) throw new InvalidOperationException("库存记录不存在"); if (inv.Available < qty) return false; // 库存不足,直接失败 inv.Available -= qty; inv.Locked += qty; // 锁定,等支付成功后真正扣减 try { await _db.SaveChangesAsync(); return true; } catch (DbUpdateConcurrencyException) { // 版本冲突,说明有人抢先改了,重新读一遍再试 foreach (var entry in _db.ChangeTracker.Entries()) await entry.ReloadAsync(); } } return false; // 重试耗尽,视为失败 }逻辑说明:每次循环重新读库存,判断够不够,够就改Available和Locked,然后 SaveChanges。如果并发冲突,EF Core 抛异常,我们ReloadAsync把实体刷新成数据库最新值再重试。maxRetry = 3是经验值,再多说明热点太集中,该考虑分库或队列削峰了。注意这里扣的是Available、加的是Locked,真正的Available减少发生在支付成功后的确认环节,这样未支付订单超时取消时能把Locked还回去。
3.3 创建订单:金额服务端重算,订单号别用自增
库存扣成功后创建订单。两个铁律:金额必须服务端根据 Sku 当前价重算,订单号不能用数据库自增主键(会暴露单量、分库也麻烦)。订单号用“时间戳 + 用户ID后四位 + 随机数”这类方案即可。
public async Task<Order> CreateOrderAsync(long userId, List<CartItem> items) { // 1. 服务端重算金额,绝不信任前端 decimal total = 0m; var orderItems = new List<OrderItem>(); foreach (var it in items) { var sku = await _db.Skus.FindAsync(it.SkuId) ?? throw new InvalidOperationException("SKU 不存在"); total += sku.SalePrice * it.Quantity; orderItems.Add(new OrderItem { SkuId = it.SkuId, Quantity = it.Quantity, UnitPrice = sku.SalePrice }); } // 2. 生成订单号:时间戳 + 用户尾号 + 随机,避免自增暴露单量 var orderNo = $"{DateTime.UtcNow:yyyyMMddHHmmss}{userId % 10000:D4}{Random.Shared.Next(1000, 9999)}"; var order = new Order { OrderNo = orderNo, UserId = userId, TotalAmount = total, Status = OrderStatus.Pending, CreatedAt = DateTime.UtcNow, Items = orderItems }; _db.Orders.Add(order); await _db.SaveChangesAsync(); return order; }参数说明:UnitPrice在下单时快照进 OrderItem,这样以后商品调价不影响历史订单金额,这是对账的基础。Status初始为Pending,配合前面的状态机,只有支付回调才能把它推到Paid。整个下单流程建议包在一个数据库事务里:扣库存、建订单、清购物车要么全成要么全败,别让用户遇到“库存扣了订单没建”的黑匣子状态。
4. 支付回调与订单状态推进:别让回调把你带沟里
订单建好只是开始,真正决定系统可靠性的是支付回调这一段。第三方支付的回调有几个特点:可能重复、可能乱序、可能延迟。处理不好,就会出现重复发货、金额对不上、订单卡在待支付。
4.1 回调必须幂等:用订单号 + 支付流水号做唯一约束
支付平台重复推送回调是常态,不是异常。所以回调处理的第一原则是幂等:同一个支付流水号,处理一次和处理十次结果必须一样。落地方式是在支付记录表上对PayTradeNo建唯一索引,插入冲突就说明处理过了,直接返回成功。
public async Task HandlePaymentCallbackAsync(string orderNo, string tradeNo, decimal paidAmount) { // 幂等:先尝试插入支付流水,唯一索引冲突即已处理 var exists = await _db.Payments.AnyAsync(x => x.TradeNo == tradeNo); if (exists) return; // 已处理,直接返回,避免重复推进状态 var order = await _db.Orders.FirstOrDefaultAsync(x => x.OrderNo == orderNo) ?? throw new InvalidOperationException("订单不存在"); // 金额校验:回调金额必须和订单金额一致 if (order.TotalAmount != paidAmount) throw new InvalidOperationException("支付金额与订单金额不符"); if (!OrderStateMachine.CanTransit(order.Status, OrderStatus.Paid)) return; // 状态不允许,比如已取消订单收到回调,忽略 order.Status = OrderStatus.Paid; order.PaidAt = DateTime.UtcNow; _db.Payments.Add(new Payment { TradeNo = tradeNo, OrderNo = orderNo, Amount = paidAmount }); await _db.SaveChangesAsync(); }这段代码里三个校验缺一不可:幂等检查防重复、金额校验防篡改、状态机校验防乱序。特别是金额校验,曾经有项目因为没校验,被构造的低金额回调骗过,血泪经验。CanTransit那一步则挡住了“已取消订单又被支付成功”的尴尬。
4.2 超时未支付:定时任务把 Locked 库存还回去
用户下单后不付款,库存一直被Locked占着,别人买不了。必须有超时取消机制。常见做法是后台定时任务,每分钟扫一次超过 15 分钟仍是Pending的订单,取消并归还库存。
// 后台服务:每分钟执行一次 public async Task CancelExpiredOrdersAsync() { var deadline = DateTime.UtcNow.AddMinutes(-15); var expired = await _db.Orders .Where(x => x.Status == OrderStatus.Pending && x.CreatedAt < deadline) .Take(200) // 分批,避免一次锁太多行 .ToListAsync(); foreach (var order in expired) { if (!OrderStateMachine.CanTransit(order.Status, OrderStatus.Canceled)) continue; order.Status = OrderStatus.Canceled; order.CanceledAt = DateTime.UtcNow; // 归还库存:Locked 减回去,Available 加回来 foreach (var item in order.Items) { var inv = await _db.Inventories.FirstAsync(x => x.SkuId == item.SkuId); inv.Locked -= item.Quantity; inv.Available += item.Quantity; } } await _db.SaveChangesAsync(); }参数上Take(200)是分批保护,避免一次加载上万订单把内存和事务撑爆。15 分钟是行业常见值,可以按业务调。注意归还库存时改的是Locked和Available,和扣减时正好相反,这样库存账目始终守恒:Available + Locked在未支付阶段不变,支付成功后Locked才真正减少。
4.3 支付成功后确认扣减:把 Locked 真正消耗掉
支付回调把订单推到Paid之后,还要做一件事:把之前锁定的库存真正扣掉,即Locked -= qty,Available不动。这一步和超时归还是一对反向操作,二者只能发生一个,靠订单状态机保证。
public async Task ConfirmStockAsync(Order order) { foreach (var item in order.Items) { var inv = await _db.Inventories.FirstAsync(x => x.SkuId == item.SkuId); if (inv.Locked < item.Quantity) throw new InvalidOperationException("锁定库存异常,需人工核查"); inv.Locked -= item.Quantity; // 真正消耗,Available 已在锁定时扣过 } await _db.SaveChangesAsync(); }这里的if (inv.Locked < item.Quantity)是一道防线。正常流程下 Locked 一定够,如果不够说明数据被别处改乱了,此时抛异常让人工介入,比默默扣成负数强。整个库存模型就三态:Available(可卖)、Locked(锁定)、已售出(不在表里),任何时刻三者关系清晰可查。
5. 避坑与排查:商城系统最容易翻车的 5 个地方
前面讲的是“怎么做对”,这一章讲“哪里会做错”。下面 5 条都是我在实际项目里踩过或见别人踩过的,按“现象 → 原因 → 解决”写清楚。
5.1 库存扣成负数
现象:并发下单后,Available出现负值,或者同一件商品卖出超过库存的数量。原因:扣库存时先读后写,中间没有并发保护,两个请求都读到Available=1,各自减一,结果变成 -1。解决:用乐观锁(RowVersion)或数据库层的UPDATE ... SET Available=Available-@q WHERE Available>=@q原子操作。前者适合 EF Core,后者适合裸 SQL。关键是判断和扣减必须在同一条带条件的语句里完成,不能分两步。
5.2 订单金额和支付金额对不上
现象:用户实付 99 元,订单显示 100 元,或者对账时总差几分钱。原因:金额用了double或float,浮点误差累积;或者下单时信任了前端传来的总价。解决:金额一律用decimal,数据库用DECIMAL(18,2)。下单和回调都以服务端重算为准,前端传的金额只做展示参考。涉及分摊、优惠券时,明确舍入规则并统一。
5.3 支付回调重复导致重复发货
现象:用户收到两份货,或者订单被推进了两次状态。原因:回调没有幂等处理,支付平台重推一次就处理一次。解决:支付流水号建唯一索引,处理前先查或靠插入冲突拦截。状态推进前用状态机校验,Paid状态不能再被推到Paid。
5.4 超时订单没取消,库存被永久占用
现象:商品明明有货却显示无货,查库存发现全在Locked里。原因:只做了下单锁定,没做超时归还,或者定时任务挂了没人发现。解决:定时任务取消超时订单并归还库存,同时给任务加监控和告警。任务本身要幂等,重复执行不能把库存加两次。
5.5 EF Core 跟踪导致更新了不该更新的字段
现象:只想改库存,结果商品描述也被覆盖成了旧值。原因:同一个 DbContext 里实体被跟踪,SaveChanges 时把所有改动过的字段都提交了。解决:读多写少的场景用AsNoTracking()查询;更新时明确只改需要的字段,或者用ExecuteUpdate直接下 SQL。别在一个长生命周期的 DbContext 里攒一堆实体。
6. 进阶:把商城从“能跑”推到“能扛”的几个具体技巧
前面五章把网上商城购物系统(C#版)的主干讲完了,最后这一章聊几个让系统更稳的进阶技巧,都是能直接落地的。
第一,给热点商品做库存分桶。秒杀场景下,同一行库存被几千个请求争抢,乐观锁重试会疯狂失败。做法是把一个 Sku 的库存拆成 N 份(比如 10 份),请求按用户 ID 哈希打到不同桶,各自扣各自的,冲突概率降到 1/N。代价是可能出现“总量还有但某个桶空了”,需要兜底逻辑从其他桶借。
第二,订单表按时间分表。订单是只增不改的典型,单表过千万后查询会明显变慢。按CreatedAt按月分表,历史订单归档到冷表,主表只留近期数据。EF Core 可以用ToTable动态指定表名,或者干脆在数据访问层手写路由。
第三,把金额计算抽成独立的领域服务。优惠券、满减、积分抵扣这些规则会不断变,别把它们散落在下单代码里。抽一个PricingService,输入商品明细和用户信息,输出最终金额和优惠明细。这样规则变了只改一处,也方便写单元测试。
第四,给关键操作加审计日志。库存变更、订单状态流转、支付回调,都记一条带时间戳和操作来源的日志。出问题时这是唯一的后悔药,能快速定位是哪一步把数据改坏的。
第五,压测要压真实链路。别只压商品列表接口,那没意义。用工具模拟“加购 → 下单 → 支付回调”的完整链路,重点看库存扣减的失败率和订单创建的成功率。我一般会盯着两个指标:库存冲突重试次数、订单状态机拒绝次数,前者高说明热点集中,后者高说明有乱序或重复请求。
最后说个我自己的习惯:每次改完下单或支付相关的代码,我都会手动构造三种异常——重复回调、金额不符、超时取消——跑一遍看系统反应。这三个场景覆盖了 80% 的线上事故。商城系统没有银弹,把状态机和幂等这两件事做扎实,就已经超过大多数练手项目了。希望帮到你。
本文还有配套的精品资源,点击获取