☰
ASP.NET电商网站完整实践:架构设计、数据模型与订单并发控制
2026/10/23 13:34:08 网站建设 项目流程

简介:一份面向ASP.NET初学者的电子商务网站课程设计源码,适合正在完成期末作业或实训项目的计算机专业学生参考。项目基于ASP.NET Web Forms实现,涵盖商品展示、购物车、用户注册登录、订单处理等常见电商模块,页面与后台代码分离,并通过用户控件复用页面区块,整体结构较清晰。包内共59个文件,以aspx页面、cs后台代码、ascx用户控件、mdb数据库、css样式以及jpg/gif图片素材为主;aspx负责商品与订单界面,cs处理事件响应与数据操作,ascx用于复用导航或购物车区块,Access数据库存放商品、用户与订单记录,完整度较高,压缩包仅400KB。已有143人学习/下载,对缺乏成体系范例的学习者较有借鉴意义。通过分析这套项目可以接触购物车Session状态管理、ADO.NET读写Access数据库、Forms身份验证控制页面访问、用户控件减少重复开发等关键环节,能较快建立企业级Web表单应用的开发观念。

1. 用 ASP.NET 搭电子商务网站:一套能跑完整闭环的项目骨架

任何想快速验证电商业务流程的从业者,迟早都会遇到同一个问题:商品、购物车、订单、库存,哪个模块单独拆出来都不难,但要把它们串成一个能上线、能扛住并发、能对账的完整闭环,工作量远超预期。这套 ASP.NET 电子商务网站资源做的正是这件事——一个基于 MVC 5 + EF 6 + SQL Server 的完整站点,会员、商品、购物车、订单、后台管理全配齐,核心代码可以直接改改跑起来。适合两类人:刚转行做 .NET 开发的初级工程师,需要一份真实可读的项目源码当脚手架;以及业务侧的技术负责人,拿来当评审其他电商项目时的对照物和避坑清单。

2. 架构与数据模型:为什么先定表结构再写业务

2.1 分层设计:Controller 变薄,Service 扛业务

这类项目最容易翻车的地方,是把所有逻辑都堆在 Controller 里。一个常见的反面案例是“Post 一个订单,Controller 里同时做校验、扣库存、生成支付参数、发邮件”,等业务复杂到一定程度,这个方法就会膨胀到上千行,任何一次改动都要回归测试。

我一般会按标准的四层来拆:Web 层(Controller + ViewModel)、应用服务层(Service)、仓储层(Repository)、数据层(DbContext)。核心原则是 Controller 只负责参数绑定、模型校验和视图返回;Service 负责事务、业务规则和状态流转;Repository 只做数据访问的封装。这套资源里也是这么分的,你拿到项目后先看 Models 和 Services 两个目录,就能确认作者有没有把边界划清楚。

典型的项目结构:

/Controllers — MVC Controllers /Services — 业务逻辑层(订单服务、购物车服务、商品服务) /Repositories — 数据访问层(EF 查询封装) /Models — EF 实体 + ViewModel /Views — Razor 视图 /Infrastructure — 通用工具(加解密、分页、文件上传)

每个 Service 类只暴露一个聚合根的操作。例如订单服务只处理订单、订单明细和支付状态,不允许直接操作商品表;商品服务只处理商品、分类和库存。这样做带来的直接好处是,后续接手的人不用靠猜就能定位修改点。

2.2 核心表设计:五张主表再加三张辅助表

数据模型是整个项目的骨架,表结构设计不好,后面写业务全是补丁。这套资源里主表设计整理如下:

表名职责关键字段
User会员Id, UserName, PasswordHash, Phone, CreatedAt
Category商品分类Id, Name, ParentId, Sort
Product商品Id, CategoryId, Name, Price, Stock, Status
Order订单Id, UserId, OrderNo, TotalAmount, Status, CreatedAt
OrderItem订单明细Id, OrderId, ProductId, Quantity, UnitPrice
ShoppingCart购物车Id, UserId, ProductId, Quantity
PaymentRecord支付流水Id, OrderId, PayNo, Amount, Status, CallbackTime
StockLog库存流水Id, ProductId, ChangeType, Quantity, CreatedAt

