简介:基于微软.NET框架的网上商城完整源码,是一份适合兼具ASP.NET Web开发基础的学习者与电商系统二次开发人员研读的工程实践。项目以VB.NET为后台语言,围绕商品展示、在线购物、订单处理等业务组织代码,并带有文章管理、折扣推荐、常见问题等常见电商模块,能帮助读者快速理解一个中型Web应用的分层与复用方式。压缩包内共151个文件,其中包含16个aspx页面、13个ascx用户控件、33个vb代码文件、30个resx资源文件,另有jpg与gif图片素材、css样式、数据库及站点配置文件,资料整体仅2.24MB,解压即可对照学习。通过源码目录可以直观看到前端展示、业务逻辑、资源存储与数据库结构的设计思路,例如aspx负责页面渲染、ascx用于封装可复用区块、resx实现多语言文案管理。目前已有128人浏览学习。仔细研读后还能掌握Global.asax中的全局事件处理、Web.config中的身份认证与连接串配置、自定义模板编辑等关键技术,可直接在此基础上扩展支付、物流等功能,对于.NET方向求职或毕业设计都有参考价值。
1. 这个.NET网上商城源码,比你想的更完整
如果你手头正好有一个“.net网上商城+源代码”的压缩包,第一反应很可能是“源码是不是又缺文件”“数据库脚本能不能直接跑”。我拆过不少同类项目,一半是半成品,但这个不太一样:商品、购物车、订单、用户认证整套流程都补齐了,前端用 Bootstrap,后端是 ASP.NET Core MVC,数据访问用 EF Core,按下 F5 就能看到完整的首页分类、商品搜索和结算页。它适合两类人:一是刚学 .NET 想找一份能跑通的完整项目做参考,二是要快速搭商城原型、想在此基础上改业务的开发者。这篇笔记就是我从下载、拆解、跑通到动手改的完整过程,连踩过的坑都记在里面。
2. 架构设计:三层分离与数据流,代码结构一眼看懂
拿到源码第一件事,不要急着按 F5,先看目录。我见过不少开发者上来就启动,结果数据库都没建,直接抛红屏。这个项目的结构比较规矩,属于标准的 Controller + Service + Repository 三层,Controller 管参数和视图,Service 管业务逻辑,Repository 管数据访问。下面把目录拆开讲。
2.1 目录结构:从控制器到数据层,每层管什么事
项目根目录下主要有这些文件夹:
ShoppingMall/ ├── Controllers/ │ ├── HomeController.cs │ ├── AccountController.cs │ ├── ProductController.cs │ ├── CartController.cs │ └── OrderController.cs ├── Models/ │ ├── User.cs │ ├── Product.cs │ ├── Category.cs │ ├── CartItem.cs │ └── Order.cs ├── Services/ │ ├── IProductService.cs │ ├── ProductService.cs │ ├── ICartService.cs │ ├── CartService.cs │ ├── IOrderService.cs │ └── OrderService.cs ├── Data/ │ ├── AppDbContext.cs │ └── SeedData.cs ├── Views/ │ ├── Home/ │ ├── Product/ │ ├── Cart/ │ └── Order/ ├── wwwroot/ │ ├── css/ │ ├── js/ │ └── lib/ └── appsettings.jsonControllers里的每个类对应一个 URL 分组,比如ProductController负责/Product/Index、/Product/Details这类路由。Services接口和实现分开,Controller 里只依赖接口,比如ProductController的构造函数写着IProductService,实际用的是ProductService。这样做的直接好处是业务逻辑可以单独测试,也可以替换实现。我建议新手从一开始就模仿这种分层,别把代码全塞进 Controller,否则后期改一个支付逻辑要动七八个方法。
Views用的是 Razor 语法,每个页面强类型绑定一个 ViewModel。比如商品列表页绑定ProductListViewModel,里面包含当前页商品集合、分页信息、搜索关键词。强类型视图的最大优势是编译器能帮你查错,拼错属性名直接编译失败,而不是运行时才崩。
wwwroot下面放静态资源,Bootstrap 和 jQuery 都从本地引用,没有走 CDN。断网环境下项目照样能跑,这对内网部署很友好。如果你想升级前端框架,直接替换wwwroot/lib下的文件即可,不用动页面代码。
2.2 技术栈选型:为什么用 ASP.NET Core + EF Core + SQLite
项目用了 ASP.NET Core MVC,这是现代 .NET 的主流框架,跨平台支持 Linux 和 macOS,不再像老 .NET Framework 那样必须挂 IIS。EF Core 做 ORM,默认连接字符串指向 SQLite 数据库,文件是mall.db。为什么选 SQLite 而不是 SQL Server?我猜作者想让项目开箱即跑——SQLite 不需要单独装服务,一个文件就能读写。如果你要换 SQL Server,改appsettings.json里的连接字符串,并在Data/AppDbContext.cs里把UseSqlite换成UseSqlServer,再装对应的 EF Core 包就行。
| 组件 | 版本方向 | 作用 |
|---|---|---|
| ASP.NET Core MVC | 6.0 或更高 | Web 框架与路由 |
| EF Core | 6.0 或更高 | ORM,数据库访问 |
| SQLite | 无需安装 | 文件型数据库 |
| Bootstrap | 5.x | 前端 UI 框架 |
| jQuery | 3.x | Ajax 交互 |
依赖注册统一在Program.cs里完成。builder.Services.AddScoped<IProductService, ProductService>()这种写法意思是:每个 HTTP 请求范围内,接口和实现是同一个实例。这样设计能避免内存泄漏,也方便测试。如果你看到AddSingleton用于数据库上下文,一定要警惕——DbContext是线程不安全的,不能长期共享。
2.3 数据库设计:商品、订单、购物车的表关系
打开AppDbContext.cs能看到实体集。核心表有Users、Categories、Products、CartItems、Orders、OrderItems。关系比较直观:
Category与Product是一对多,一个分类下多个商品,分类删除时商品级联删除。这个设计其实有点危险,实际业务里分类下通常有商品,应该限制删除。User与Order是一对多,用户下单时用UserId关联。Order与OrderItem是一对多,一个订单包含多个明细。每个明细都存了商品名称、单价、数量快照,防止商品改价或改名后老订单变成乱账。CartItem没有强制外键到用户,而是用SessionId字符串关联匿名购物车。用户登录后会把当前 Session 的购物车合并到用户购物车。
EF Core 的 Fluent API 在OnModelCreating里配置了索引和唯一约束。比如Products.Name有索引,搜索时走 Index 会更快;CartItem对(SessionId, ProductId)建了联合唯一索引,同一个购物车里同一件商品只能存在一条记录,数量增加时只更新数量字段,不会重复插入。
这块如果你要二次开发,重点看订单表和订单明细表里的快照字段。很多商城项目翻车就翻在只存ProductId,商品改价后老订单金额就变了。这个项目把PriceSnapshot、ProductNameSnapshot存在OrderItem里,历史订单不受商品表改动影响,这是成熟项目该有的设计。
3. 核心模块拆解:从登录到下单的完整链路
看代码别按文件顺序读,要按业务链路读。商城最核心的一条链路是:用户登录 -> 浏览商品 -> 加入购物车 -> 提交订单 -> 模拟支付。我照着这条链路,把关键代码一段段过给你。
3.1 用户认证:Cookie 方案与登录拦截
项目没有用 Identity,而是自己实现的 Cookie 认证。AccountController的Login方法接收LoginViewModel,校验密码后调用HttpContext.SignInAsync写入认证 Cookie。代码大致如下:
[HttpPost] public async Task<IActionResult> Login(LoginViewModel model) { if (!ModelState.IsValid) return View(model); var user = await _userService.ValidateUser(model.UserName, model.Password); if (user == null) { ModelState.AddModelError("", "用户名或密码错误"); return View(model); } var claims = new List<Claim> { new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync(new ClaimsPrincipal(identity)); return RedirectToAction("Index", "Home"); }逻辑说明:ValidateUser里并不是明文比对密码,而是把用户输入的密码加盐后做 PBKDF2 哈希,再和数据库里的PasswordHash比对。哈希函数在Services/UserService.cs里,用Rfc2898DeriveBytes生成 16 字节盐值和哈希,所以数据库里看不到明文密码。如果你拿到的是没有哈希处理的版本,务必补上,否则用户密码泄露等于明文裸奔。
[Authorize]特性挂在OrderController和结算相关的动作上,未登录访问会自动跳登录页。这里有个细节:AddToCart不要求登录,所以匿名用户也能加购物车,到结算时才强制登录。这样用户体验更好,否则用户刚看中一件商品就被要求注册,很容易直接关掉页面。
3.2 商品列表与搜索:分页、排序与缓存
ProductController的Index动作接收categoryId、keyword、page三个参数。它调用IProductService.GetProductsAsync,内部通过IQueryable构建查询,再分页。关键代码:
public async Task<PaginatedList<Product>> GetProductsAsync( int? categoryId, string keyword, int pageIndex, int pageSize) { var query = _context.Products .Include(p => p.Category) .AsNoTracking(); if (categoryId.HasValue) { query = query.Where(p => p.CategoryId == categoryId.Value); } if (!string.IsNullOrEmpty(keyword)) { query = query.Where(p => p.Name.Contains(keyword) || p.Description.Contains(keyword)); } return await PaginatedList<Product>.CreateAsync( query.OrderByDescending(p => p.CreatedAt), pageIndex, pageSize); }EF Core 会把Contains翻译成 SQL 的LIKE '%keyword%',所以不用手写 SQL。分页用的是自定义的PaginatedList<T>,它内部先CountAsync,再用Skip/Take,最后封装总页数、当前页、是否有下一页等属性。View 里通过@Model.HasNextPage来决定是否显示“下一页”链接。
性能方面,源码在ProductService的构造函数里注入了IMemoryCache,对GetCategoriesAsync()做了 5 分钟缓存。分类通常不会频繁变动,但商品列表没有做缓存,因为要实时反映库存和价格。这是一个很好的默认策略:读多写少的数据用缓存,高频变动的数据直接走数据库。
3.3 购物车:Session 存储与数量更新
购物车用 Session 存储 JSON 字符串。CartService核心方法AddToCart大致如下:
public void AddToCart(string sessionId, int productId, int quantity) { var cart = GetCartFromSession(sessionId); var existing = cart.FirstOrDefault(c => c.ProductId == productId); if (existing != null) { existing.Quantity += quantity; } else { var product = _context.Products.Find(productId); cart.Add(new CartItem { ProductId = productId, ProductName = product.Name, Price = product.Price, Quantity = quantity }); } SaveCartToSession(sessionId, cart); }注意这里CartItem是视图模型,不是数据库实体。购物车没有单独的表,Session 过期数据就没了。小项目里这样可以接受,但用户已经加了很多商品,Session 一过期就全部丢失,体验不好。
在CartController的AddToCart动作里,sessionId取自HttpContext.Session.Id;如果用户已登录,则优先用userId作为购物车标识。源码里做了一个合并逻辑:MergeCart会读取匿名 Session 里的商品,逐条插入用户购物车表,然后清空 Session。但合并时如果同一个商品在 Session 和数据库里各有一条,容易重复插入。我建议在二次开发时改成一个独立的ShoppingCarts表,主键是UserId,登录前后用同一个标识,逻辑更清晰。
3.4 订单提交:事务、库存扣减与防重复
订单提交是最容易埋 bug 的地方。源码里OrderService.CreateOrderAsync用一个IDbContextTransaction包住整个下单过程,核心逻辑如下:
using var transaction = await _context.Database.BeginTransactionAsync(); try { var cartItems = await GetCartItemsAsync(userId); var order = new Order { UserId = userId, OrderNo = GenerateOrderNo(), TotalAmount = cartItems.Sum(c => c.Price * c.Quantity), Status = OrderStatus.Pending, CreatedAt = DateTime.Now }; _context.Orders.Add(order); await _context.SaveChangesAsync(); foreach (var item in cartItems) { var product = await _context.Products.FindAsync(item.ProductId); if (product.Stock < item.Quantity) { throw new InsufficientStockException($"商品 {product.Name} 库存不足"); } product.Stock -= item.Quantity; _context.OrderItems.Add(new OrderItem { OrderId = order.Id, ProductId = product.Id, ProductName = product.Name, PriceSnapshot = product.Price, Quantity = item.Quantity }); } await _context.SaveChangesAsync(); await _context.CartItems.RemoveRange(cartItems); await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }逻辑说明:先开启事务,生成订单头,再遍历购物车检查库存并扣减,写入订单明细,最后清空购物车。事务保证任何一步失败,前面所有改动全部回滚。检查库存和扣库存发生在同一个事务里,但并发下仍有超卖隐患——默认事务隔离级别是 Read Committed,两个请求同时读到库存 10,各自扣 10,最终库存变成负数。要彻底防超卖,应该用原子更新UPDATE Products SET Stock = Stock - 1 WHERE Id = @id AND Stock > 0,或者加乐观并发控制。这个项目没有处理并发,我在第五章会给出改进思路。
订单号GenerateOrderNo()用的是DateTime.Now.ToString("yyyyMMddHHmmssfff")加随机四位,小流量下够用。如果做活动大促,建议换雪花算法生成全局唯一 ID。
4. 运行与避坑:让源码在本机跑起来
光看代码不跑,等于没拆。我把项目从下载到跑通的完整过程走了一遍,踩了五个坑。先按正确步骤说,再把坑列出来给你排雷。
4.1 环境准备:SDK、依赖和启动命令
你需要 .NET SDK 6.0 或更高版本。命令行输入dotnet --version确认版本。然后进入源码目录:
dotnet restore dotnet rundotnet restore会从 NuGet 还原所有依赖。如果网络慢,可以把NuGet.Config里的源换成国内镜像,但原版一般没问题。启动后控制台会显示监听地址,默认http://localhost:5000或https://localhost:5001。如果端口被占用,在Properties/launchSettings.json里改applicationUrl。
如果源码里带了.sln解决方案文件,可以用 Visual Studio 打开,但用 VS Code 加 C# 插件也能跑。我习惯命令行,简单直接,还能看到 EF Core 执行的 SQL 日志。
4.2 数据库迁移与种子数据
数据库是 SQLite,文件mall.db在项目根目录。作者可能已经把这个文件放进压缩包,但如果你新克隆或删了它,需要先建立数据库:
dotnet ef database update前提是安装了dotnet-ef工具:
dotnet tool install --global dotnet-ef如果项目里没有Migrations文件夹,源码可能用了EnsureCreated。更稳妥的做法是在Program.cs里加启动参数支持:
if (args.Contains("--seed")) { using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); SeedData.Initialize(db); }运行dotnet run -- --seed会插入管理员账号和几个测试分类。种子数据里有个admin/admin123,首次登录后建议立刻改密码。注意:--seed参数要放在程序名之后,否则不会被识别。
4.3 常见问题排查:这些坑我替你踩过了
以下是我实际运行中遇到的五个问题,按现象、原因、解决给排好。
现象一:dotnet run直接报System.InvalidOperationException: The entity type 'CartItem' requires a primary key to be defined。原因:CartItem实体没有标注Key属性,EF Core 默认把Id当主键,但模型里没有Id。解决:在模型类加一个带[Key]的Id属性,或者用 Fluent API 把(SessionId, ProductId)设为主键。源码里其实已经配了联合主键,报错通常是因为你自己新加了实体。如果没改东西就报错,先检查是否忘了dotnet restore。
现象二:数据库建好了,但页面导航栏是空的,没有分类。原因:EF Core 迁移只建表,不插入种子数据。解决:运行带--seed的启动命令,比如dotnet run --seed,或者手动往Categories表插几条记录。如果mall.db是旧版本,先删除再重新迁移。
现象三:HTTPS 证书报错,浏览器显示不安全。原因:本机没有信任开发证书。解决:运行dotnet dev-certs https --trust,然后重启项目。如果是在 Linux 服务器部署,这个命令不适用,要在 Nginx 层配证书。
现象四:dotnet ef database update提示No migrations were found。原因:项目里没有生成过迁移脚本。解决:先执行dotnet ef migrations add InitialCreate,再执行dotnet ef database update。如果你不想用迁移,可以删除Migrations文件夹,让 EF Core 用EnsureCreated模式,但以后每次改模型都要手动删库重建,比较麻烦。
现象五:下单时提示SQLite Error 19: 'UNIQUE constraint failed: CartItems.SessionId, CartItems.ProductId'。原因:AddToCart先查原有购物车再新增,并发下两个请求同时插入同一商品,第二个就撞了唯一索引。解决:把AddToCart的查询和插入放到事务里,或者改成INSERT OR IGNORE加UPDATE的两步原子操作。这个坑非常隐蔽,我压测时才遇到,单用户测试永远发现不了。
5. 二次开发扩展:给商城加上定时秒杀活动
如果你拿到这套源码,不只是想看,肯定想改点真东西。我建议从“秒杀”入手,因为它的核心难点正好覆盖了这个项目最弱的部分——并发库存扣减。
5.1 设计思路与库存保护
秒杀活动需要一个SeckillActivity表,里面存商品原价、秒杀价、开始时间、结束时间、总库存和已售数量。用户参与秒杀时,不能直接走ProductService.CreateOrderAsync,因为那个方法扣的是普通库存。更好的做法是在OrderService里新加一个CreateSeckillOrderAsync,在扣库存时用原子更新代替“查再改”:
UPDATE Products SET SeckillStock = SeckillStock - 1 WHERE Id = @id AND SeckillStock > 0在 EF Core 里用ExecuteSqlRaw执行这条语句,受影响行数大于 0 才说明抢到了,然后创建订单。用这种方式,两个并发请求同时执行更新,数据库的行锁会让一个成功、一个失败,不会出现超卖。
5.2 代码改动要点
具体改动三步:
第一步,在Product实体加一个SeckillStock字段,秒杀库存独立,不占用普通库存。迁移字段时要小心:如果该商品已有秒杀库存,要把初值设为普通库存的某个比例。
第二步,在ProductController加一个SeckillAction(int id)的 POST 动作,先校验活动时间是否在有效期内,再执行原子扣库存,成功后才创建订单。代码大致这样:
[HttpPost] public async Task<IActionResult> Seckill(int id) { var product = await _context.Products.FindAsync(id); if (product == null || product.SeckillStock <= 0) return Json(new { success = false, msg = "已售罄" }); var rows = await _context.Database.ExecuteSqlRawAsync( "UPDATE Products SET SeckillStock = SeckillStock - 1 WHERE Id = {0} AND SeckillStock > 0", id); if (rows == 0) return Json(new { success = false, msg = "已售罄" }); await _orderService.CreateSeckillOrderAsync(userId, product); return Json(new { success = true }); }第三步,给秒杀接口加上频率限制,最简单的是用内存缓存做每用户每分钟一次,防止脚本刷接口。我按这个思路改了半小时,普通下单链路没受影响,秒杀订单也能正常落库。唯一要留意的是 Session 缓存:秒杀前加购物车会走 Session,但秒杀订单独立建单,两者不要混用。
从那以后,我每接手一个商城源码,都会先跑一遍登录、加购、下单、库存扣减的完整链路,再用并发工具压一压秒杀接口。至少把库存为负数、重复下单这两个低级问题排除掉。希望帮到你。
本文还有配套的精品资源,点击获取