简介:面向ASP.NET初学者的游戏商城网页项目,以仿Steam界面为切入点,完整串联用户认证、游戏数据展示、购物车订单与后台管理流程。项目基于ASP.NET MVC架构,包含登录注册、热门游戏/全部游戏列表、游戏详情、购物车结算和管理员维护等模块,Razor视图与SQL查询贯穿其中,适合作为课程设计或框架入门练习。压缩包共455个文件,约385.7MB,主体为81个.cs后端代码、12个.aspx页面、23个CSS与6个JS前端文件,以及大量jpg/jpeg/png图片素材;同时附有webm演示视频、PDB调试符号、DLL依赖、SQL脚本和SQLite数据库,便于直接还原运行环境并跟踪调试。已有1184人学习下载,学习者可对照源码理解会话管理、角色权限、支付接口对接等关键实现,也可基于现有页面快速改造出自己的商城系统。
1. 这个标题背后,是多数 ASP.NET 新手的第一个坎
仿 Steam 的游戏商城页,是一道分水岭。新手写 ASP.NET Web Forms 拖控件,三天能出来一个能在浏览器里看的主页;但要做到列表筛选、详情、加购、结算、订单回查这一整条链路,大多数人挂在两个地方:一是把页面交互想成了「前端工程」,二是把数据访问写成了「在代码里拼 SQL」。真实的仿 Steam 商城,重心不在像素级复制 Steam 的黑色界面,而在理解 ASP.NET 的请求管线如何把浏览器里的点击映射到服务端代码,再映射回数据库。
这个标题要解决的场景是:商品数据从哪来、怎么渲染成列表页、点进详情怎么带参数、加入购物车怎么跨页面保持状态、提交订单怎么保证库存不多扣、支付回调来了怎么确认订单状态。这些问题的答案串联起来,就是一个完整的 MVC 业务闭环。
适合谁做?已经学过 C# 语法、知道 SQL 基本写法的开发者最合适。如果你只会拖控件而从来没搞清过 PostBack 和路由的差别,这篇文章能帮你把概念掰正。若你已经是五年以上 ASP.NET 工程师,请直接跳到第 4 章看订单超卖和支付幂等的处理细节——那是仿 Steam 类项目里最见功力的地方。
2. 技术选型和项目骨架:选 ASP.NET Core 还是 ASP.NET MVC 5
2.1 仿 Steam 商城为什么更推荐 ASP.NET Core
标题写的 ASP.NET 是一个宽泛概念。在 2025 年的语境下,新项目默认选 ASP.NET Core,它不是 ASP.NET 的升级版,而是重写版。MVC 5 跑在 .NET Framework 上,只能在 Windows 部署;ASP.NET Core 是跨平台的,可以上 Linux + Nginx,这对游戏商城的成本控制很关键——Steam 这类站点永远在抢首屏速度,Linux 低配云主机跑 Kestrel 的性能明显优于同等配置的 Windows Server + IIS。
理解 ASP.NET Core 的 MVC 工作原理,是拆解整个项目的前提。客户端发来一个 HTTP 请求,路由中间件拿 URL 去匹配路由表,命中后把请求交给对应的 Controller 方法,Controller 取出数据塞进 Model,再丢给 View 渲染成 HTML 返回。这套逻辑在仿 Steam 里体现在:/Home/Index访问首页,/Game/Detail/42访问 42 号游戏详情,/Cart/Add?gameId=42执行加购动作。
如果做内部管理系统,Razor Pages 够用;但仿 Steam 的页面有复杂交互,需要精细控制 URL 和返回结构,MVC 是合理选择。我见过有人用 Blazor 做商城,交互性确实强,但首屏加载一个几十 KB 的 WebAssembly 运行时,对游戏商店这类内容型页面得不偿失。
2.2 最小可运行的项目结构搭建命令
用 dotnet CLI 三分钟拉起项目,不依赖 Visual Studio:
dotnet new mvc -n SteamGameShop -f net8.0 cd SteamGameShop dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools dotnet add package Microsoft.AspNetCore.Session dotnet restore dotnet run-n参数指定项目名称,-f指定目标框架。第二条命令进到项目目录,后面三条dotnet add package分别引入数据库框架、EF 迁移工具和会话中间件。dotnet restore下载依赖,dotnet run启动 Kestrel 服务器,默认监听http://localhost:5000。
启动后你会看到一堆模板页面,这些要清掉。把Controllers/HomeController.cs里的默认 Index 方法改写,Views/Home/Index.cshtml换成商城首页布局,Views/Shared/_Layout.cshtml里的导航换成游戏分类。模板是脚手架,不是最终答案。
2.3 路由配置:仿 Steam URL 结构的设计
ASP.NET MVC 的路由和方法配置是这个项目最先接触的硬知识点。打开Program.cs,默认的约定路由长这样:
app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");{controller=Home}表示 URL 第一段是控制器名,缺省时用 Home;{action=Index}是第二段方法名;{id?}是可选的第三段参数。因此/Game/Detail/42会命中GameController的Detail(int id)方法。
游戏详情的 URL 带三个参数时,约定路由就不够用了。在Program.cs里追加一条:
app.MapControllerRoute( name: "gameRoute", pattern: "game/{id}/{slug}", defaults: new { controller = "Game", action = "Detail" });这样/game/42/grand-theft-auto-v会直接把 42 和 slug 字符串传给Detail方法。好处有两个:URL 可读性好,对搜索引擎友好;参数名明确,不用在 QueryString 里传一堆?gameId=42&slug=xxx。
提示:路由里参数名必须和方法参数名一致,否则 MVC 绑定不到值,会得到 404 或者参数为 0 的异常行为。
3. 商品、购物车、订单三大核心模块的落地实现
3.1 数据库实体建模:从 Steam 页面反推表结构
打开 Steam 的任意一个游戏详情页,提炼出实体。商品列表页展示封面图、名称、价格、折扣率、标签;详情页多出描述、截图、配置要求。订单涉及用户、商品快照、支付状态。设计表结构时一个关键决策点:订单明细要不要存商品价格快照。
必须存。游戏商品会调价、会打折,如果订单明细表只存GameId而下单后商品涨价了,用户拿订单找售后时你没法证明当时谈好的成交价。快照的存在让订单表自洽,不依赖商品表的当前状态。
实体代码示例:
public class Game { public int Id { get; set; } public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public decimal OriginalPrice { get; set; } public decimal DiscountRate { get; set; } public string CoverImageUrl { get; set; } = string.Empty; public bool IsActive { get; set; } public DateTime CreatedAt { get; set; } } public class Order { public int Id { get; set; } public string OrderNo { get; set; } = string.Empty; public int UserId { get; set; } public decimal TotalAmount { get; set; } public string Status { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } public List<OrderItem> Items { get; set; } = new(); } public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public int GameId { get; set; } public string GameTitle { get; set; } = string.Empty; public decimal UnitPrice { get; set; } public decimal DiscountRate { get; set; } public decimal FinalPrice { get; set; } }OrderItem里的GameTitle、UnitPrice、DiscountRate都是冗余字段,这就是价格快照。真实商城项目里还会存一张GameSnapshot独立表来记录价格变更历史,但刚开始做订单时,在明细表里冗余三个字段足够支撑业务和售后。Status用字符串而非常量,方便下单时直接赋初始值,也便于阅读日志时直读含义。
3.2 商品列表和详情页:EF Core 查询的几个写法差异
列表页要支持按分类筛选、按价格排序、按折扣排序、关键词搜索。在GameController里写对应的 action:
public async Task<IActionResult> Index(string? search, int? categoryId, string? sort, int page = 1) { var query = _context.Games.Where(g => g.IsActive); if (!string.IsNullOrEmpty(search)) query = query.Where(g => g.Title.Contains(search)); if (categoryId.HasValue) query = query.Where(g => g.CategoryId == categoryId.Value); switch (sort) { case "priceAsc": query = query.OrderBy(g => g.OriginalPrice * (1 - g.DiscountRate)); break; case "priceDesc": query = query.OrderByDescending(g => g.OriginalPrice * (1 - g.DiscountRate)); break; case "discount": query = query.OrderByDescending(g => g.DiscountRate); break; default: query = query.OrderByDescending(g => g.CreatedAt); break; } int pageSize = 12; var total = await query.CountAsync(); var games = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize); ViewBag.CurrentPage = page; return View(games); }query的类型是IQueryable<Game>,注意Contains翻译成 SQL 里的LIKE '%search%',在游戏名称字段上做搜索没问题,但不要在描述这种长文本字段上直接Contains,性能会很差。Skip/Take是分页的标准写法,EF Core 会翻译成OFFSET/FETCH。
列表数据传到 View 后,页面里渲染卡片:
<div class="row"> @foreach (var game in Model) { <div class="col-md-3 mb-4"> <a asp-controller="Game" asp-action="Detail" asp-route-id="@game.Id"> <img src="@game.CoverImageUrl" class="game-cover" alt="@game.Title" /> <h5>@game.Title</h5> <span class="price">@((game.OriginalPrice * (1 - game.DiscountRate)).ToString("C"))</span> @if (game.DiscountRate > 0) { <span class="original-price text-muted">@game.OriginalPrice.ToString("C")</span> } </a> </div> } </div>asp-controller、asp-action是 Tag Helper 的语法,它依据第 2 章里的路由配置自动生成 URL,好处是改路由后 View 里的链接会自动跟着变,不用手动改字符串。ToString("C")是货币格式化,显示为$29.99或¥116.00,取决于服务器区域设置,建议在Program.cs里专门配置好区域文化,否则结算金额可能出现人民币符号错位。
3.3 购物车:Session 存储还是独立表
购物车的实现有两种常见做法。不登录也能加购的临时购物车,用 Session 存;用户登录后的购物车,存数据库表。仿 Steam 做了登录才能购买,所以购物车表设计成独立的CartItem表,User 维度做关联。
但有一个折中方案:不管登录与否,都用 Session 存购物车,提交订单时才要求登录并写入数据库。这个方案的优势在于浏览体验顺畅,劣势是 Session 默认存在内存里,站点重启购物车就没了。解决方式是给 Session 配置 Redis 存储,第 5 章展开讲。
Session 存购物车的代码:
public class CartService { private const string CartSessionKey = "UserCart"; private readonly IHttpContextAccessor _httpContextAccessor; public CartService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public void Add(int gameId, int quantity) { var cart = GetCart(); var existing = cart.FirstOrDefault(c => c.GameId == gameId); if (existing != null) existing.Quantity += quantity; else cart.Add(new CartItemDto { GameId = gameId, Quantity = quantity }); SaveCart(cart); } public List<CartItemDto> GetCart() { var json = _httpContextAccessor.HttpContext?.Session.GetString(CartSessionKey); return string.IsNullOrEmpty(json) ? new List<CartItemDto>() : System.Text.Json.JsonSerializer.Deserialize<List<CartItemDto>>(json) ?? new(); } private void SaveCart(List<CartItemDto> cart) { var json = System.Text.Json.JsonSerializer.Serialize(cart); _httpContextAccessor.HttpContext?.Session.SetString(CartSessionKey, json); } }CartItemDto是一个不能再简单的数据传输对象,只放 GameId、Quantity、游戏标题、单价、折扣率五六个字段。业务逻辑上加购时只存 GameId 和数量,查询购物车时再用 GameId 回查数据库拿最新价格——这叫「读时刷新价格」,确保用户最终看到的金额是当前价格,而不是加购瞬间的旧价格。
提示:Session 在 ASP.NET Core 里默认是启用的,但需要显式调用
builder.Services.AddSession(),并在中间件里app.UseSession(),顺序要放在UseRouting()之后。
4. 订单结算、事务与防超卖:仿 Steam 类项目最硬核的一环
4.1 订单生成:业务订单号和状态流转
用户从购物车点结算,进入创建订单流程。订单号不能依赖数据库自增 Id 直接展示给用户,因为自增容易暴露销量,而且合并订单号时容易撞。常见做法是时间戳加用户号加随机数:
public static string GenerateOrderNo(int userId) { var datePart = DateTime.Now.ToString("yyyyMMddHHmmss"); var userPart = userId.ToString("D6"); var randPart = Random.Shared.Next(1000, 9999).ToString(); return $"GS{datePart}{userPart}{randPart}"; }订单状态用字符串常量管理。常见的有PendingPayment(待支付)、Paid(已支付)、Completed(已完成)、Cancelled(已取消)。仿 Steam 的场景里Completed意味着游戏已经入库到用户的游戏库,购买行为完成。状态流转方向不可逆,从PendingPayment到Cancelled可以,从Paid到Cancelled属于退款流程,需要额外处理。
状态机写进一个静态类,防止散落的字符串拼写错误:
public static class OrderStatus { public const string PendingPayment = "PendingPayment"; public const string Paid = "Paid"; public const string Completed = "Completed"; public const string Cancelled = "Cancelled"; }4.2 事务内扣减库存:防超卖的两种写法
游戏商城的库存模型和实体商品有差异。Steam 是数字商品,理论上无限库存,但实际运营中会有激活码库存、限购活动库存。这里按有限库存处理。
下单扣减库存的代码可以这样写。假设库存字段是Game.Stock:
public async Task<IActionResult> CreateOrder() { var cartItems = _cartService.GetCart(); if (cartItems.Count == 0) return RedirectToAction("Index", "Cart"); await using var transaction = await _context.Database.BeginTransactionAsync(); try { var order = new Order { OrderNo = GenerateOrderNo(CurrentUserId), UserId = CurrentUserId, CreatedAt = DateTime.Now, Status = OrderStatus.PendingPayment }; decimal total = 0; foreach (var item in cartItems) { var game = await _context.Games.SingleAsync(g => g.Id == item.GameId); if (game.Stock < item.Quantity) { await transaction.RollbackAsync(); return BadRequest($"库存不足:{game.Title}"); } game.Stock -= item.Quantity; var orderItem = new OrderItem { OrderId = order.Id, GameId = game.Id, GameTitle = game.Title, UnitPrice = game.OriginalPrice, DiscountRate = game.DiscountRate, FinalPrice = game.OriginalPrice * (1 - game.DiscountRate) }; order.Items.Add(orderItem); total += orderItem.FinalPrice; } order.TotalAmount = total; _context.Orders.Add(order); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return RedirectToAction("Payment", "Order", new { orderId = order.Id }); } catch { await transaction.RollbackAsync(); return StatusCode(500); } }BeginTransactionAsync开启事务,SingleAsync查出游戏实体,game.Stock -= item.Quantity直接改内存实体,SaveChangesAsync时 EF Core 把整个对象的状态更新成一条 UPDATE 语句。事务保证了「查库存、扣库存、建订单」要么全部成功,要么全部失败回滚。
这个写法有个并发隐患。两个用户同时下单同一款游戏且只剩最后 1 个库存时,两边SingleAsync读到的Stock可能都是 1,两边都通过了if (game.Stock < item.Quantity)检查,两边都执行减一,最后数据库里 Stock 变成 -1,超卖了。
正确做法是把读和写在一条 SQL 里完成:
var affectedRows = await _context.Database.ExecuteSqlRawAsync( "UPDATE Games SET Stock = Stock - {0} WHERE Id = {1} AND Stock >= {0}", item.Quantity, item.GameId); if (affectedRows == 0) { await transaction.RollbackAsync(); return BadRequest($"库存不足:{item.GameId}"); }UPDATE Games SET Stock = Stock - {0} WHERE Id = {1} AND Stock >= {0}的意思是:只有当现有库存不小于扣减数量时才扣减,如果条件不满足则影响行数为 0,应用层判affectedRows == 0就知道抢失败了。数据库的行锁在这个 UPDATE 执行期间锁住这行记录,后到的请求会等待前一个事务提交或回滚,然后重新执行条件判断——这是标准的数据库层面乐观防超卖方案,比SingleAsync再检查可靠得多。
4.3 模拟支付:回调接口的幂等验证
仿 Steam 项目的支付环节做不到真正对接 Steam 钱包,常见做法是模拟支付或者对接沙箱支付网关。无论哪一种,支付回调接口的编写是核心:支付平台向你的服务端发一个 HTTP 请求,告诉你「用户的钱收到了」。
回调处理两个问题。验证签名——支付平台用密钥对回调参数签名,服务端用同样算法重新算一遍,不一致就拒绝。幂等——同一笔支付回调可能被发送多次,第一次把订单状态从未支付改成已支付,第二次如果发现已支付就直接返回成功,不能重复处理。
[HttpPost] public async Task<IActionResult> PaymentCallback([FromBody] PaymentNotifyModel model) { string sign = ComputeSign(model, _config["PaymentApi:SecretKey"]); if (!sign.Equals(model.Sign, StringComparison.OrdinalIgnoreCase)) return BadRequest("invalid sign"); var order = await _context.Orders.FirstOrDefaultAsync(o => o.OrderNo == model.OrderNo); if (order == null) return NotFound("order not found"); if (order.Status == OrderStatus.PendingPayment) { order.Status = OrderStatus.Paid; await _context.SaveChangesAsync(); } return Ok("success"); }ComputeSign是 MD5 或 HMAC-SHA256 的算法实现,_config从配置文件读密钥。回调接口不放在某个 Controller 的权限保护下,而是靠签名验证来保证安全——这个回调 URL 是公开的,伪造者不知道密钥就算出了合法签名。订单状态从待支付改成已支付之后,再来的重复回调直接返回success,不做重复处理。
提示:回调处理完成返回给支付平台的响应必须是「纯文本 success」,不是 JSON,不是 HTML,否则支付平台会认为你没收到回调而持续重试。
5. 首页性能和图片策略:仿 Steam 商城的几个可复用优化
5.1 大促首页 3 分钟缓存方案
仿 Steam 的首页挂着大图轮播、限时折扣、热门推荐一堆模块。每次访问都查库,数据库要扛的 QPS 很高。实际项目里首页用内存缓存,过期时间设 3 分钟,用IMemoryCache:
public class HomeService { private readonly IMemoryCache _cache; private readonly AppDbContext _context; public HomeService(IMemoryCache cache, AppDbContext context) { _cache = cache; _context = context; } public async Task<List<Game>> GetDiscountedGames() { const string cacheKey = "home_discounted_games"; if (_cache.TryGetValue(cacheKey, out List<Game>? cached)) return cached ?? new List<Game>(); var games = await _context.Games .Where(g => g.IsActive && g.DiscountRate > 0) .OrderByDescending(g => g.DiscountRate) .Take(8) .ToListAsync(); _cache.Set(cacheKey, games, TimeSpan.FromMinutes(3)); return games; } }TryGetValue命中缓存直接返回,没命中查数据库再塞进缓存。3 分钟过期时间是个经验值:太短缓存意义不大,太长会导致价格已更新但首页还显示旧价。游戏打折活动开始前,运营通常在后台改了折扣率,3 分钟内首页自动拉到新数据。
商品详情页不能这样缓存。/Game/Detail/42的库存、价格随时在变,而且详情页的缓存会让用户看到「已售罄但页面还能加购」的诡异状态。详情页只缓存截图地址列表这类静态数据,核心的价格库存每次查库。
5.2 Session 从内存搬进 Redis
第 3 章说过购物车用 Session 存,默认的 Session 存内存,App 重启就丢。生产环境给 Session 换 Redis 存储,重启不丢,跨实例共享。
安装包是Microsoft.Extensions.Caching.StackExchangeRedis,配置:
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "localhost:6379"; options.InstanceName = "SteamShop_"; }); builder.Services.AddSession(options => { options.Cookie.Name = ".SteamShop.Session"; options.IdleTimeout = TimeSpan.FromHours(2); });Configuration是 Redis 连接串,InstanceName是 key 前缀,同实例里的多个应用不会互相污染。IdleTimeout是会话空闲过期时间,仿 Steam 的用户可能把页面挂一上午再回来下单,设太短会在提交订单时莫名丢购物车。
5.3 发布部署:Kestrel 摆在 Nginx 后面
ASP.NET Core 应用自带 Kestrel 服务器,可以直接对公网服务,但实际部署通常是 Nginx 反代到 Kestrel。Nginx 处理静态资源、TLS 证书、负载均衡,Kestrel 专心跑应用。发布命令和 Nginx 配置:
dotnet publish -c Release -o /var/www/steamshopNginx 配置写入/etc/nginx/sites-available/steamshop:
server { listen 80; server_name shop.example.com; location / { proxy_pass http://localhost:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } location /images { alias /var/www/steamshop/static/images; expires 30d; } }proxy_pass把请求转给 Kestrel,proxy_set_header Host把原始域名传给后端。location /images把商品图直接拉出来让 Nginx 处理,设了expires 30d,浏览器缓存 30 天,这个配置给商城减少的图片请求压力非常可观。
5.4 压测验证:库存并发到底守住没有
部署完之后做一次并发验证。用一个简单的 .NET 控制台程序模拟 50 个并发请求下单同一款游戏,压前库存 50:
using var client = new HttpClient(); var tasks = new List<Task<HttpResponseMessage>>(); for (int i = 0; i < 50; i++) { tasks.Add(client.GetAsync("http://localhost:5000/Order/Create")); } var responses = await Task.WhenAll(tasks); var successCount = responses.Count(r => r.IsSuccessStatusCode); Console.WriteLine($"成功请求: {successCount}"); Console.WriteLine($"其余响应: {responses.Count(r => !r.IsSuccessStatusCode)}");Task.WhenAll并发发出 50 个 GET 请求,统计成功响应数。假如库存 50 且没有并发保护,这 50 个请求可能全部通过检查并扣库存,最后库存为 0 但订单建了 50 笔——没有超卖但扣减超过了实际控制的订单量。注意模拟时订单表里不能带绑定用户,否则同一用户并发加购会有其他业务干扰。这个压测的意义在于检验 4.2 节那条UPDATE ... WHERE Stock >= {0}的 SQL 是否真的把并发请求挡在了事务边界之外——观察点只有一个:数据表里的最终库存不能为负数。如果压出负数,先把SingleAsync改成条件更新 SQL。
本文还有配套的精品资源,点击获取