这里几个设计点值得单独说。金额字段一律用 decimal(18,2),包括订单明细里的 UnitPrice 也要冗余存一份。因为商品价格后续可能调整,如果订单明细去实时关联商品价格表,历史订单金额就会跟着变,对账时全是错。Price、TotalAmount、UnitPrice 全部快照,这是电商订单表的基本纪律。

库存单独建 StockLog 流水表。很多人只在 Product 表上放一个 Stock 字段,扣库存时直接 update。一旦出现超卖或者手动调价,你没有审计依据,很难定位是哪笔操作把数据改坏。加一张流水表,每次扣减、回补都记录操作类型和数量,事后排查成本极低。

订单号用业务流水号而不是自增 Id。自增 Id 会暴露订单量,而且多系统交互时容易撞号。常见的做法是拼接:yyyyMMddHHmmss + 用户Id后四位 + 随机数,或者直接用日期时间加顺序号。订单号生成逻辑放在独立的工具类里,方便将来接入分布式 ID 时替换。

2.3 Code First 还是 Database First:我建议 Code First

ASP.NET 开发里,EF 的两种建模模式经常把人绕晕。Database First 先建库再生成实体,适合数据库已经存在、DBA 主导的存量项目;而电商这种从零起步的项目,用 Code First 能保持代码和表结构同步,改完实体跑一次迁移就行,不用维护两套定义。

这套资源用的是 EF 6 Code First。实体定义上,比较容易被忽略的是导航属性和外键的配置:

public class Order { public int Id { get; set; } public string OrderNo { get; set; } public int UserId { get; set; } public decimal TotalAmount { get; set; } public int Status { get; set; } public DateTime CreatedAt { get; set; } public virtual User User { get; set; } public virtual List<OrderItem> Items { get; set; } } public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public int ProductId { get; set; } public string ProductName { get; set; } // 快照字段,防商品改名影响历史订单 public int Quantity { get; set; } public decimal UnitPrice { get; set; } public virtual Order Order { get; set; } }

注意 OrderItem 里的ProductName,这就是快照字段。商品名称被后台改掉之后,历史订单展示的仍然是下单时的名称,对用户体验很重要。

DbContext 里用 Fluent API 配置关系:

public class AppDbContext : DbContext { public DbSet<User> Users { get; set; } public DbSet<Product> Products { get; set; } public DbSet<Order> Orders { get; set; } public DbSet<OrderItem> OrderItems { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Entity<Order>() .HasMany(o => o.Items) .WithRequired(i => i.Order) .HasForeignKey(i => i.OrderId); modelBuilder.Entity<OrderItem>() .Property(i => i.UnitPrice) .HasPrecision(18, 2); } }

HasPrecision(18, 2)是很容易漏掉的一步。EF 对 decimal 默认映射为decimal(18,2),但如果你在别的实体里配了更高精度,迁移时可能会把订单金额字段也错误生成成decimal(18,4)。金额字段统一显式指定精度,避免 SQL Server 端出现意外的四舍五入差异。

3. 商品与会员模块:图片上传、密码哈希和分页检索的落地写法

3.1 商品检索:分页、筛选、防 SQL 注入

商品列表是电商站点的门面,也是搜索流量进入后第一个请求的接口。不要用存储过程拼字符串做筛选,项目里直接用 EF 的 IQueryable 链式查询即可,它能帮你参数化 SQL,基本杜绝注入风险。分页逻辑建议封装成通用的 PagedResult:

public class PagedResult<T> { public List<T> Data { get; set; } public int TotalCount { get; set; } public int PageIndex { get; set; } public int PageSize { get; set; } } public async Task<PagedResult<ProductListItem>> GetProductsAsync( int categoryId, string keyword, decimal? minPrice, decimal? maxPrice, int pageIndex, int pageSize) { IQueryable<Product> query = _context.Products.Where(p => p.Status == 1); if (categoryId > 0) query = query.Where(p => p.CategoryId == categoryId); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(p => p.Name.Contains(keyword)); if (minPrice.HasValue) query = query.Where(p => p.Price >= minPrice.Value); if (maxPrice.HasValue) query = query.Where(p => p.Price <= maxPrice.Value); var totalCount = await query.CountAsync(); var data = await query .OrderByDescending(p => p.Id) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .Select(p => new ProductListItem { Id = p.Id, Name = p.Name, Price = p.Price, Stock = p.Stock, CoverImage = p.CoverImage }) .ToListAsync(); return new PagedResult<ProductListItem> { Data = data, TotalCount = totalCount, PageIndex = pageIndex, PageSize = pageSize }; }

