ASP.NET电商网站分层架构与库存订单并发控制
简介这是一份基于 ASP.NET 的电子商务网站示例项目面向正在学习 Web 开发、准备课程设计或期末作业的学生可用于理解商品展示、购物车、用户注册登录与订单处理等电商基础功能的实现思路。压缩包共 59 个文件约 400KB包含 12 个 cs 源码、7 个 aspx 页面、4 个 ascx 用户控件、4 个 mdb 数据库文件以及 css 样式、gif/jpg 图片和站点配置能够呈现页面结构、后台逻辑、数据存储与静态素材的组织关系。已有 143 人学习可作为小型 ASP.NET 电商站点的入门参考通过梳理母版页、用户控件、数据库连接、页面跳转与购物车状态等模块读者可较快把握学生项目的整体架构和功能划分。其价值在于用较小体量展示从产品浏览到下单流程的常见实现适合对照学习、拆解改造并迁移到自己的课程设计或练习项目中。1. 电子商务网站从“能下单”到“能交付”的第一道分水岭很多同学做 asp.net 电子商务网站第一反应是先把首页轮播、商品列表、购物车按钮堆出来结果做到支付环节发现订单表根本扛不住并发扣库存回头改数据库结构前端全部重写。这个项目的关键分水岭不在页面好不好看而在你一开始有没有把「商品—库存—订单—支付」这条主链路当成一个整体来设计。这套方案面向两类人一是要交电子商务网站毕设、课程设计需要一份结构完整、能答辩、能跑通的作品二是刚接手小型电商后台的从业者想找一套能直接改造成自己业务的 Asp.NET 分层骨架。下面按需求、设计、实现、验收一路讲透中间穿插我踩过的坑照着搭基本不会翻车。2. 需求与选型把电商主链路拆成四个可交付模块2.1 先定业务边界再谈技术栈电子商务网站这个词听着大落到一个能在学期内交付的项目上必须砍到最小闭环。我一般会把需求收敛成四件事用户能注册登录并维护收货地址能按分类浏览商品、搜索、看详情能把商品加入购物车并生成订单管理员能上架商品、改库存、处理订单状态。这四件事之外的优惠券、秒杀、多商户入驻、物流轨迹对接全部列为「可选扩展」写进文档但不进第一版代码。原因很实际——每多一个模块就多一套表、多一层权限判断、多一组答辩时要解释的逻辑而答辩老师真正会追问的往往是你订单状态怎么流转、库存超卖怎么防不是你有没有做积分商城。技术选型上asp.net 这条线有两个岔路口Web Forms 和 ASP.NET MVC / ASP.NET Core MVC。Web Forms 拖控件快但页面生命周期和 ViewState 会让分层变得很别扭后期想换前端几乎不可能MVC 模式职责清晰Controller 收请求、Model 承数据、View 只管渲染配合三层架构能把业务逻辑抽到 Service 层是电商这类业务规则多的系统更合适的选择。数据库统一用 SQL ServerORM 用 Entity Framework或 EF Core既省掉大量手写 SQL又能用 Code First 让表结构跟着实体类走改字段时不用两头对。前端不追求框架化Bootstrap 加少量 jQuery 做交互就够把精力留给后端主链路。这里有个容易被忽略的点需求文档里必须写清楚「非功能需求」。比如商品列表页首屏要求在普通配置机器上 2 秒内出结果订单提交接口要能承受 50 个并发不出现负库存密码必须加盐哈希存储。这些不是凑字数它们直接决定你后面要不要加缓存、要不要给库存字段加乐观锁、要不要单独抽一个加密工具类。很多方案写到验收阶段才发现没有可验证的指标只能靠「老师看着还行」过关这是很被动的。2.2 三层架构的职责划分与目录结构asp.net 电子商务网站最容易做歪的地方是把数据库查询直接写在 Controller 里一开始写着爽等到要加「下单后扣库存、写订单日志、发通知」这三步联动时代码就变成一坨。规范做法是经典三层加一个公共层各层只依赖下一层不允许跨层回调层职责典型内容禁止出现表现层Web接收请求、参数校验、渲染页面Controller、ViewModel、视图直接写 SQL、直接操作 DbContext业务层Service业务规则、事务编排、状态流转订单服务、库存服务、用户服务拼 HTML、读 Request数据访问层DAL/Repository实体映射、查询封装仓储类、DbContext、迁移文件业务判断如“库存不足则报错”公共层Common通用能力加密工具、分页封装、统一返回模型、日志依赖上层任何项目对应的解决方案目录通常是Eshop.Web、Eshop.Service、Eshop.Repository、Eshop.Model、Eshop.Common五个项目引用关系单向Web → Service → Repository → ModelCommon 被所有层引用但不引用别人。这个结构看着朴素好处是答辩时你能指着一张图说清楚每个请求经过哪几层也能解释为什么改一个支付逻辑不用动页面。提示分层不是为了好看是为了让「订单创建」这种跨表操作能在一个事务边界内完成。Controller 里写事务几乎必然出现部分成功部分失败的数据。依赖注入要在启动时统一注册别在类里 new 服务对象// Startup.cs / Program.cs 中统一注册生命周期按需选择 public void ConfigureServices(IServiceCollection services) { services.AddDbContextEshopContext(opt opt.UseSqlServer(Configuration.GetConnectionString(Default))); // 仓储与业务服务用 Scoped跟随单次请求 services.AddScopedIProductRepository, ProductRepository(); services.AddScopedIOrderRepository, OrderRepository(); services.AddScopedIOrderService, OrderService(); services.AddScopedICartService, CartService(); // 加密、配置读取这类无状态能力用 Singleton services.AddSingletonIPasswordHasher, Pbkdf2PasswordHasher(); services.AddControllersWithViews(); }AddDbContext默认注册为 Scoped意味着一次 HTTP 请求内共享同一个上下文事务才能覆盖同一批操作AddSingleton用于密码哈希器这类不持有请求状态的对象如果误把 DbContext 注册成 Singleton多请求并发时会直接抛「上下文已被释放」或数据串号这个坑后面还会专门讲。3. 数据库与领域模型设计六张核心表撑起整条交易链3.1 表结构与关键字段取舍电子商务网站的数据模型不需要一上来就做几十张表先把主链路六张表设计扎实用户表、分类表、商品表、购物车表、订单主表、订单明细表。地址可以并入用户表先做单地址也可以单独一张收货地址表支持多地址——我倾向后者因为订单里必须冗余保存下单时的收货信息快照不然用户改了地址历史订单就跟着变了这在验收时是硬伤。几个字段的取舍值得单独说。商品表要有Stock库存字段和RowVersion并发标记后者是 SQL Server 的rowversion类型EF 会用它做乐观并发控制订单主表要有OrderNo业务单号区别于自增主键对外展示、排查问题都用它和Status状态字段订单明细表必须冗余保存ProductName、UnitPrice下单时的快照因为商品可能改名改价甚至下架。金额统一用decimal(18,2)绝不用 float 或 double浮点误差在对账时是灾难。-- 订单主表状态机字段与业务单号是核心 CREATE TABLE Orders ( Id INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL UNIQUE, -- 对外单号如 202405211530xxxx UserId INT NOT NULL, TotalAmount DECIMAL(18,2) NOT NULL, -- 下单时应付总额 Status TINYINT NOT NULL DEFAULT 0,-- 0待付款 1已付款 2已发货 3已完成 4已取消 ReceiverName NVARCHAR(50) NOT NULL, -- 收货信息快照 ReceiverPhone VARCHAR(20) NOT NULL, ReceiverAddr NVARCHAR(200) NOT NULL, CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME(), PaidAt DATETIME2 NULL ); CREATE INDEX IX_Orders_UserId_CreatedAt ON Orders(UserId, CreatedAt DESC);索引这条容易被跳过UserId CreatedAt的联合索引是给「我的订单」列表页准备的按用户过滤再按时间倒序没有它数据量一大列表页就明显卡。状态字段用TINYINT而不是字符串既省空间又方便加约束但要在代码里定义枚举并写注释否则过两周自己都不记得 2 代表什么。3.2 用 EF Code First 管理表结构变更用 Entity Framework 的 Code First实体类就是表结构的唯一事实来源改字段时生成迁移脚本避免手工改库导致开发和答辩环境不一致。迁移文件要纳入版本管理答辩前在一台干净机器上从零执行一次Update-Database能跑通才算数。// 商品实体并发标记 软删除两个字段解决大部分后期需求 public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public int CategoryId { get; set; } public decimal Price { get; set; } public int Stock { get; set; } public bool IsDeleted { get; set; } // 软删除下架不等于物理删除 public DateTime CreatedAt { get; set; } [Timestamp] public byte[] RowVersion { get; set; } Array.Emptybyte(); // 乐观并发标记 }[Timestamp]标注的RowVersion由数据库自动维护每次更新自增。EF 在SaveChanges时会把它放进 WHERE 条件若影响行数为 0 就抛DbUpdateConcurrencyException这就是后面防超卖的底层机制。软删除字段则保证商品下架后历史订单明细仍能通过ProductId查到关联记录虽然明细里已经存了快照但后台统计仍会用到。4. 核心模块实现购物车、下单与库存并发控制4.1 购物车的存储位置与合并逻辑购物车放 Session 还是放数据库是电子商务网站课设里争论最多的问题之一。Session 实现快但用户换设备就丢且服务器重启后内存态消失数据库方案多一张表但支持登录后跨端同步也更符合真实业务。我的做法是双轨未登录用户用 Cookie 存一个购物车标识购物车明细落库并关联这个匿名标识用户登录时把匿名购物车合并到账号购物车同商品数量相加然后清掉匿名记录。合并逻辑必须放在 Service 层并且在一个事务内完成因为它涉及「查匿名车、查用户车、逐项合并、删匿名车」四步任何一步失败都可能留下脏数据。public async Task MergeCartAsync(string anonCartId, int userId) { await using var tx await _context.Database.BeginTransactionAsync(); try { var anonItems await _context.CartItems .Where(c c.CartKey anonCartId).ToListAsync(); var userItems await _context.CartItems .Where(c c.UserId userId).ToListAsync(); foreach (var item in anonItems) { var exist userItems.FirstOrDefault(u u.ProductId item.ProductId); if (exist ! null) exist.Quantity item.Quantity; // 同商品数量累加 else _context.CartItems.Add(new CartItem { UserId userId, ProductId item.ProductId, Quantity item.Quantity }); } _context.CartItems.RemoveRange(anonItems); // 清理匿名车 await _context.SaveChangesAsync(); await tx.CommitAsync(); } catch { await tx.RollbackAsync(); throw; } }参数anonCartId来自 Cookie必须是随机 GUID 而不是自增数字否则别人改个数字就能看到他人购物车。合并时数量累加要不设上限地加吗通常的做法是合并后再校验一次库存超过库存的在购物车页标红提示而不是静默截断——静默改数量是用户投诉高发点。4.2 下单接口一个事务里扣库存、建订单、清购物车下单是整个系统的心脏也是最容易翻车的地方。它要在一个事务里完成校验库存、扣减库存、生成订单主表、生成订单明细、清空已下单的购物车项。库存扣减必须用带条件的更新不能「先查再减」public async TaskOrderResult CreateOrderAsync(int userId, Listint cartItemIds) { await using var tx await _context.Database.BeginTransactionAsync(); var items await _context.CartItems.Include(c c.Product) .Where(c c.UserId userId cartItemIds.Contains(c.Id)).ToListAsync(); if (items.Count 0) return OrderResult.Fail(购物车为空); var order new Order { OrderNo GenerateOrderNo(), // 时间戳 随机位 UserId userId, Status OrderStatus.Pending, ReceiverName user.ReceiverName, // 快照 ReceiverPhone user.ReceiverPhone, ReceiverAddr user.ReceiverAddr }; foreach (var item in items) { // 原子扣减库存足够才更新影响行数为 0 即失败 var affected await _context.Database.ExecuteSqlInterpolatedAsync($ UPDATE Products SET Stock Stock - {item.Quantity} WHERE Id {item.ProductId} AND Stock {item.Quantity} AND IsDeleted 0); if (affected 0) return OrderResult.Fail($商品 {item.Product.Name} 库存不足); order.Items.Add(new OrderItem { ProductId item.ProductId, ProductName item.Product.Name, // 名称价格双快照 UnitPrice item.Product.Price, Quantity item.Quantity }); } order.TotalAmount order.Items.Sum(i i.UnitPrice * i.Quantity); _context.Orders.Add(order); _context.CartItems.RemoveRange(items); await _context.SaveChangesAsync(); await tx.CommitAsync(); return OrderResult.Ok(order.OrderNo); }关键在WHERE Id ... AND Stock N这个条件更新数据库层面保证「检查与扣减」不可分割即使两个请求同时进来也只有一个能把库存从 1 减到 0另一个影响行数为 0 直接回滚。这比在 C# 里if (product.Stock qty)再赋值安全得多后者是典型的先查后改中间的时间窗口足够并发请求插进来这就是超卖的来源。GenerateOrderNo一般用yyyyMMddHHmmss加 4 位随机数高并发下再加用户 ID 后几位降低碰撞概率并给OrderNo建唯一索引兜底重复就重试生成。4.3 订单状态机与支付回调的幂等处理订单状态不能随便改要定义成明确的状态机待付款 → 已付款 → 已发货 → 已完成任意时刻可转「已取消」但已完成和已取消是终态不允许再流转。所有状态变更走同一个 Service 方法内部校验当前状态是否允许目标状态避免出现「已取消的订单又被支付回调改成已付款」这类脏数据。支付回调是幂等性考验的重灾区。第三方支付会重复通知网络超时也会触发重发如果回调里直接「把订单置为已付款」重复通知就会导致重复发货或者重复加积分。标准做法是回调入口先按订单号加行锁或状态条件更新// 只有仍处于「待付款」的订单才允许被回调改成已付款 var affected await _context.Database.ExecuteSqlInterpolatedAsync($ UPDATE Orders SET Status 1, PaidAt SYSDATETIME() WHERE OrderNo {orderNo} AND Status 0); if (affected 1) { /* 首次处理发货、发通知 */ } else { /* 重复回调直接返回成功不做任何副作用 */ }这段逻辑看起来只有几行却是答辩时最能体现工程思维的细节。很多同学的支付模块是用假的按钮点一下改状态老师一问「回调重复怎么办」就答不上来。把幂等写进代码并能在文档里说清楚含金量比多做两个页面高得多。5. 避坑与排查电商项目里最容易翻车的五个点5.1 库存超卖现象是卖出了负库存现象压测或演示时两个账号同时下最后一单两个订单都成功库存变成 -1。原因几乎都是「先查询库存再更新」的两步操作中间存在并发窗口。解决办法是把判断合并进 UPDATE 的 WHERE 条件里用影响行数判断成败全程在一个事务内同时给商品表加RowVersion乐观锁兜底捕获DbUpdateConcurrencyException后向用户返回「手慢了商品已被抢完」。要验证有效可以写个简单脚本用两个线程同时请求下单接口看库存是否严格不为负。5.2 DbContext 生命周期用错报「上下文已被释放」现象页面偶发 500异常信息是Cannot access a disposed context instance刷新又好了。原因通常是把 DbContext 注册成了单例或者用了using手动创建后跨方法传递导致一个请求还没处理完上下文就被释放。解决坚持用依赖注入拿EshopContext生命周期保持 Scoped异步方法里用await using管理事务而不是上下文仓储层不缓存 DbContext 到静态字段。这类问题最坑的是本地不复现、演示时爆炸属于典型的玄学故障只能靠规范规避。5.3 价格用浮点对账时差几分钱现象购物车合计和订单总额差 0.01或者优惠计算后金额对不上。原因是用float/double存金额二进制浮点无法精确表示 0.1 这类十进制小数。解决数据库用DECIMAL(18,2)C# 用decimal所有中间计算保持 decimal最后一步再按业务规则四舍五入用Math.Round(x, 2, MidpointRounding.AwayFromZero)而不是默认的银行家舍入。这个坑不解决后面做报表统计会一直对不上账。5.4 登录态与权限只在前端校验现象普通用户手动输入/Admin/Product/Edit就能进后台改商品。原因是只在菜单里隐藏了入口服务端没做角色校验。解决所有后台 Controller 加[Authorize(Roles Admin)]敏感操作在 Service 层再校验一次当前用户 ID 与资源归属同时把权限判断抽成统一过滤器别每个 Action 里手写 if。验收时用普通账号直接敲后台 URL能拦住才算过关。5.5 迁移与环境不一致换台机器就跑不起来现象本机演示正常换到答辩教室的电脑上建库失败或字段缺失。原因是没有用迁移管理表结构而是手工在数据库里加字段且初始化数据分类、管理员账号没脚本化。解决全部表结构走 EF 迁移准备一个SeedData初始化方法在启动时幂等执行交付前在干净环境完整跑一遍「建库—迁移—初始化—下单」全流程跑不通就不算完成。注意这五个点建议在开发中期就逐个自查一遍别等答辩前一天集中排查那时候改一处往往牵动三处。6. 验收与答辩用一条可复现的检查清单自证电子商务网站的验收最忌讳的是当场演示时靠运气。我现在习惯在交付前跑一张固定清单把「能演示」变成「能证明」。这张清单按主链路顺序走每一项都要求有可见的结果或日志而不是口头说做了。检查项操作方式通过标准环境可重建干净机器执行迁移与初始化脚本数据库表齐全分类和管理员账号自动生成主链路闭环注册→登录→加购→下单→支付回调→状态变更订单状态从待付款变为已付款库存同步扣减并发不超卖两线程同时下最后一件商品一单成功一单失败库存为 0 不为负回调幂等同一支付单号重复调用回调两次第二次直接返回成功不产生重复副作用权限隔离普通账号直接访问后台地址跳转登录或无权限页接口返回 403数据快照下单后修改商品价格与名称历史订单明细仍显示下单时价格名称演示流程建议提前写成脚本化的步骤控制在 8 分钟内先用管理员账号上架一件库存为 1 的商品再用两个浏览器窗口同时下单现场展示一成一败这一下就把并发控制讲明白了接着展示订单详情里的价格快照再改商品价格刷新订单页价格不变。这两个动作比讲十分钟架构图更有说服力。答辩被问到的概率最高的三个问题提前准备好答案为什么用三层架构而不是把逻辑写在 Controller 里库存并发是怎么防的为什么条件更新比先查后改可靠订单状态有哪些非法流转怎么拦截。回答时尽量落到代码位置和表字段上别停留在概念层面。如果老师追问扩展性可以讲清楚当前设计的边界——单库单表、支付是模拟回调、没有做分布式事务这些是已知取舍而不是遗漏坦诚说明比硬编一个「高并发架构」更稳。从那以后我每次带人做这类项目都会强制在中期跑一遍并发下单和重复回调这两个测试因为这两处一旦翻车修起来要动表结构和事务边界代价远大于早期发现。把清单贴在项目根目录的 README 里每次提交前过一遍答辩时心里就有底。希望帮到你。本文还有配套的精品资源点击获取