.NET网上商城源码:从架构设计到部署避坑的完整实践 简介基于.NET框架开发的网上商城源代码项目面向正在学习ASP.NET Web应用或电商系统开发的开发者完整呈现了电商平台的前端页面、后端逻辑、配置管理与多语言资源等核心模块。资源采用RAR格式压缩共151个文件以aspx/ascx页面、vb后台代码、resx资源文件、jpg/gif图片及css样式表为主另含Web.config配置文件、数据库文件mdf/ldf与说明文档压缩包仅2.24MB结构清晰便于按模块研读。目前已有128人学习适合通过源码研习全局事件处理、页面与业务逻辑分离、数据库交互及多语言资源定义等关键实现。借助该代码库开发者可快速理解.NET电商应用的整体架构并在此基础上进行功能定制或二次开发是一份小巧而完整的入门级实践参考。1. .net网上商城源代码别急着搜商城Demo先看这套能不能扛住真实订单做.NET开发的十有八九搜过“网上商城源代码”这种关键词尤其手里压着传统行业的改造需求时更是到处找能直接跑的Demo。但老实说市面上流传的商城源码两极分化严重要么是四五年前的Web Forms老古董连.NET Framework 4.5都跑不利索要么是阉割版的教学项目购物车用静态字典糊弄订单表不落库支付回调直接写死返回成功。这类代码拿来做毕设答辩还凑合真要往生产环境引加班费都赔不起。这份 .NET网上商城源代码的价值在于它把商品、购物车、订单、支付、后台管理串成了一整条能跑通的业务线用的还是当前主流的ASP.NET Core MVC加EF Core组合适合两类人一类是刚转.NET没多久、想看看真实商城业务怎么组织的开发者另一类是手里有定制化需求、想基于一套完整代码二次开发的实施人员。接下来我按自己拆这套代码的顺序把架构、核心模块、踩坑和部署挨个讲透。2. 架构与技术选型为什么这套源码用MVC而不是Web Forms以及三层结构怎么落地2.1 技术栈对比Web Forms确实没落但这不意味着MVC就是唯一解拿到一套.NET商城源码第一件事不是看功能列表而是看技术栈。我拆过的项目里技术栈决定了后续改造成本的上限。这套源码采用ASP.NET Core MVC而不是老牌的ASP.NET Web Forms这是个正确的取舍。Web Forms在.NET Framework时代确实统治了国内企业开发很多年但它把HTML和服务器控件绑得太死前端要嵌Vue或React时服务端控件事件模型会让你想砸键盘。MVC模式把请求路由到Controller再由View渲染HTML正好给前后端分离留了余地——后端返回JSON前端随便用什么框架Controller层改一行代码就行。Razor Pages是另一个经常被拿出来对比的选项。它的PageModel模型对简单页面很友好一个页面一个模型文件看起来整洁但商城这种业务模型复杂的场景页面之间有大量的数据共享和状态流转比如购物车数据要跨多个页面共享订单状态要在列表页和详情页之间协同。Razor Pages的PageModel天然是单页面作用域共享状态得依赖Session或TempData代码写起来反而绕。MVC的Controller天然支持依赖注入和作用域隔离一个ShoppingCartController管整个购物车生命周期逻辑集中度更高。选型还要看生态。MVC的Filter管道、ModelState验证、TagHelper这些在商城这种重交互场景里能省大量模板代码。而且MVC的Area机制天然适合商城这种按功能域拆分结构的场景——Admin区管理后台Api区处理微信支付回调Web区面向C端用户三个Area互不干扰。这套源码里虽然只用了两个Area但目录结构已经为扩展留好了位子。2.2 项目结构拆解三个工程文件分别管什么依赖方向在哪里整套源码不是一个单项目跑到底而是分成了三个工程商城Web主程序、业务逻辑层、数据访问层。这是标准的Clean Architecture变体——Web层只认识业务接口不认识数据库DAL层只管查询和持久化不承载业务判断。依赖方向是Web引用BLLBLL引用DAL禁止反向引用。这种分层最大的好处是单元测试好写。比如订单计算价格的逻辑在BLL里测试时不用启动Web服务器直接实例化OrderServiceMock一个内存版的IOrderRepository就能验证满减规则算得对不对。我见过把业务逻辑写在Controller里的商城代码一个下单动作要测试就得带起整条HTTP管道最终导致没人敢写测试越改越乱。从实际操作角度看拆三个工程还有个隐形好处发布时可以只发布Web主程序。BLL和DAL编译成DLL文件随主程序一起输出但代码组织上分开了后期维护时改数据库字段只需要动DAL重新编译后替换DLL就行不必把整个Web站点重新发布一遍。这对于那些已经上线、不能随便重启的商城项目来说是个救命特性。2.3 数据库设计重点SKU与SPU拆分、订单快照、索引取舍商城数据库设计翻车通常翻在商品模型上。新手设计商品表时往往一张表搞定所有字段颜色、尺寸、库存全塞在商品表里。这套源码的商品设计遵循了SKU与SPU拆分的标准范式SPU是商品抽象概念比如某个牌子的跑鞋SKU是具体可下单的规格组合比如黑色42码。Product表存SPUProductSku表存SKU每个SKU有独立的库存、价格、编码。这样设计的直接好处是下单时锁库存只锁SKU行不会因为某个规格缺货而影响整个商品的下单。订单表设计上最值得借鉴的是订单快照字段。所谓订单快照就是下单时把商品名称、购买时单价、商品图片URL完整地拷贝一份存在订单明细表里而不是下单后通过商品ID再去关联查询商品表。原因很实际商品价格会改SKU可能下架如果订单明细只存商品ID事后用户查看历史订单时商品名可能已经变了价格也未必对得上。电商平台碰到这类问题只能靠快照表兜底。这套源码用了两个字段ProductSnapshotJson存整个商品信息的JSONUnitPrice存下单时的精确价格。查询历史订单时优先读快照字段只有快照缺失时才回源查商品表。索引设计上有一个容易被忽略的点订单表的查询模式基本是“按用户查最近订单”和“按状态查待处理订单”。如果只给OrderId建了主键索引用户中心翻订单列表时大概率走全表扫描。源码里对UserCreateTime做了联合索引对OrderStatus单独做了索引这两个索引的取舍是基于实际查询语句设计的不是拍脑袋。但要注意索引不是越多越好商城订单表是高频写入表每多一个索引插入时的维护成本就多一点。过度索引的危险没准比缺索引更隐蔽——你会看到CPU和磁盘IO莫名其妙升高最后定位到是二级索引太多导致的写放大。2.4 依赖注入与服务注册Startup里那几十行配置到底改哪些.NET Core的依赖注入容器在商城项目里唯一的难点是生命周期选择。Singleton、Scoped、Transient三个周期用错轻则性能瓶颈重则数据错乱。这套源码里的注册原则值得抄作业仓储层用Scoped因为DbContext本身是Scoped的同一次请求内多次操作数据库应共享同一个DbContext实例业务服务层用Scoped和仓储层保持一致工具类例如MD5加密助手注册为Singleton因为它们是无状态的。以下是Program.cs里的典型服务注册代码我从这套源码里提炼出来的模式// Program.cs - 服务注册核心片段 var builder WebApplication.CreateBuilder(args); // 1. 配置数据库上下文SQL Server连接串从appsettings.json读取 builder.Services.AddDbContextShopDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection))); // 2. 仓储与业务服务注册Scoped生命周期保证一次请求内DbContext唯一 builder.Services.AddScopedIProductRepository, ProductRepository(); builder.Services.AddScopedIOrderRepository, OrderRepository(); builder.Services.AddScopedIOrderService, OrderService(); builder.Services.AddScopedIShoppingCartService, ShoppingCartService(); // 3. 工具类注册无状态Singleton即可 builder.Services.AddSingletonIEncryptionHelper, MD5EncryptionHelper(); // 4. Session配置商城购物车必备设置超时时间为30分钟 builder.Services.AddSession(options { options.IdleTimeout TimeSpan.FromMinutes(30); options.Cookie.HttpOnly true; options.Cookie.IsEssential true; // GDPR合规其实主要防止IE丢Cookie }); var app builder.Build();代码逻辑说明AddDbContext注册的是Dbcontext的工厂EF Core会在每次请求时自动创建新的DbContext实例配合Scoped的仓储使用能保证同一个HTTP请求里不管调用多少次仓储方法用的是同一个DbContext实例这样EF Core的ChangeTracker才能跨多次查询跟踪实体变更状态。如果仓储注册成SingletonDbContext是Scoped就会出现Singleton持有了Scoped对象实际运行时每次请求拿到的DbContext可能不是当前请求的那个导致数据错乱或并发冲突。参数说明IdleTimeout设置30分钟意味着用户购物车会话最多空闲半小时超过后Session自动失效用户需要重新登录。这个值不是越大越好对商城来说太大会在Redis或内存里堆积大量Session数据。IsEssential设置为true是给GDPR法规准备的它的含义是即使用户没同意Cookie策略这个Cookie也会被写入因为购物车属于网站核心功能。如果站点只在特定地区运营这个参数可以删除。3. 核心模块实现购物车、订单状态机与支付回调验签3.1 购物车设计的两种实现路径Session临时态与Redis持久态购物车看似简单实际是商城系统里第一个分水岭。这套源码的实现方式是用户未登录时购物车数据存Session登录后自动迁移到Redis。这个设计的目标很明确——既降低未登录用户的存储成本又避免登录用户换设备后购物车消失。Session方案的技术实现可以看下面的代码public class ShoppingCartService { private readonly IHttpContextAccessor _httpContextAccessor; private const string CartSessionKey CartSession; public ShoppingCartService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor httpContextAccessor; } // 将商品加入购物车存在Session中 public void AddItem(int skuId, int quantity) { var cart GetCartFromSession(); var existingItem cart.Items.FirstOrDefault(i i.SkuId skuId); if (existingItem ! null) { existingItem.Quantity quantity; } else { cart.Items.Add(new CartItem { SkuId skuId, Quantity quantity }); } SaveCartToSession(cart); } private ShoppingCart GetCartFromSession() { var session _httpContextAccessor.HttpContext.Session; var cartJson session.GetString(CartSessionKey); if (string.IsNullOrEmpty(cartJson)) { return new ShoppingCart { Items new ListCartItem() }; } return JsonSerializer.DeserializeShoppingCart(cartJson); } private void SaveCartToSession(ShoppingCart cart) { var session _httpContextAccessor.HttpContext.Session; session.SetString(CartSessionKey, JsonSerializer.Serialize(cart)); } }逻辑说明整个购物车的Session操作通过HttpContextAccessor访问Session对象这里有个陷阱——如果在非HTTP请求上下文里调用这个服务比如后台定时任务里HttpContextAccessor.HttpContext会抛NullReferenceException。早期我把购物车服务注入到后台任务里结果定时清理购物车数据时直接白屏后来才发现问题不在数据逻辑而在访问器的上下文局限。参数说明CartSessionKey是Session中的Key名你可以按业务需要改成不同的名称以支持多购物车逻辑比如“GiftCartSession”当礼品卡购物车用。但要注意SessionKey的命名空间全局共享不能两个业务逻辑用同一个Key但不共用同一个数据结构那会导致反序列化时Json格式不匹配直接抛异常。用户登录后从Session迁移到Redis的过程其实就是一个读Session、写Redis、清Session的三步操作public async Task MigrateCartFromSessionToRedis(string userId) { var sessionCart GetCartFromSession(); if (sessionCart.Items.Count 0) return; // 与Redis中的购物车合并 var redisCart await GetCartFromRedis(userId); foreach (var item in sessionCart.Items) { var existing redisCart.Items.FirstOrDefault(i i.SkuId item.SkuId); if (existing ! null) { existing.Quantity item.Quantity; } else { redisCart.Items.Add(item); } } await SaveCartToRedis(userId, redisCart); // 清空Session购物车 _httpContextAccessor.HttpContext.Session.Remove(CartSessionKey); }迁移逻辑的处理重点是合并时的数量累加而不是简单覆盖。如果用户先在浏览器里加了商品到Session购物车又用手机在Redis购物车里加了同SKU商品登录后合并时不能丢任何一边必须数量相加。Redis方案的存储结构建议用Hash而不是简单String。Hash可以支持按字段操作比如HIncrBy可以原子递增某个SKU的数量不用先读出整个对象再写回。高并发场景下String方案会有丢失更新的问题——两次请求同时读同一份购物车都加同一个SKU后写回的覆盖先写回的。3.2 订单状态机从待支付到已完成的状态流转控制下单流程是这个商城源码里最值得反复读的部分。它的订单状态机定义明确不是用简单的int字段加if判断而是用枚举加状态流转表。状态机的好处是后续接线下的业务操作时不会被野路子的状态跳转坑到。以下是订单状态的枚举定义public enum OrderStatus { PendingPayment 0, // 待支付 Paid 1, // 已支付等待发货 Shipped 2, // 已发货 Completed 3, // 已完成 Cancelled 4, // 已取消 Refunding 5, // 退款中 Refunded 6 // 已退款 }光有枚举还不够关键在状态流转的合法性校验。从Shipped直接跳到Refunded这在业务上可能是不允许的——已发货未收货的订单要退款得走退货流程而不是直接退款。源码里用了一个静态字典存合法流转路径public static class OrderStateMachine { // 定义合法状态流转矩阵 private static readonly DictionaryOrderStatus, ListOrderStatus _allowedTransitions new DictionaryOrderStatus, ListOrderStatus { { OrderStatus.PendingPayment, new ListOrderStatus { OrderStatus.Paid, OrderStatus.Cancelled } }, { OrderStatus.Paid, new ListOrderStatus { OrderStatus.Shipped, OrderStatus.Refunding } }, { OrderStatus.Shipped, new ListOrderStatus { OrderStatus.Completed, OrderStatus.Refunding } }, { OrderStatus.Completed, new ListOrderStatus { OrderStatus.Refunding } }, { OrderStatus.Refunding, new ListOrderStatus { OrderStatus.Refunded } }, { OrderStatus.Cancelled, new ListOrderStatus() }, // 终态 { OrderStatus.Refunded, new ListOrderStatus() } // 终态 }; public static bool CanTransition(OrderStatus current, OrderStatus target) { if (!_allowedTransitions.ContainsKey(current)) return false; return _allowedTransitions[current].Contains(target); } }逻辑说明这个状态机的价值在于所有修改订单状态的入口都要先调用CanTransition做校验不合法的流转直接抛异常或返回错误提示。比如用户刚下单还没支付后台管理员手滑点了“发货”因为状态机里PendingPayment到Shipped不合法操作会被拦截。这套机制可以根除那些“我倒要看看这个状态是怎么变过去的”的灵异问题。实际上我在自己写商城时会把状态机校验从Service层再抽到Attribute里做成AOP切面。这样Controller层调UpdateOrderStatus方法之前Filter会自动校验状态流转合法性代码侵入性更低。但如果团队对AOP不熟保持Service层调用前显式校验更稳毕竟魔法太少比魔法太多更好排查问题。3.3 并发安全处理扣库存必须用数据库原子操作商城源码最容易翻车的地方是下单扣库存。异步并发下如果扣减操作设计为“先读库存判断是否足够再减一写回库存”就会出现典型的超卖问题。两个用户同时读库存等于1都判断够都减一都写回最后库存变成0但卖出了两单。这种问诊洁癖式的写法在并发场景下毫无防御力。正确的做法是让数据库执行原子更新语句把判断和扣减合成一条SQL。EF Core的ExecuteSqlRaw方法可以直接执行原生SQL// SKU库存扣减 - 使用原子操作防止超卖 public async Taskbool TryDecreaseStockAsync(int skuId, int quantity) { var sql UPDATE ProductSkus SET Stock Stock - quantity WHERE Id skuId AND Stock quantity; var affectedRows await _dbContext.Database.ExecuteSqlRawAsync( sql, new SqlParameter(skuId, skuId), new SqlParameter(quantity, quantity)); return affectedRows 0; }逻辑说明UPDATE语句里的WHERE条件Stock quantity是关键SQL Server在更新行时会对满足条件的行加锁两个并发请求同时执行这条语句第二个请求要么等待第一个更新完成要么因为条件不满足更新0行。affectedRows返回值如果为0说明库存不足下单流程直接在业务层拦截并提示用户库存不够。这就是一次判断加扣减的原子性保证。参数说明quantity是用户购买数量不等于1是针对于购物车一次下单多件的情况。使用原生SQL而不是EF Core的LINQ写法原因是EF Core生成的Update语句在并发场景有竞态条件——它的WHERE子句里通常不包含Stock quantity这个条件你必须在业务代码里先查后改查和改之间就会有间隙。写原生SQL虽然不好维护但数据库层面的原子操作不可替代。注意一个跟扣库存强相关的坑订单取消或支付失败后要恢复库存恢复操作不能简单地把Stock加回去而应该同时记录库存流水。不加流水的话两周后复盘库存时发现账实不符你无法判断是超卖还是退款导致只能拍脑袋。给ProductSku加一张StockLog表每次增减都记录原因下单扣减/取消回补/手动调整一个最简单的日差值核对需求就能自动完成。3.4 支付回调验签被坑过的都知道回调处理失败的订单全是售后纠纷支付模块是商城系统的黑匣子大多数人第一次对接支付接口时都在回调验签上栽过跟头。这套源码里的支付处理逻辑是标准的“三验一改”验签名、验金额、验订单状态然后才修改订单状态。[HttpPost] [Route(api/payment/callback)] public async TaskIActionResult PaymentCallback() { // 1. 读取原始请求体 - 必须用原始字符串验签不能用反序列化后的对象 string rawBody; using (var reader new StreamReader(Request.Body, Encoding.UTF8)) { rawBody await reader.ReadToEndAsync(); } // 2. 解析回调数据 var callbackData JsonSerializer.DeserializePaymentCallbackModel(rawBody); // 3. 验签拿本地商户Key对签名做MD5-HMAC比对 string expectedSign GenerateSign(callbackData, _config[Payment:MerchantKey]); if (expectedSign ! callbackData.Sign) { return BadRequest(invalid sign); } // 4. 验金额回调金额必须与订单实付金额一致 var order await _orderRepository.GetByOrderNoAsync(callbackData.OrderNo); if (order null || order.TotalAmount ! callbackData.Amount) { return BadRequest(amount mismatch); } // 5. 验状态防止重复回调 if (order.Status OrderStatus.Paid) { return Ok(already paid); } // 6. 修改订单状态 await _orderService.UpdateOrderStatusAsync(order.Id, OrderStatus.Paid); return Ok(success); }逻辑说明整个回调处理的核心是“幂等”。支付平台可能因为网络问题多次回调同一个订单你的接口必须保证同一个订单不会被重复处理两次。这里通过先查订单状态判断是否已处理来保证幂等。另外验签时用的sign算法要跟支付平台文档完全一致多一个空格、少一个字段拼接都会导致验签失败。参数说明Payment:MerchantKey是商户密钥这个配置务必放到配置文件的机密区域或环境变量里不能硬编码在代码中。一旦密钥泄露别人可以伪造回调通知向你的服务器发送虚假的支付成功消息后果你懂的。还有一个容易被忽略的点回调接口的响应内容必须是支付平台能识别的明确文本。比如微信支付要求响应“success”字符串而且必须是纯文本不能返回JSON支付宝则要求返回“success”或“failure”。如果你返回的不是支付平台预期的格式它会认为回调失败并反复重试每隔几分钟骚扰一次你的服务器。我在一次对接中看不起文档直接返回了空的200状态码结果支付平台连续重推了八次直到运维找上门。4. 上线前必看的避坑记录五个翻车现场换来的经验4.1 坑一Session丢失用户购物车动不动就清空现象用户登录后把商品加入购物车跳转到结算页时购物车里的东西还行但刷新一下就没了或者用户从商品详情页跳转到首页再跳回来购物车数量归零。原因排查发现是Session存储在进程内In-Process当应用程序池回收或代码修改bin目录下的文件导致应用重启时内存中的Session全部丢失。在开发环境不明显因为很少重启应用池但生产环境只要有一次部署或回收所有在线用户的Session就没了。解决改用Redis或SQL Server存储Session。在appsettings.json中配置Redis连接串后在Program.cs中注册// 改用Redis存储Session builder.Services.AddStackExchangeRedisCache(options { options.Configuration builder.Configuration.GetConnectionString(Redis); }); builder.Services.AddSession(options { options.IdleTimeout TimeSpan.FromMinutes(30); options.Cookie.HttpOnly true; });配置完成后Session数据从内存迁到了Redis应用池再怎么重启也不影响在线用户购物车。其实从那以后我形成了个习惯任何涉及Session长驻数据的项目上线前第一件事就是确认Session不是In-Process模式宁可先把Redis搭好也不愿上线后收客诉。4.2 坑二EF Core查询性能翻车订单列表页秒开变十秒现象后台订单列表页第一次打开要3-4秒点击翻页后每页要6-7秒。排查发现页面只展示20条订单记录但关联的商品明细、用户信息、物流信息全部被EF Core查询出来。原因EF Core的导航属性懒加载在循环里触发了N1查询。订单列表页面遍历订单集合每个订单读取下面的OrderItems每读一次OrderItems就产生一条SQL20条订单加上关联数据实际产生了60多条SQL语句。数据库承受得住但网络往返的时间累积起来就慢了。解决使用Include显式预加载一次性把关联数据查出来// 使用Include预加载避免N1查询 var orders await _dbContext.Orders .Include(o o.OrderItems) .ThenInclude(item item.ProductSku) .Include(o o.User) .Where(o o.OrderStatus phase) .OrderByDescending(o o.CreateTime) .Skip(pageIndex * pageSize) .Take(pageSize) .ToListAsync();逻辑说明Include告诉EF Core在执行主查询时使用JOIN把关联表数据一并查出OrderItems和User等导航属性数据在一次数据库往返中全部加载完成。性能对比很直观同一份列表页从6秒降到了300毫秒。原因就是SQL数量从60条降到了1条。但凡事有代价。If Include嵌套太深生成的JOIN语句会把数据行膨胀很多倍如果一次Include超过三个级别就要考虑拆分查询或用显式加载。商城订单列表这种结构两个级别的Include是安全边界别再往深了加。4.3 坑三图片上传到服务器后访问404IIS路径居然大小写不敏感现象后台管理上传商品图片上传成功后预览显示404文件确实存到了服务器指定目录但浏览器访问就是打不开。在开发环境没问题部署到Windows服务器的IIS上就翻车。原因IIS的物理路径大小写不敏感但虚拟目录配置时Site根目录绑定错了。我把图片存在一个独立的Upload目录但IIS站点没有为Upload目录启用静态文件访问或者目录的应用程序名和存放路径不一致。解决先把图片存储路径从物理路径改为虚拟路径并在IIS站点中的处理程序映射中确认StaticFile模块已启用// 在Program.cs中添加静态文件中间件 app.UseStaticFiles(new StaticFileOptions { FileProvider new PhysicalFileProvider( Path.Combine(builder.Environment.ContentRootPath, Upload)), RequestPath /Upload });这段配置的意思是把服务器物理路径中的Upload目录映射到网站的 /Upload 虚拟路径下用户访问 https://你的域名/Upload/xxx.jpg 时IIS直接从物理目录读取文件返回。同时IIS无需额外配置代码层面就把路径对应关系锁死。还需要注意服务器上传目录的写入权限。默认情况下IIS应用程序池的身份用户通常是IIS_IUSRS对磁盘目录没有写入权限上传接口会报500。手动给Upload目录添加IIS_IUSRS用户的写权限即可。这条经验花了我一个下午才从事件日志里抠出来——权限问题真不比代码bug好查。4.4 坑四EF Core迁移误伤数据一条Update触发全表重建现象某次修改了商品表的一个字段长度执行数据库迁移后商品表的数据全部丢了。这丢数据的翻车现场回顾时心脏都漏了一拍。原因EF Core的Code First迁移默认行为在字段类型或长度发生变化时会尝试删除原表并重建新表以匹配新的模型定义。如果迁移脚本里没有显式指定保留数据SQL Server会先Drop原表再Create新表数据就这样被干了。解决有两个方案。方案一是对已有数据的表禁用数据丢失迁移警告在OnModelCreating中配置protected override void OnModelCreating(ModelBuilder modelBuilder) { // 禁用删除列/表时的数据丢失警告避免迁移自动Drop表 modelBuilder.EntityProductSku(entity { entity.Property(p p.SkuName).HasMaxLength(200); }); }但更稳妥的方案是每次迁移前检查生成的迁移文件如果看到Up方法里出现DropColumn或DropTable就该警觉手动修改迁移文件把删除和重建改成新增字段加数据拷贝。从那以后我每次执行迁移都会先运行dotnet ef migrations script生成SQL脚本在测试库执行一遍观察有没有警告。生产环境的迁移永远先备份数据库这是底线没有例外。4.5 坑五定时任务跑挂没有重试机制直接吞订单超时现象自动取消超时未支付订单的定时任务是商城系统的刚需没有它的话大量待支付订单会堆积库存一直被锁住。某次服务器重启后定时任务没有自动恢复用户下单未支付10分钟后库存不释放积压了上千单。原因使用的定时任务组件在进程重启后不能自动加载任务或者执行任务时抛了异常没有被捕获任务直接静默停止。解决给定时任务加双重保险。第一层任务执行代码里必须捕获所有异常并写日志public async Task ExecuteAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { try { // 查询所有超时未支付订单 var timeoutOrders await _orderRepository.GetTimeoutOrdersAsync(DateTime.Now.AddMinutes(-30)); foreach (var order in timeoutOrders) { await _orderService.CancelOrderAsync(order.Id); // 恢复库存 await _orderService.RestoreStockAsync(order.Id); } } catch (Exception ex) { // 异常必须记录日志不能静默吞掉 _logger.LogError(ex, 定时取消超时订单任务执行失败); } finally { // 无论是否异常等待下一轮执行 await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); } } }第二层注册为托管服务时设置成单例且启动执行builder.Services.AddHostedServiceCancelTimeoutOrdersTask();AddHostedService注册的后台服务会随应用启动而启动且默认是单实例运行不会重复触发。如果你用第三方的Quartz或Hangfire要仔细看持久化配置否则重启后任务列表丢失只能手动重新注册。踩过这个雷后我所有生产项目的定时任务都会额外配一套日志巡检每天检查任务心跳不靠记忆过日子。5. 部署与发布从本地到Windows服务器的完整流程5.1 发布前的必要配置连接串、密钥与日志分离部署.NET商城之前需要先清理代码里的配置残留。开发环境的数据库连接串和支付密钥绝对不能带到生产环境。这套源码的appsettings.Development.json和appsettings.Production.json是分开的发布时要确认使用哪个环境的配置。我一般会在csproj里设置多个发布Profile将Development和Production环境变量分开PropertyGroup Condition$(Configuration) Release EnvironmentNameProduction/EnvironmentName /PropertyGroup同时把密钥放到环境变量而不是配置文件。ASP.NET Core的配置系统原生支持环境变量覆盖你可以在系统中设置Payment__MerchantKey这样的环境变量名双下划线等价于配置层级冒号{ Payment: { MerchantKey: } }在Windows服务器上设置环境变量的命令是setx Payment__MerchantKey 这里填商户密钥设置完需要重启应用程序池才生效这是个容易忽略的细节环境变量是进程级别读取不重启进程就读不到。5.2 IIS部署步骤详解三处必须改的配置部署.NET Core应用需要确认服务器安装了.NET托管模块。安装完运行时后在IIS发布站点的具体步骤如下第一步发布项目。在Visual Studio中右键Web项目选择发布目标框架选Framework-Dependent这样服务器只要装了.NET运行时即可不用把运行时打进包。dotnet publish -c Release -o D:\Publish\ShopApp第二步在IIS中新建站点物理路径指向发布的文件夹。应用程序池选择“无托管代码”集成模式。.NET Core应用不受IIS托管管道影响它自带的Kestrel服务器担任宿主IIS通过反向代理转发请求。第三步修改web.config文件。这是部署时最容易被忽略的一步因为发布生成的web.config是针对开发环境的ASP.NET Core Module的hostingModel默认是InProcess。生产环境建议改成OutOfProcess让IIS和Kestrel分开进程应用崩溃时IIS仍能响应简单的健康检查请求configuration location path. inheritInChildApplicationsfalse system.webServer handlers add nameaspNetCore path* verb* modulesAspNetCoreModuleV2 resourceTypeUnspecified / /handlers aspNetCore processPathdotnet arguments.\ShopWeb.dll stdoutLogEnabledtrue stdoutLogFile.\logs\stdout hostingModelOutOfProcess environmentVariables environmentVariable nameASPNETCORE_ENVIRONMENT valueProduction / /environmentVariables /aspNetCore /system.webServer /location /configuration参数说明processPath指定dotnet可执行文件arguments指定应用入口DLL这两个值必须与发布目录的文件名一致。stdoutLogEnabled是开启标准输出日志首次部署时建议开启确认没有启动报错后再关闭避免日志文件无限膨胀。ASPNETCORE_ENVIRONMENT设为Production后应用会读取appsettings.Production.json里的配置。IIS部署后常见的坑是502.5错误它意味着进程启动失败。排查方式是看事件查看器里的.NET Runtime错误日志以及上面在web.config里开启的stdout日志文件。日志里会明确告诉你是缺少运行时、连接串格式错误还是端口占用比盲猜有效得多。5.3 HTTPS与反向代理全站HTTPS是底线不是加分项商城系统如果不启用HTTPS用户的登录密码、支付信息全走明文传输这在任何安全审计里都是致命伤。ASP.NET Core启用HTTPS重定向很简单app.UseHttpsRedirection(); app.UseHsts();UseHsts在开发环境中不生效需要满足条件是请求头已识别为HTTPS。但在IIS场景下用户请求先到IIS已配置HTTPS证书IIS转发到Kestrel时用的是HTTP协议Kestrel无法识别原始请求是否HTTPS那么UseHttpsRedirection会出现重定向死循环。正确做法是让IIS的URL Rewrite模块执行重定向逻辑代码里只保留HSTS头添加。IIS的web.config中加一条重写规则rewrite rules rule nameHTTPS Force stopProcessingtrue match url.* / conditions add input{HTTPS} patternoff ignoreCasetrue / /conditions action typeRedirect urlhttps://{HTTP_HOST}{REQUEST_URI} redirectTypePermanent / /rule /rules /rewrite这条规则的含义是当IIS检测到请求不是HTTPS时302重定向到等价的HTTPS地址。进行HTTP到HTTPS的301永久跳转对SEO友好搜索引擎会把旧HTTP页面的权重转移到HTTPS页面。部署HTTPS证书时有个检查点证书链是否完整。很多运维只装了服务器证书漏装中间证书导致Android手机访问时提示证书错误而Windows电脑因为带有中间证书缓存访问一切正常。检查方法是用浏览器访问站点点证书图标查看证书路径是否完整无缺。5.4 数据库连接串与发布后配置别把本地密码带到生产appsettings.Production.json里的连接串如果写的是本地SQL Server的实例名和sa密码发布到生产环境后应用能启动但连库瞬间报错这是最基础的配置管理失误{ ConnectionStrings: { DefaultConnection: Server.;DatabaseShopDb;User Idsa;Password你的数据库密码;TrustServerCertificateTrue; } }连接串里TrustServerCertificateTrue是SQL Server加密连接时的必要配置。如果不开这个选项.NET的SQL客户端在证书验证失败时会抛异常。生产环境如果不想用自签名证书就要装正式证书到SQL Server所在机器否则连接会一直报错。发布后还要检查一个细节数据库的防火墙规则是否放行应用服务器的IP。很多部署失败的案例不是代码问题而是数据库端口在云安全组里没开或者SQL Server只允许Windows身份验证而你用了SQL身份验证。这些属于基础设施排查但往往最耗时间。6. 进阶优化三板斧缓存设计、异步改造与二次开发切入线这套 .NET网上商城源码跑通以后核心数据表从几张变成几十张接口响应从毫秒级掉到秒级性能优化就得提上日程。不是说代码写得差而是真实流量的压力测试总会暴露单机架构的天花板。我在这套代码基础上做优化时有三板斧是必用的。第一板斧是给商品详情页加二级缓存。商品信息是最适合缓存的业务数据——读多写少变更不频繁且对实时性要求不极端。我采用MemoryCache加Redis的双层缓存方案进程内存缓存命中时直接返回零网络开销内存未命中则查RedisRedis也没有则查数据库并将结果回填到两级缓存。public async TaskProductDetailViewModel GetProductDetailAsync(int productId) { // 一级缓存进程内存Key包含商品ID string memoryKey $product:{productId}; if (_memoryCache.TryGetValue(memoryKey, out ProductDetailViewModel cachedProduct)) { return cachedProduct; } // 二级缓存RedisKey与内存缓存一致方便失效同步 string redisKey $product:{productId}; var redisProduct await _redisCache.GetAsyncProductDetailViewModel(redisKey); if (redisProduct ! null) { // 回填到内存缓存5分钟过期 _memoryCache.Set(memoryKey, redisProduct, TimeSpan.FromMinutes(5)); return redisProduct; } // 三级数据库查询 var product await _productRepository.GetProductDetailAsync(productId); if (product ! null) { // 回填两级缓存Redis过期时间设长一些为30分钟 await _redisCache.SetAsync(redisKey, product, TimeSpan.FromMinutes(30)); _memoryCache.Set(memoryKey, product, TimeSpan.FromMinutes(5)); } return product; }逻辑说明三级回填的核心思路是“缓存雪崩防护”。内存缓存5分钟失效防止数据长时间不一致Redis缓存30分钟兜底防止数据库被瞬间打穿。商品价格更新后需要主动失效缓存不能等过期自动刷新。失效方式也很简单后台保存商品成功后手动调用DistributedCache.RemoveAsync来删掉这个商品ID对应的缓存键。参数说明内存缓存和Redis缓存使用同一个Key前缀方便统一管理。不同的业务模块可以用不同的前缀区分product:、cart:、order:这样排查缓存问题时能通过Key前缀快速定位。缓存过期时间没有标准答案根据业务接受的延迟和数据一致性要求做权衡。商品信息我设5分钟其实有点保守如果被访问热度过高可以调到15分钟但要加一个后台定时刷新机制保证不出现商品价格变了但页面还显示旧价格的情况。第二板斧是异步改造。同步方法在ASP.NET Core里不仅浪费线程池资源而且在IoT高并发场景下会造成线程饥饿。这套源码里数据库操作已经用了async方法但有些业务帮助类还是同步的。比如发送通知邮件、生成订单PDF、写操作日志这些慢IO操作如果不加await会阻塞当前请求线程直到操作完成。// 同步阻塞方式 - 线程会卡住等待邮件服务器响应 _emailService.SendEmail(userEmail, 订单确认, emailContent); // 异步方式 - 立即返回结果邮件发送在后台完成 await _emailService.SendEmailAsync(userEmail, 订单确认, emailContent);异步改造的边界要把握好一个方法如果内部有三个以上异步调用且它们之间没有依赖关系应该用Task.WhenAll并行执行而不是逐行await串行等待if (order.Status OrderStatus.Paid) { // 这三个操作互相独立可以并行执行 Task sendEmailTask _emailService.SendEmailAsync(userEmail, 订单已支付, emailContent); Task writeLogTask _logService.WriteOperationLogAsync(OrderPaid, order.Id); Task updateStockTask _stockService.UpdateStockSnapshotAsync(order.Id); await Task.WhenAll(sendEmailTask, writeLogTask, updateStockTask); }Task.WhenAll会把三个异步任务并发发起在IO层面同时进行网络请求和数据库写入总耗时从三次顺序执行的3倍时间压缩到最大单个任务的耗时。异步IO的收益在高并发下特别明显——线程在await期间不被占用能立即处理其他请求吞吐量能翻上去。第三板斧是二次开发的切入线。这套源码的改造入口我从易到难排序首先是配置文件。商品展示数量、默认排序规则、图片上传大小限制、Session有效期这些参数几乎都在appsettings.json里集中管理。改配置就能满足的修改就不要改代码。然后是从表现层加功能。如果想给商品详情页加一个“浏览历史”模块只需要在ProductController的Detail方法里加一段Session读写逻辑不影响任何现有业务代码。这是最安全的侵入方式风险仅限单个页面。再往后是给Service层加接口扩展。如果要在下单流程中增加优惠券逻辑需要在IOrderService里加一个ApplyCouponAsync方法然后在OrderService中实现最后在Controller中调用。关键是不要修改PostOrder的原有逻辑——新建一个新方法包装原有流程添加优惠券计算这是灰度发布的开闭原则。最复杂的改造是数据层。如果要增加一种新的促销类型比如满三件打八折促销不仅要改数据库表结构还要修改ProductSku的查询语句、OrderItem的价格计算逻辑甚至要调整订单状态机的部分流转。这类改造动手前建议先画一张促销类型的状态流转图和现有订单状态机的交互边界理清楚后再动代码不然一个字段的变动会顺着对象引用链影响十几个页面。这套源码我实际拆完加二次开发后最大的感悟是商城的难点不在增删改查而在状态管理和并发控制。订单状态机、库存原子扣减、支付回调幂等这三个点吃透了遇到任何商城类项目心里都有底。所有分布式失效、数据丢失、重复处理的惨案都绕不开这三条线。现在每当我接到一个新的商城需求第一反应不是找Demo代码抄页面而是先把订单状态流转图画出来把库存扣减的并发方案定下来最后才考虑界面长什么样。这个习惯帮我少加了很多班希望也能帮到你。本文还有配套的精品资源点击获取