参数说明:pageIndex从 1 开始,Skip((pageIndex - 1) * pageSize)这样前端传 1 时不会跳过第一条;keyword用Contains在 EF 里翻译成 LIKE,匹配时要注意 SQL Server 默认排序规则对中文大小写不敏感,但如果你给该字段加了区分重音的排序规则,全半角匹配会出现一些很隐蔽的“查不出来”问题,这种问题一旦出现很难查,建议启动时统一排序规则。

3.2 会员注册与登录:千万不要明文存密码

密码存储是最容易翻车的点。网上很多老项目源码用的是 MD5(password) 这种写法,实际上在撞库面前等于裸奔。正确做法是使用 .NET 自带的Rfc2898DeriveBytes做 PBKDF2 迭代哈希。

public static string HashPassword(string password, int iterations = 10000) { byte[] salt = new byte[16]; using (var rng = new RNGCryptoServiceProvider()) { rng.GetBytes(salt); } var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations); byte[] hash = pbkdf2.GetBytes(32); byte[] hashBytes = new byte[49]; Array.Copy(salt, 0, hashBytes, 0, 16); Array.Copy(hash, 0, hashBytes, 16, 32); return Convert.ToBase64String(hashBytes); }

这段代码把 16 字节的随机盐和 32 字节的哈希结果一起拼成了 49 字节的字符串。校验时拆出盐,用同样的迭代次数重新计算哈希再逐字节比对。迭代次数 10000 是当前常见的设置。注册时还要注意两个小坑:一是用户名要加唯一索引,否则并发注册会插入重复数据;二是不要把邮箱或手机号当主键,用户后续可能换绑,主键最好是自增 Id。

3.3 图片上传:扩展名校验只是个及格线

后台商品管理的图片上传,是安全漏洞重灾区。早年大量案例是攻击者上传一个包含恶意脚本的 .aspx 文件,然后直接访问得到执行权限。IIS 的请求过滤能挡住一部分,但自己写的上传代码仍然要注意几个点:

[HttpPost] public ActionResult UploadImage(HttpPostedFileBase file) { var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(ext)) return Json(new { code = 0, msg = "不支持的图片格式" }); if (file.ContentLength > 2 * 1024 * 1024) return Json(new { code = 0, msg = "图片不能超过2MB" }); // 服务端生成文件名,绝不使用用户上传的原始文件名 var fileName = Guid.NewGuid().ToString("N") + ext; var savePath = Path.Combine(Server.MapPath("~/Uploads/Images"), fileName); file.SaveAs(savePath); return Json(new { code = 1, url = "/Uploads/Images/" + fileName }); }

三个关键点:扩展名白名单、大小限制、服务端重命名。用 GUID 重命名文件,避免中文文件名和特殊字符在 URL 里出现编码问题。更重要的是,上传目录必须关闭脚本执行权限。IIS 里给 Uploads 文件夹单独加一个 web.config,通过 handler 映射禁止执行任何动态脚本,这一步比扩展名校验更能兜底。

4. 购物车与订单状态机:并发扣库存不超卖的完整闭环

4.1 购物车三种实现方式:Session、Cookie、数据库

购物车实现方式直接决定用户体验和服务器压力。纯 Session 的购物车适合单机小站点,用户一关浏览器购物车就丢,体验很差;纯 Cookie 的购物车可以持久化,但购物车里的商品数量、价格这些数据暴露在客户端,后台改价后容易对不上;数据库购物车最可靠,但每次加购都要写库,压力偏大。

实际项目中我倾向于混合方案:未登录用户购物车放 Cookie(只存商品 Id 列表和数量),登录后合并进数据库购物车表。这套资源里用的就是ShoppingCart表方案,登录用户每次加购直接落库。合并购物车要在登录成功后触发一次:

public async Task MergeCartAsync(int userId, List<CartCookieItem> cookieItems) { foreach (var item in cookieItems) { var dbItem = await _context.ShoppingCarts .FirstOrDefaultAsync(c => c.UserId == userId && c.ProductId == item.ProductId); if (dbItem != null) { dbItem.Quantity += item.Quantity; } else { _context.ShoppingCarts.Add(new ShoppingCart { UserId = userId, ProductId = item.ProductId, Quantity = item.Quantity }); } } await _context.SaveChangesAsync(); }

