C#网上商城购物系统实战:从能跑通到能上线的核心设计
发布时间:2026/10/13 22:39:37 锦皓数字建站

简介这是一套面向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.Emptybyte(); // 乐观锁版本 }这段代码里最关键的是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 DictionaryOrderStatus, 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 DbSetProduct Products SetProduct(); public DbSetSku Skus SetSku(); public DbSetInventory Inventories SetInventory(); public DbSetOrder Orders SetOrder(); protected override void OnModelCreating(ModelBuilder mb) { mb.EntityInventory(e { e.HasKey(x x.SkuId); // 把 RowVersion 配成并发令牌SaveChanges 时自动带上版本条件 e.Property(x x.RowVersion).IsRowVersion(); }); mb.EntitySku() .HasOneProduct() .WithMany() .HasForeignKey(x x.ProductId); } }IsRowVersion()这一行是重点。配置之后EF Core 在更新 Inventory 时会自动生成WHERE SkuIdid AND RowVersionold这样的条件如果版本对不上就抛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 Taskbool 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 TaskOrder CreateOrderAsync(long userId, ListCartItem items) { // 1. 服务端重算金额绝不信任前端 decimal total 0m; var orderItems new ListOrderItem(); 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 - qtyAvailable不动。这一步和超时归还是一对反向操作二者只能发生一个靠订单状态机保证。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出现负值或者同一件商品卖出超过库存的数量。原因扣库存时先读后写中间没有并发保护两个请求都读到Available1各自减一结果变成 -1。解决用乐观锁RowVersion或数据库层的UPDATE ... SET AvailableAvailable-q WHERE Availableq原子操作。前者适合 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% 的线上事故。商城系统没有银弹把状态机和幂等这两件事做扎实就已经超过大多数练手项目了。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。