简介:面向ASP.NET MVC与SQL Server初学者及毕业设计开发者,这是一套完整的网上商城系统项目源码与数据库备份。项目覆盖商品浏览、购物车、订单结算、用户认证等典型电商流程,清晰体现模型-视图-控制器分层、路由配置、Razor视图、身份验证、缓存及AJAX异步交互等核心机制,并包含商品、用户、订单等数据表设计。压缩包共1029个文件,以dll运行库、cshtml视图页面、cs控制器与模型代码、js/css前端资源为主,另含SQL脚本与mdf数据文件,整体大小约88.97MB,解压后即可对照学习,从数据库脚本到页面交互均可直接运行,便于二次开发与功能扩展。已有637人学习下载,尤其适合需要参考完整项目结构、理解ASP.NET MVC与SQL Server整合实践的开发者,可借此掌握从数据库表设计到业务层封装再到界面展示的完整实现思路,无论课程设计还是项目实战都能提供扎实参考。
1. 拆开 rar 之后,这套网上商城最值得读的是请求链路
网上商城类的 ASP.NET MVC 源码包并不稀罕,稀罕的是结构能直接跑起来、代码能顺着读下来。这套基于 ASP.NET MVC 的商城系统,页面和普通网站没有太大区别,真正值钱的是从 URL 到路由、再到控制器、最后落到 SQL Server 的这一整条链路是完整的。它以 MVC 架构把商品、订单、购物车、用户拆成了标准的三层,数据访问通过 Entity Framework 操作 SQL Server,而不是在页面里拼 SQL 字符串。适合刚学完 MVC 基础、想看看真实项目怎么组织的人,也适合要照着搭一个商城后台的开发者。拿到压缩包后建议先看数据库文件和连接字符串,因为绝大多数“打开就报错”都出在数据库环境上,而不是代码本身。
2. URL 到 Action 的映射:路由配置和控制器方法怎么设计
2.1 默认路由:为什么商城首页是 /Home/Index
打开解决方案先看 App_Start/RouteConfig.cs,这是 MVC 应用的入口,也是商城所有 URL 的翻译器。默认路由把{controller}/{action}/{id}三段映射到控制器的动作方法,Home和Index作为缺省值,所以访问根路径/时会落到 HomeController 的 Index 方法。这套商城里商品列表、商品详情、购物车都是靠这条规则解析的,比如/Product/Detail/12就会调用 ProductController.Detail(int id) 并把 12 绑定给 id。
public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute( name: "Default", url: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional } ); } }这段配置里IgnoreRoute是放行 .axd 之类的旧式 HTTP 处理器,商城项目一般不需要动。MapRoute的第一个参数是路由名,第二个是 URL 模式,第三个是缺省值。UrlParameter.Optional表示 id 可以不给,所以/Product和/Product/Index都能访问同一个列表页。路由表是按注册顺序从上往下匹配的,第一条命中就会停止,因此自定义路由必须放在默认路由前面。
2.2 控制器的 Action 方法怎么设计
看完整套商城的控制器,会发现动作方法基本可以分成三类:返回视图的页面动作、接收表单提交的写动作、返回 JSON 给前端做异步刷新的接口动作。以 ProductController 为例,Index 承担商品分页列表,Detail 显示商品详情,AddToCart 是加购接口。控制器里不直接写new DbContext(),而是通过构造函数接收 IProductService,这是这套代码里比较值得学习的习惯。
public class ProductController : Controller { private readonly IProductService _productService; public ProductController(IProductService productService) { _productService = productService; } public ActionResult Index(int page = 1, int pageSize = 12) { var model = _productService.GetPagedProducts(page, pageSize); return View(model); } public ActionResult Detail(int id) { var product = _productService.GetById(id); if (product == null) { return HttpNotFound(); } return View(product); } [HttpPost] public ActionResult AddToCart(int productId, int quantity) { // 写入购物车后返回 JSON,页面用 AJAX 局部更新角标 return Json(new { ok = true, cartCount = quantity }, JsonRequestBehavior.DenyGet); } }Index(int page = 1, int pageSize = 12)说明控制器返回视图时可以从默认值取参数,也可以从查询字符串绑定。Detail返回HttpNotFound()而不是直接返回 null 视图,这样 HTTP 状态码是 404,前端和搜索引擎能正确识别无效商品。AddToCart加[HttpPost]并在 Json 里写JsonRequestBehavior.DenyGet,防止 GET 请求触发加购动作。
下面这张表基本覆盖了商城最常用的动作方法映射:
| 控制器动作 | URL 示例 | 关键参数 | 返回内容 |
|---|---|---|---|
| Product/Index | /Product?page=2 | page, pageSize | 商品列表视图 |
| Product/Detail | /Product/Detail/12 | id | 商品详情视图 |
| Product/AddToCart | /Product/AddToCart | productId, quantity | JSON 结果 |
| Checkout | /Order/Checkout | CheckoutInput | 重定向到成功页 |
2.3 模型绑定:表单字段是怎么变成对象的
商城结算页提交的收货人、电话、地址是一组关联字段,MVC 模型绑定器可以根据名称映射自动装配成对象,不需要手动去读Request.Form["Phone"]。这里的关键是表单控件的 name 属性和模型属性名必须完全一致,否则绑定后 ModelState 里全是空值。
public class CheckoutInput { public string ReceiverName { get; set; } public string Phone { get; set; } public string Address { get; set; } public string Remark { get; set; } } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Checkout(CheckoutInput input) { if (!ModelState.IsValid) { return View(input); } // 创建订单、扣库存、清空购物车 return RedirectToAction("OrderSuccess"); }[ValidateAntiForgeryToken]必须和视图里的@Html.AntiForgeryToken()配合使用,防止跨站请求伪造。ModelState.IsValid不通过时把 input 原样返回给视图,用户已经填的内容不会丢。最后的RedirectToAction是 PRG 模式,避免用户刷新页面导致重复下单。排查模型绑定问题时,我一般会先在 Action 入口打个断点看ModelState里有哪些 key 是无效的,通常都是表单 name 与属性名不一致。
3. SQL Server 连接与 EF 数据访问:Model 层不是堆实体类
3.1 连接字符串:本地跑通和部署上线的差异
商城系统的 Web.config 里会有一段 connectionStrings 配置,这是整个系统能不能跑起来的第一道关卡。本地开发时最常见的写法是Data Source=.或Data Source=.\SQLEXPRESS,前者表示本机默认 SQL Server 实例,后者指定命名实例。Initial Catalog 是数据库名称,它不决定 .mdf 文件的位置,只决定连接后默认使用哪个库。
<connectionStrings> <add name="ShopDbContext" connectionString="Data Source=.;Initial Catalog=Shop;Integrated Security=True;MultipleActiveResultSets=True;" providerName="System.Data.SqlClient" /> </connectionStrings>Integrated Security=True表示用当前 Windows 账号登录 SQL Server,本地调试最省事;部署到 IIS 后,应用池运行账号可能没有 SQL Server 登录权限,这时需要改成User ID=sa;Password=xxx,并把连接字符串放到 Web.config 的发布配置里。MultipleActiveResultSets=True尽量保留,EF 延迟加载时会在同一个连接上叠加读取,不开这个选项容易报“已有打开的与此连接相关联的 DataReader”。
3.2 核心表设计:商品、用户、订单
虽然这套商城系统的实体类在 Models 目录下,但真正理解业务要看数据库表。一个可运营的商城,五张核心表就够了:Category、Product、Account、Order、OrderItem。字段设计里有几个容易被忽略的细节,比如价格全部用decimal(18,2)而不是float,订单项里要冗余下单时的单价,因为商品改价后历史订单不能被影响。
| 表 | 核心字段 | 说明 |
|---|---|---|
| Category | Id, Name, ParentId | ParentId 为 0 表示顶级分类 |
| Product | Id, CategoryId, Name, Price, Stock, Status | Status 控制上架下架 |
| Account | Id, UserName, PasswordHash, Email | 密码只存哈希 |
| Order | Id, AccountId, OrderNo, TotalAmount, Status, CreatedAt | 订单状态机 |
| OrderItem | Id, OrderId, ProductId, Quantity, UnitPrice | 单价快照 |
订单表的状态字段建议用 int 而不是字符串,代码里定义枚举对应待支付、已支付、已发货、已完成、已关闭。给 Order 的 AccountId 和 CreatedAt 建索引,商城后台按用户或时间查订单会快很多。库存字段 Stock 不要用无符号设计,扣库存时要判断减完后是否小于 0。
3.3 DbContext 和查询写法:EF 的正确打开方式
数据访问层围绕 DbContext 展开,实体类的属性和数据库表一一对应。构造函数里base("ShopDbContext")表示从连接字符串里取名为 ShopDbContext 的那条配置,这个 name 必须和 Web.config 里的 name 完全一致。OnModelCreating 里做精度配置,避免 EF 把 decimal 默认映射成 numeric(18,0),导致价格被四舍五入。
public class ShopDbContext : DbContext { public ShopDbContext() : base("ShopDbContext") { } public DbSet<Product> Products { get; set; } public DbSet<Category> Categories { get; set; } public DbSet<Order> Orders { get; set; } public DbSet<OrderItem> OrderItems { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Entity<Product>() .Property(p => p.Price) .HasPrecision(18, 2); } }商品列表页的分页查询是 EF 里最典型的写法,注意Skip和Take的顺序,颠倒后 SQL 生成的语句会完全不一样。Include(p => p.Category)是预加载导航属性,让分类信息通过一次 JOIN 取出来;如果不加这句话,EF 会在循环里逐条查询 Category,这就是常见的 N+1 问题。
var products = db.Products .Where(p => p.Status == 1) .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Include(p => p.Category) .ToList();这段代码的Where最终会生成带参数的 SQL,参数化查询本身就防 SQL 注入,不要在 EF 里用string.Format拼接查询条件。Skip((page - 1) * pageSize)的逻辑是前端传 page 从 1 开始,数据库端跳过前 N 条。如果商品量级过了百万,Skip/Take会随着页码变慢,届时要改成基于游标的方案,但这是后话。
4. Razor 视图层:商品列表、购物车和结算页的渲染链路
4.1 布局页和局部视图:整个商城的公共骨架
Views/Shared/_Layout.cshtml 是商城的公共模板,顶部导航、购物车角标、底部版权都在这里。Views/_ViewStart.cshtml 里那句Layout = "~/Views/Shared/_Layout.cshtml";决定了所有视图默认套用这个布局。分类菜单如果在每个页面里都直接查询数据库,会随着页面数量放大开销,常见的做法是把它做成局部视图,用Html.Action在布局页里调用一次。
@Html.Action("CategoryMenu", "Home", new { area = "" })这行代码会发起一次子请求,调用 HomeController 的 CategoryMenu 方法,返回一个局部视图。子请求同样经过路由和过滤器,所以这个位置非常值得做缓存,否则商城的每个页面都会额外多一次数据库查询。如果分类菜单变化不频繁,直接给 CategoryMenu 动作加 OutputCache 会更省事。
4.2 强类型视图与 ViewModel:不要把实体直接丢给页面
看这套商城的前台页面,会发现视图使用@model指令声明自己的数据类型,而不是依赖 ViewBag 传值。商品列表页除了商品集合,还要知道当前页码、总页数、搜索关键词,这些字段如果拆开塞进 ViewBag,页面和控制器之间的约定就全靠记忆了,所以需要专门的 ViewModel。
public class ProductListViewModel { public List<Product> Products { get; set; } public int CurrentPage { get; set; } public int TotalPages { get; set; } public string Keyword { get; set; } }对应视图文件 Index.cshtml 里先用@model声明命名空间,再通过Model.Products循环渲染商品卡片。Url.Action("Detail", "Product", new { id = p.Id })生成的是符合路由规则的 URL,不要在页面里硬拼"/Product/Detail/" + p.Id,因为一旦路由规则变化,硬拼 URL 全部会断。
@model Web.Models.ProductListViewModel @foreach (var p in Model.Products) { <div class="product-item"> <a href="@Url.Action("Detail", "Product", new { id = p.Id })"> <h3>@p.Name</h3> </a> <span class="price">@p.Price.ToString("C")</span> <button class="add-cart">public class CartService { private const string CartSessionKey = "Cart"; public List<CartItem> GetCart(HttpSessionStateBase session) { var cart = session[CartSessionKey] as List<CartItem>; if (cart == null) { cart = new List<CartItem>(); session[CartSessionKey] = cart; } return cart; } }我一般会按状态分流:未登录用户走 Session,登录成功后把 Session 里的购物车合并到数据库的 Cart 表,并从 Session 清除。这样既保证游客能购物,又不怕进程回收丢数据。商城前台加购按钮通常不整页刷新,而是用 jQuery 发异步请求,控制器返回 JSON,前端只更新右上角的购物车数量。
$(".add-cart").click(function () { var productId = $(this).data("product-id"); $.post("/Product/AddToCart", { productId: productId, quantity: 1 }) .done(function (res) { if (res.ok) { $("#cart-count").text(res.cartCount); } }); });这段 jQuery 用$.post提交,第二个参数是 JavaScript 对象,jQuery 会把它转成表单格式的请求体。控制器端的 AddToCart 接收 int productId 和 int quantity,模型绑定器会自动解析。要注意 JSON 属性名大小写:C# 默认序列化出来的是 PascalCase 即cartCount,如果后端让它输出 camelCase,前端要跟着一致,否则res.cartCount拿到 undefined。
视图传值方式的选择也直接影响维护难度:
| 传值方式 | 类型安全 | 适用场景 |
|---|---|---|
| ViewData | 弱类型 | 布局页传标题、面包屑 |
| ViewBag | 弱类型 | 单个零散值 |
| 强类型 ViewModel | 类型安全 | 商品列表、表单提交 |
5. 登录授权、缓存与全局异常:让商城像能上线的样子
5.1 Cookie 认证和 [Authorize]:哪些页面不能匿名访问
商城的用户中心和后台管理需要身份鉴别,ASP.NET 里最常见的方案是 Cookie 认证加 ASP.NET Identity。用户在登录页提交账号密码,验证通过后生成加密 Cookie,后续请求通过 Cookie 识别身份。控制器里的[Authorize]是声明式的访问控制,放在类上表示整个控制器都要登录,放在方法上表示只有该动作需要登录。
app.UseCookieAuthentication(new CookieAuthenticationOptions { LoginPath = new PathString("/Account/Login"), AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, ExpireTimeSpan = TimeSpan.FromHours(12) });LoginPath是匿名用户访问受限页面时被重定向到的地址,ExpireTimeSpan控制登录状态有效期,商城前台建议设置成滑动过期。[Authorize(Roles = "Admin")]可以进一步把后台功能限制给管理员角色,不满足条件的用户会被导入授权失败流程。
登录动作的代码里有一个容易被忽略的安全点:returnUrl 重定向。用户本来想访问 /Order/List,被重定向到登录页,登录成功后要跳回去,这个 returnUrl 来自查询字符串,必须用Url.IsLocalUrl校验,否则可能被用来做开放重定向钓鱼。
[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task<ActionResult> Login(LoginViewModel model, string returnUrl) { if (!ModelState.IsValid) { return View(model); } var result = await SignInManager.PasswordSignInAsync( model.UserName, model.Password, model.RememberMe, shouldLockout: false); switch (result) { case SignInStatus.Success: if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) { return Redirect(returnUrl); } return RedirectToAction("Index", "Home"); default: ModelState.AddModelError("", "用户名或密码错误"); return View(model); } }PasswordSignInAsync内部会校验密码哈希,即使数据库泄露,攻击者也拿不到明文密码。登录失败的提示写得笼统一点,不要告诉用户“用户名不存在”还是“密码错误”,这是防止账号枚举的常规做法。shouldLockout: false表示暂时不启用登录失败锁定,商城前台为了防止撞库一般会开启,设置连续失败 5 次锁定 15 分钟。
5.2 OutputCache 和 MemoryCache:热门商品怎么扛流量
商城首页的热门商品往往会被大量用户同时访问,这类数据读多写少,非常适合缓存。OutputCache 是 ASP.NET 层面的输出缓存,命中后直接返回缓存的 HTML,Action 方法根本不会执行。把它加在返回 PartialView 的动作上,可以让首页的商品推荐区域瞬间完成响应。
[OutputCache(Duration = 300, VaryByParam = "none", Location = OutputCacheLocation.Server)] public ActionResult HotProducts() { var products = _productService.GetHotProducts(); return PartialView("_HotProducts", products); }Duration单位是秒,300 表示缓存 5 分钟。VaryByParam很关键:如果这个方法根据参数变化输出不同内容,就要写参数名,多个参数用分号分隔;完全不依赖参数写 none。Location设置为 Server 表示只在服务器端缓存,避免 CDN 或浏览器缓存造成用户看到过期数据。OutputCache 缓存的是最终 HTML,如果页面里包含“我的购物车”这类个人数据,就不能直接用 OutputCache,那是用户级缓存该管的事。
另一种是 MemoryCache 数据缓存,它缓存的是对象而不是 HTML,适合在 Service 层使用。两者区别在于:OutputCache 命中后不执行 Action,省掉了整条请求管线;MemoryCache 命中后 Action 照常执行,但省掉了数据库查询。商城系统里两者通常会配合,页面级缓存用 OutputCache,商品价格、库存这类需要实时判断的数据用 MemoryCache 设置更短过期时间。
5.3 全局异常处理和友好错误页
没有异常处理的商城,遇到数据库连不上会直接显示黄色错误页,连 SQL 连接字符串都可能被带出来。ASP.NET MVC 的项目里,App_Start/FilterConfig.cs 中的 RegisterGlobalFilters 可以注册全局过滤器,配合 Web.config 的 customErrors 把异常转发到统一错误页。
public static void RegisterGlobalFilters(GlobalFilterCollection filters) { filters.Add(new HandleErrorAttribute()); }HandleErrorAttribute 默认只处理所有异常并渲染 Error.cshtml,但它有一个边界:只捕获 Action 方法内部抛出的异常,路由没匹配上、静态文件不存在这类错误它管不了。404 需要在 RouteConfig 注册一个兜底路由,把未知 URL 转到一个专门的 ErrorController。异常日志也不能只靠错误页,我一般会在 Application_Error 事件里写日志文件,保留堆栈和请求参数,否则线上出了问题只能靠用户截图。
| ExceptionType | 错误页 | 适用场景 |
|---|---|---|
| SqlException | Error/Database.cshtml | 数据库连接失败、SQL 超时 |
| NullReferenceException | Error/NotFound.cshtml | 被请求的对象不存在 |
| 通用 Exception | Error/Index.cshtml | 兜底 |
5.4 控制器的单元测试:分层之后才能写
这套商城之所以把业务逻辑放在 Service 层而不是 Controller 里,就是为了让控制器能独立测试。用 Moq 模拟 IProductService,传入固定数据,验证控制器返回的 ViewResult 以及 Model 是否正确。商品详情的动作方法测试写出来是这样的:
[TestMethod] public void Product_Detail_With_Valid_Id_Returns_View() { var mockService = new Mock<IProductService>(); mockService.Setup(s => s.GetById(1)) .Returns(new Product { Id = 1, Name = "测试商品" }); var controller = new ProductController(mockService.Object); var result = controller.Detail(1) as ViewResult; Assert.IsNotNull(result); Assert.AreEqual("测试商品", ((Product)result.Model).Name); }这个测试没有连数据库,执行速度极快。SetUp定义当 GetById 收到 1 时返回固定商品,as ViewResult把 ActionResult 转成真实的视图结果,然后检查 Model 类型和值。测试控制器只能验证行为,真正要覆盖的业务规则如订单状态流转、库存扣减是 Service 层的测试重点,那部分建议优先写。
6. SQL Server 附加数据库失败与字符串转换的实用排错
6.1 附加 .mdf 失败和连接不上的处理顺序
源码包里的数据库一般有两种交付形式:直接放 .mdf/.ldf 文件,或者给一个 .bak 备份。.bak 用还原比较稳妥,.mdf 则可以用 CREATE DATABASE 附加。附加报错时先确认该版本 SQL Server 是否兼容,高版本 SQL Server 生成的 .mdf 文件低版本实例可能连附加都拒绝。排查连接问题最直接的办法是用 sqlcmd 先验证实例通不通,排除代码层面的干扰:
sqlcmd -S . -E -Q "SELECT @@VERSION"这条命令使用 Windows 身份登录本机默认实例,如果返回 SQL Server 版本信息就说明服务和权限没问题,接下来再检查连接字符串。若 .mdf 文件的日志文件 .ldf 丢失,SQL Server 会在日志文件缺失时报错,此时可以用FOR ATTACH_REBUILD_LOG重建日志,但要注意这只适用于非系统数据库,并且文件本身要完整。
CREATE DATABASE Shop ON (FILENAME = N'D:\Data\Shop.mdf') FOR ATTACH;6.2 字符串转数字和日期比较的 SQL 写法
商城后台做数据筛选时,前端传来的是字符串,数据库字段是 int 或 datetime,转换就不可避免。SQL Server 2012 及以上版本推荐用TRY_CAST而不是CAST,因为CAST遇到非数字会直接报错中断整个查询,而TRY_CAST返回 NULL,配合 WHERE 过滤可以让查询更健壮。
-- 安全转换:无法转换时返回 NULL,不会抛错 SELECT TRY_CAST('123' AS INT); -- 结果 123 SELECT TRY_CAST('abc' AS INT); -- 结果 NULL -- 取三个日期中的最大值,VALUES 构造器比多层 CASE 更容易扩展 SELECT MAX(d) AS MaxDate FROM (VALUES (@d1), (@d2), (@d3)) AS T(d);VALUES (…)构造器把多个表达式组成一张临时表,MAX(d)直接求最大值。这个写法比嵌套 CASE WHEN 清晰,而且再增加第四个日期时只需要在 FROM 里加一行。C# 侧的对应做法是int.TryParse,不要用Convert.ToInt32把异常抛给页面。如果业务字段本身就有脏数据,建议先在数据库层用TRY_CAST做一次清洗,而不是在 C# 里逐个判断。
SQL Server 出现明显延迟时不要急着重启服务,先查等待类型,重点看门闩等待。门闩是内存级别的锁,大量 LATCH 等待往往指向 tempdb 争用或存储子系统太慢。用下面的查询能看到当前阻塞最明显的会话:
SELECT session_id, wait_type, wait_time FROM sys.dm_os_waiting_tasks WHERE wait_type LIKE '%LATCH%' ORDER BY wait_time DESC;如果查出来的等待集中在LATCH_EX或PAGELATCH_*,先把 tempdb 从慢速磁盘挪到 SSD,同时将 tempdb 拆成多个数据文件,每个文件的初始大小保持一致,这一步对高并发商城系统很有效。文件数量一般建议和 CPU 核数的一半持平,但不是越多越好,拆多了反而增加管理开销。最后记得确认MultipleActiveResultSets=True,这个连接字符串选项在 EF 延迟加载的商城项目里几乎属于必开项。
本文还有配套的精品资源,点击获取