合并时要注意,Cookie 里的商品可能在服务端已下架或库存不足,合并前要重新校验商品状态和库存。只凭 Cookie 里的数量相加,很容易把失效商品带进新账户。

4.2 订单创建:事务、锁与防重复提交

订单创建是电商系统复杂度最集中的地方。一个订单要同时做三件事:生成订单头、生成订单明细、扣减库存。任何一个步骤失败,都不能留下半截数据,所以要用事务包住。扣库存还有并发问题,两个用户同时买最后一个商品,如果代码是“先查库存再改库存”,在并发场景下就可能超卖。更稳的做法是用原子 UPDATE 让数据库来把关:

public async Task<bool> DeductStockAsync(int productId, int quantity, string orderNo) { using (var transaction = _context.Database.BeginTransaction()) { try { // 悲观锁:先锁行,再判断库存 var rows = await _context.Database.ExecuteSqlCommandAsync( "UPDATE Product SET Stock = Stock - {0} WHERE Id = {1} AND Stock >= {0}", quantity, productId); if (rows == 0) { transaction.Rollback(); return false; // 库存不足 } _context.StockLogs.Add(new StockLog { ProductId = productId, ChangeType = "Order", Quantity = -quantity, OrderNo = orderNo, CreatedAt = DateTime.Now }); await _context.SaveChangesAsync(); // 这里可以接着创建 Order 和 OrderItem // ... transaction.Commit(); return true; } catch { transaction.Rollback(); throw; } } }

SQL 里用WHERE ... AND Stock >= {0}做条件判断,UPDATE 语句本身是原子操作,数据库行锁保证同一时刻只有一个事务能改这行,库存不足时受影响行数为 0,事务回滚,从根上避免了超卖。注意用参数化 SQL 而不是拼字符串,这类语句放在仓储层而不是 Service 里,保持数据访问逻辑集中。

4.3 订单状态机:五个状态之间的流转路径

订单状态不能用一张订单表上的一个 int 字段随便赋值,否则到了售后环节会乱成一锅粥。一般这样定义状态:

状态值名称允许的流转
0待支付→ 已取消 / 已支付
1已支付→ 已发货 / 已取消
2已发货→ 已完成 / 售后中
3已完成→ 售后中
4已取消终止
5售后中→ 已完成 / 已取消

状态流转建议收敛到一个服务方法里:

public async Task<bool> ChangeOrderStatusAsync(int orderId, int fromStatus, int toStatus, string reason) { var order = await _context.Orders.FindAsync(orderId); if (order == null || order.Status != fromStatus) return false; if (!IsValidTransition(fromStatus, toStatus)) throw new InvalidOperationException("非法订单状态流转"); order.Status = toStatus; if (!string.IsNullOrEmpty(reason)) order.StatusReason = reason; await _context.SaveChangesAsync(); return true; }

IsValidTransition里维护一张状态流转表。为什么要这么设计?因为后台操作人员点“发货”时,如果订单还在待支付状态,必须拦截;用户取消订单时,如果订单已发货,应该走售后流程而不是直接改成“已取消”。把这些规则集中到一个方法里,比散落在各个 Action 里的 if-else 好维护得多。

5. 常见问题排查:五条血泪经验与绕过路径

5.1 并发扣库存出现超卖:日志里看不出问题

现象:大促时同一个商品卖出去了 120 件,后台库存只剩 50 件,但订单里却有 120 个成功记录。

原因:扣库存的代码是 Read-Check-Update 模式,两个请求同时读到 Stock=10,都通过了if (Stock >= quantity)的检查,然后各自减 1,最终库存从 10 变成了 8,但卖出了 2 件。

解决:用本文第 4.2 节的原子 UPDATE 方式,让WHERE Stock >= quantity条件由数据库保证。如果不想动 SQL,也可以用UPDATE Product SET Stock = Stock - @quantity WHERE Id = @id AND Stock >= @quantity这个思路,总之不能先查后改。

5.2 Session 在负载均衡下随机丢失

现象:用户登录后把商品加进购物车,过几分钟再点购物车,发现是空的,用户直接被踢下线。

原因:部署了两台应用服务器,没有配置独立的 Session 存储。第一次请求落在 A 机器上写了 Session,第二次请求被负载均衡转发到 B 机器,B 机器上没有这份 Session,用户只能重新登录。

解决:要么用 IIS 的 SQL Server 会话状态模式,要么引入 Redis 做会话存储。如果项目用的 ASP.NET MVC 5,可以在 Web.config 里配置:

<sessionState mode="SQLServer" sqlConnectionString="Data Source=...;Initial Catalog=ASPState;User ID=...;Password=...;" allowCustomSqlDatabase="true" cookieless="false" timeout="60" />

要注意,ASPState 数据库需要单独初始化,不要和生产库混在一起。

5.3 金额四舍五入后对不上账

现象:订单金额显示正常,但到支付环节金额比订单表多了一分钱,对账不平。

原因:最初数据库里UnitPrice字段被设计成decimal(18,3),而商品价格是decimal(18,2),EF 映射时自动补零。但在调用支付网关时,金额格式化字符串没有指定小数位数,浮点数转字符串时默认把多余的位数带出去了。

解决:数据库字段和 C# 实体全部锁定decimal(18,2),并在所有涉及金额输出到外部系统的位置显式调用.ToString("0.00")。金额计算一律不做四舍五入,直接保留两位。

5.4 支付回调重复通知导致订单重复发货

现象:用户支付成功,第三方支付平台因为网络抖动,同一个支付结果回调了三次,后台订单状态被覆盖了三次,订单服务在下一次回调时又触发了一次发货动作。

原因:回调处理接口没有做幂等处理,每收到一次通知就无条件重跑一遍业务流程。

解决:回调入口处先查支付流水表,如果同一PayNo已经处理过,直接返回成功标记,不再执行业务逻辑。同时在PaymentRecord表给PayNo加唯一索引,数据库层面兜底。

public ActionResult PayCallback(string payNo, string orderNo, decimal amount) { var record = _context.PaymentRecords.FirstOrDefault(p => p.PayNo == payNo); if (record != null && record.Status == 1) { return Content("success"); // 幂等:重复回调直接返回 } // 正常处理支付结果 // ... }

5.5 上传图片显示 404 或者变成空页面

现象:后台传图成功后,前端图片地址打不开,或者直接提示错误页面。

原因:多半是 IIS 应用池对上传目录配置了脚本执行权限,某些请求被当作动态脚本处理;或者是上传目录的 NTFS 权限导致应用池标识没有读取权限。

解决:IIS 中在 Uploads 目录的“处理程序映射”里禁用所有脚本,只留静态内容;然后检查应用池标识是否有该目录的读取权限。另外文件名别用中文,做好重命名,能省不少编码问题带来的麻烦。

6. 上线前的验证:压测三个指标如何定位瓶颈

把资源里的代码跑通之后,不要急着上线,我一般会先做一轮压测。重点不是压单个接口,而是压整个购买链路:登录 → 浏览商品 → 加购 → 提交订单 → 支付回调。这一步能同时暴露数据库连接数、Session 存储、缓存命中率三个层面的问题。

压测时我会关注一个核心指标:商品详情页在 100 并发下的 QPS。如果数据层直接用 EF 查商品表,QPS 通常很低,因为每次请求都打数据库。优化办法是给商品详情加一层缓存,缓存失效时间控制在 10 分钟以内,配合后台商品编辑接口主动刷新缓存。购物车和订单接口不要缓存,它们实时性要求高且涉及账户数据。

另一个容易忽略的小检查:把所有 View 里的数据库查询拉出来,看有没有在 Razor 视图里直接调用context.Products.Where(...)的行为。视图里写查询意味着每次渲染都会重复查库,且很难被缓存框架覆盖。遇到这种情况我会把查询提前到 Service,通过 ViewModel 传给视图。

这套资源整体跑完,最让我有印象的不是某个炫技功能,而是它把状态机和库存流水这类“看不见的部分”做在了前面。从那以后我每次设计电商项目都强制走一遍:先画状态流转图,再指定库存扣减的并发方案,然后才动手写 Controller。这个顺序一旦颠倒,后期补的后悔药成本极高。希望这套项目的骨架和经验细节能帮到你,少踩几个我当年踩过的坑。

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

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

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

立即咨询