简介:网上商城购物系统源码基于C#与ASP.NET开发,面向计算机专业课程设计、毕业设计以及希望熟悉Web购物流程的初中级开发者,可作为商品展示、购物车、订单处理与后台管理等模块的完整实现参考。压缩包共205个文件、约1.51MB,含43个cs业务源码、30个aspx页面、6个ascx用户控件,以及gif/jpg界面素材、db/mdf/ldf数据库文件和配置说明,覆盖表现层、逻辑层、数据层与静态资源,分层与目录组织较清晰。已有676人学习下载,具备一定参考价值。读者可重点了解用户控件复用、商品浏览、购物车增减、订单列表与订单修改等流程,也能借助现成页面结构和数据库文件快速搭建练习环境,用于功能对照、代码阅读和二次改造,减少从零搭建购物系统时在页面组织与数据衔接上的摸索。
1. 网上商城购物系统(C# 版):一份能从前台逛到后台发货的完整源码
上周帮人看一个接手的老项目:前台商品页能打开,点加购物车报 500,后台订单列表永远是空的。翻了两个小时才发现,问题既不在控制器也不在 SQL,而是初始化脚本里少跑了一张表,外键全断。这类「界面看着完整、跑起来处处是坑」的处境,恰恰是拿到一份网上商城购物系统(C# 版)源码后最常见的开局。这份C# 源码把商品、分类、购物车、订单、会员、后台管理六条主流程串成了一个闭环,技术选型是 ASP.NET MVC 5 + ADO.NET 三层 + SQL Server,前后台一体,业务表结构清晰,适合拿来当管理系统类项目的骨架,也适合想把电商下单链路从头理顺一遍的开发者。接下来按「结构怎么分、请求怎么走、钱怎么算、坑怎么躲、功能怎么加」拆下去。
2. 解决方案结构拆解:C# 三层架构在这个商城里怎么落地
2.1 从 .sln 到五个项目:每层到底管什么
打开解决方案,第一眼看到的是五个类库加一个 Web 项目。很多人拿到源码习惯直接从 Default.aspx 或者 HomeController 往下读,读三层就迷路。先看目录,比读代码省时间。
ShopMall/ ├── ShopMall.Web/ # 表现层:Controllers、Views、Content、Scripts、Uploads │ ├── Controllers/ # HomeController、ProductController、CartController、OrderController、Admin/* │ ├── Views/ # Razor 视图,前后台分两个目录 │ ├── Content/ # Bootstrap 3 + 自定义样式 │ └── Web.config # 连接字符串、Session 配置、上传限制 ├── ShopMall.BLL/ # 业务层:ProductService、CartService、OrderService、UserService ├── ShopMall.DAL/ # 数据层:SqlHelper、ProductDal、OrderDal、UserDal ├── ShopMall.Model/ # 实体层:Product、Order、OrderItem、CartItem、User └── ShopMall.Common/ # 工具层:LogHelper、Md5Helper、PagerHelper、CacheHelper依赖方向是单向的:Web 引用 BLL 和 Model,BLL 引用 DAL 和 Model,DAL 引用 Model,Common 被所有层引用,Model 谁也不引用。这条线守住了,改一个 SQL 不会牵动页面,换一套 UI 也不用碰业务逻辑。我在类似项目里见过最糟的改法,是在 Controller 里直接 new SqlConnection 写 SQL,半年后要加一个「商品下架不删除」的需求,得把十几个 Action 全翻一遍。
实体层用的是贫血模型,只有属性和少量计算属性,没有 ORM 的导航属性,也就没懒加载那套玄学。这么做的好处是查询走向完全可预测:哪一页慢,去 DAL 里找对应方法就行,不用猜 EF 生成了什么 SQL。
// ShopMall.BLL/ProductService.cs public class ProductService { private readonly ProductDal _dal = new ProductDal(); // pageIndex 从 1 开始,pageSize 由前端传入但这里做上限保护 public PagedResult<Product> GetList(int pageIndex, int pageSize, int categoryId) { if (pageIndex < 1) pageIndex = 1; if (pageSize < 1 || pageSize > 60) pageSize = 12; var total = _dal.CountOnSale(categoryId); var items = _dal.GetPaged(pageIndex, pageSize, categoryId); return new PagedResult<Product>(items, total, pageIndex, pageSize); } }pageSize的上限保护经常被忽略。没有这行判断,前端改个参数就能一次拉几万条商品出来,服务器内存直接起飞。PagedResult<T>里同时带着总数、当前页和总页数,视图里翻页控件直接读属性,不用再算一遍。
2.2 八张核心表撑起整个商城
商城类项目的表结构其实高度相似,看懂了这几张,看别的系统也能对上号。
| 表名 | 作用 | 关键字段 | 注意点 |
|---|---|---|---|
| Users | 前台会员 | UserId、UserName、PasswordHash、Salt、Status | 密码不存明文,存 MD5+盐 |
| Categories | 商品分类 | CategoryId、ParentId、SortOrder | ParentId 为 0 表示一级分类 |
| Products | 商品主表 | ProductId、Price、Stock、Sales、IsOnSale、IsDeleted | 价格用 decimal(18,2) |
| ProductImages | 商品图 | ImageId、ProductId、ImageUrl、IsMain | 一个商品多图,IsMain 取封面 |
| Orders | 订单主表 | OrderNo、UserId、TotalAmount、Status、CreateTime | OrderNo 唯一索引 |
| OrderItems | 订单明细 | ItemId、OrderNo、ProductId、UnitPrice、Quantity | 单价必须快照,不关联商品现价 |
| Addresses | 收货地址 | AddressId、UserId、Receiver、Phone、Detail | 下单时也做快照 |
| Admins | 后台账号 | AdminId、Account、PasswordHash、RoleId | 前后台账号体系分开 |
OrderItems里的UnitPrice要存下单那一刻的价格,不能下单后现查Products.Price。这条经验是踩过的:商品调价之后,历史订单金额跟着变,财务对账直接对不上。同理,收货地址也要在订单上留一份快照字段,用户改了默认地址,旧订单不该跟着变。
2.3 配置与连接字符串:跑不起来多半卡在这几个节点
Web.config里有三处必改,改完才能编译运行。
<configuration> <connectionStrings> <!-- 三个参数必须对:服务器实例名、数据库名、账号密码 --> <add name="ShopMallConn" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=ShopMall;User Id=sa;Password=你的密码;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" /> </connectionStrings> <system.web> <!-- 超时 30 分钟,购物车放 Session 里,太短会丢 --> <sessionState mode="InProc" timeout="30" /> <!-- 商品图上传,默认 4MB 不够用 --> <httpRuntime targetFramework="4.7.2" maxRequestLength="10240" /> <authentication mode="Forms"> <forms loginUrl="~/Admin/Account/Login" timeout="120" /> </authentication> </system.web> </configuration>MultipleActiveResultSets=True不是可选项。三层架构里读 Reader 的同时再发起一次查询,没有这个参数就会抛「已有打开的与此 Command 相关联的 DataReader」。maxRequestLength单位是 KB,10240 就是 10MB,改完还要去 IIS 的requestFiltering里同步改maxAllowedContentLength,两处不一致是上传翻车的经典原因。
3. 商品浏览与购物车:一次加购请求的完整链路
3.1 商品列表分页:SQL 与 DAL 层怎么写
列表页是流量入口,写法直接决定首屏速度。项目里用的是 SQL Server 的OFFSET FETCH,比ROW_NUMBER()套子查询干净。
-- 只查上架且未删除的商品,销量倒序;OFFSET 必须配合 ORDER BY 使用 SELECT ProductId, ProductName, Price, Stock, MainImage, Sales FROM Products WHERE IsDeleted = 0 AND IsOnSale = 1 AND (@CategoryId = 0 OR CategoryId = @CategoryId) ORDER BY Sales DESC, ProductId DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;@CategoryId = 0表示查全部分类,这是种偷懒但好用的写法,省得在 C# 里拼 WHERE 子句。ORDER BY后面跟了ProductId DESC作为第二排序键,保证销量相同时分页结果稳定,不会出现第二页重复出现第一页的商品。
对应的 DAL 方法:
// ShopMall.DAL/ProductDal.cs public IList<Product> GetPaged(int pageIndex, int pageSize, int categoryId) { const string sql = @"SELECT ... OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY"; var ps = new[] { new SqlParameter("@CategoryId", categoryId), new SqlParameter("@Offset", (pageIndex - 1) * pageSize), // 从 0 开始的偏移量 new SqlParameter("@PageSize", pageSize) }; var list = new List<Product>(); using (var reader = SqlHelper.ExecuteReader(sql, ps)) { while (reader.Read()) { list.Add(new Product { ProductId = Convert.ToInt32(reader["ProductId"]), ProductName = reader["ProductName"].ToString(), Price = Convert.ToDecimal(reader["Price"]), Stock = Convert.ToInt32(reader["Stock"]) }); } } return list; }SqlHelper.ExecuteReader返回SqlDataReader,调用方using包住,连接在 Helper 内部用CommandBehavior.CloseConnection绑定,Reader 关闭时连接自动释放。这套写法简单,但要注意 Reader 必须在同一线程读完,异步场景下要换成ExecuteReaderAsync,不然会卡线程池。
价格字段统一Convert.ToDecimal。见过有人写成double.Parse的,商品列表价格显示 19.900000000000002,用户截图来投诉,这就是浮点数的锅。
3.2 购物车数据结构:Session 里到底存了什么
这个版本的购物车没落库,直接放 Session,键名固定为CART_SESSION,值是List<CartItem>。对中小流量够用,也没有「用户没登录不能加购」的门槛。
// ShopMall.Model/CartItem.cs [Serializable] // InProc 下不是必须,但换 StateServer 或 Redis 就必须 public class CartItem { public int ProductId { get; set; } public string ProductName { get; set; } public decimal UnitPrice { get; set; } // 加购时刻的价格 public int Quantity { get; set; } public string MainImage { get; set; } public decimal Subtotal => UnitPrice * Quantity; }// ShopMall.BLL/CartService.cs public class CartService { private const string CartKey = "CART_SESSION"; private const int MaxQuantityPerItem = 99; public List<CartItem> Current { get { var cart = HttpContext.Current.Session[CartKey] as List<CartItem>; return cart ?? new List<CartItem>(); } } public void Add(CartItem item) { var cart = Current; var exist = cart.FirstOrDefault(x => x.ProductId == item.ProductId); if (exist != null) { // 同一商品累加数量,但封顶 99,防止前端刷数量 exist.Quantity = Math.Min(exist.Quantity + item.Quantity, MaxQuantityPerItem); } else { item.Quantity = Math.Min(item.Quantity, MaxQuantityPerItem); cart.Add(item); } HttpContext.Current.Session[CartKey] = cart; // 值类型集合必须回写 } }Session[CartKey] = cart这行不能省。List<T>是引用类型,理论上改了里面的元素不用回写,但一旦 Session 模式切到 StateServer 或者 Redis,每次读取都是反序列化出来的新对象,不回写就等于没改。我一般养成「改完必回写」的习惯,省得将来换 Session 存储时背这个锅。
加购时价格取的是加购那一刻的Products.Price,但结算时必须重新查一次价格,这是后面下单章节要强调的点。
3.3 金额计算与前端联动:小计、运费、优惠的边界
购物车页要显示商品总额、运费、应付总额,这三项的计算集中在 BLL 里,视图只负责渲染。
// ShopMall.BLL/CartService.cs public CartSummary BuildSummary(List<CartItem> items) { if (items == null || items.Count == 0) return CartSummary.Empty; // 商品小计:decimal 保证精度,绝不用 double decimal goodsAmount = items.Sum(x => x.UnitPrice * x.Quantity); // 运费规则:满 99 免运费,否则 8 元;这里规则硬编码,后续可挪到配置表 decimal freight = goodsAmount >= 99m ? 0m : 8m; // 应付总额四舍五入到分,AwayFromZero 与财务口径一致 decimal payable = Math.Round(goodsAmount + freight, 2, MidpointRounding.AwayFromZero); return new CartSummary { GoodsAmount = goodsAmount, Freight = freight, Payable = payable, TotalQuantity = items.Sum(x => x.Quantity) }; }三个金额字段分开返回,不做「随便给个总数」。购物车页面要显示「还差 X 元免运费」,缺了GoodsAmount这个字段就得前端再算一遍,前后端两套算法迟早对不上。MidpointRounding.AwayFromZero是必须要写的,.NET 默认的银行家舍入会把 0.125 舍成 0.12,和收银台习惯不一样,差一分钱的事故就是这么来的。
前端联动这块,数量加减按钮通过 Ajax 调CartController.UpdateQty,服务端返回最新的CartSummary的 JSON,页面用 jQuery 更新三个数字,不整页刷新。
// 数量变更后只刷新金额区域,避免整页重绘丢失滚动位置 function updateQty(productId, qty) { $.post('/Cart/UpdateQty', { productId: productId, quantity: qty }, function (res) { if (!res.success) { alert(res.message); return; } $('#goodsAmount').text('¥' + res.data.goodsAmount.toFixed(2)); $('#freight').text('¥' + res.data.freight.toFixed(2)); $('#payable').text('¥' + res.data.payable.toFixed(2)); }); }服务端一定要再校验一遍quantity是否在 1 到 99 之间、商品是否上架、库存是否够。前端传什么都得当成不可信输入,这条没有例外。
4. 下单与库存扣减:订单状态机和事务边界
4.1 下单主流程:从购物车到 Orders 与 OrderItems
下单是整个系统里唯一必须开事务的地方。它要同时完成五件事:写订单主表、写订单明细、扣库存、加销量、清空购物车。任何一步失败都得整体回滚,不然就出现「订单生成了但库存没扣」或者「库存扣了但订单没生成」的对不上账。
// ShopMall.BLL/OrderService.cs public OrderResult CreateOrder(int userId, List<CartItem> cart, int addressId, string remark) { if (cart == null || cart.Count == 0) return OrderResult.Fail("购物车是空的"); // 1. 服务端重新查价格与库存,前端传来的金额一律不采信 var products = new ProductDal().GetByIds(cart.Select(x => x.ProductId).ToArray()); var details = new List<OrderItem>(); decimal goodsAmount = 0m; foreach (var item in cart) { var p = products.FirstOrDefault(x => x.ProductId == item.ProductId); if (p == null) return OrderResult.Fail($"商品 {item.ProductName} 不存在"); if (!p.IsOnSale) return OrderResult.Fail($"商品 {p.ProductName} 已下架"); if (p.Stock < item.Quantity) return OrderResult.Fail($"商品 {p.ProductName} 库存不足"); details.Add(new OrderItem { ProductId = p.ProductId, ProductName = p.ProductName, UnitPrice = p.Price, // 快照当前价格 Quantity = item.Quantity }); goodsAmount += p.Price * item.Quantity; } decimal freight = goodsAmount >= 99m ? 0m : 8m; var order = new Order { OrderNo = DateTime.Now.ToString("yyyyMMddHHmmss") + new Random().Next(1000, 9999), UserId = userId, AddressId = addressId, TotalAmount = Math.Round(goodsAmount + freight, 2), Status = (int)OrderStatus.WaitPay, Remark = remark, CreateTime = DateTime.Now }; // 2. 事务:写主表 + 写明细 + 扣库存,三步同生共死 using (var conn = new SqlConnection(SqlHelper.ConnString)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { var orderId = new OrderDal().Insert(conn, tran, order); new OrderItemDal().InsertBatch(conn, tran, orderId, details); new ProductDal().ReduceStock(conn, tran, details); // 内部做条件更新 tran.Commit(); return OrderResult.Ok(order.OrderNo); } catch (Exception ex) { tran.Rollback(); LogHelper.Error("创建订单失败", ex); return OrderResult.Fail("下单失败,请稍后重试"); } } } }几个细节值得说。OrderNo用「时间戳 + 4 位随机数」生成,实际生产里更稳的做法是加一列自增序列或者用雪花算法,随机数在并发高的时候有碰撞可能,虽然加了唯一索引兜底,但会抛异常给用户。这里的details是在事务外就组装好的,事务内只做写入,事务持有时间越短越好,连接池不容易被耗尽。
ReduceStock传的是conn和tran,不是自己新开连接。三层项目里很常见的错误是 DAL 方法内部using (var conn = new SqlConnection(...)),事务就断了,后面的操作根本不在同一个事务里,回滚只回滚了主表。
下单成功后要清购物车,这一步可以放在事务提交之后,因为它不影响账务一致性:
// 事务提交成功后再清空 Session,失败时购物车保留,用户可以重试 HttpContext.Current.Session.Remove("CART_SESSION");如果把它塞进事务里,事务回滚了购物车也空了,用户得重新挑一遍商品,体验很差。
4.2 库存扣减与并发:为什么不能先查再减
新手写扣库存,常见的是先在 C# 里if (p.Stock >= qty)判断,然后UPDATE Products SET Stock = Stock - @Qty。单线程跑没问题,两个人同时下单,都读到库存 1,都判断通过,最后库存变成 -1。
正确做法是把判断和扣减压进一条 SQL,让数据库来做原子操作:
-- 条件更新:库存不够时影响行数为 0,调用方据此判断是否回滚 UPDATE Products SET Stock = Stock - @Qty, Sales = Sales + @Qty WHERE ProductId = @Pid AND Stock >= @Qty AND IsOnSale = 1;// ShopMall.DAL/ProductDal.cs public void ReduceStock(SqlConnection conn, SqlTransaction tran, List<OrderItem> details) { foreach (var d in details) { var ps = new[] { new SqlParameter("@Pid", d.ProductId), new SqlParameter("@Qty", d.Quantity) }; // 返回受影响行数,0 表示没扣成功(库存不足或已下架) int rows = SqlHelper.ExecuteNonQuery(conn, tran, "UPDATE Products SET Stock = Stock - @Qty, Sales = Sales + @Qty " + "WHERE ProductId = @Pid AND Stock >= @Qty AND IsOnSale = 1", ps); if (rows == 0) throw new InvalidOperationException($"商品 {d.ProductName} 库存不足,下单失败"); } }抛异常的目的是触发外层事务的Rollback,前面已经写进去的订单主表、明细全部撤销,库存也不会出现扣了一半的情况。WHERE Stock >= @Qty这一句是整段的核心,它把「判断」和「扣减」做成了一次原子操作,SQL Server 会在行上加锁,第二个请求进来时读到的就是扣完后的值。
如果业务上允许超卖(比如预售),就把这个条件去掉,改成允许负库存然后人工补。但只要不是明确要求,一律走条件更新。
4.3 订单状态流转:待付款到已完成
订单状态用枚举定义,存库时存 int,避免改文案时动数据。
// ShopMall.Model/OrderStatus.cs public enum OrderStatus { WaitPay = 10, // 待付款 Paid = 20, // 已付款,待发货 Shipped = 30, // 已发货 Completed = 40, // 已完成 Cancelled = 90, // 已取消 Refunded = 99 // 已退款 }状态不允许随便改,必须按白名单走。加一个CanTransferTo方法做守卫,后台点「发货」时先校验当前状态是不是Paid,防止把待付款订单直接发出去。
// ShopMall.BLL/OrderService.cs private static readonly Dictionary<OrderStatus, OrderStatus[]> Allowed = new() { [OrderStatus.WaitPay] = new[] { OrderStatus.Paid, OrderStatus.Cancelled }, [OrderStatus.Paid] = new[] { OrderStatus.Shipped, OrderStatus.Refunded }, [OrderStatus.Shipped] = new[] { OrderStatus.Completed, OrderStatus.Refunded }, [OrderStatus.Completed] = new OrderStatus[0], [OrderStatus.Cancelled] = new OrderStatus[0], [OrderStatus.Refunded] = new OrderStatus[0] }; public bool CanTransferTo(OrderStatus from, OrderStatus to) => Allowed.TryGetValue(from, out var list) && list.Contains(to);后台的改状态接口统一调这个方法,返回 false 就提示「当前状态不允许该操作」。有了这层守卫,测试提的「已取消订单还能发货」这种 bug 基本不会再冒出来。取消订单还要把库存加回去,走的是UPDATE Products SET Stock = Stock + @Qty,同样放在事务里,和状态变更一起提交。
5. 跑起来与排查:环境、初始化脚本和几个血泪坑
5.1 本地环境搭建:顺序错一步就得重来
环境要求不高,但顺序有讲究:先建库跑脚本,再改配置,最后编译运行。反过来做会浪费很多时间在排查「为什么连接不上」上。
- 开发工具:Visual Studio 2019 及以上,安装「ASP.NET 和 Web 开发」工作负载
- 运行时:.NET Framework 4.7.2(项目目标框架,装错版本会提示找不到引用)
- 数据库:SQL Server 2016 及以上,Express 版够用
- Web 服务器:调试用 IIS Express,部署用 IIS 10 集成模式
数据库初始化用sqlcmd一条命令就能跑完,脚本里包含建库、建表、索引和一批演示数据。
# -S 指定实例名,Express 版默认是 .\SQLEXPRESS # -i 指向初始化脚本,脚本里已经写好 USE ShopMall sqlcmd -S .\SQLEXPRESS -U sa -P "你的密码" -i db/ShopMall_Init.sql脚本跑完,SSMS 里刷新一下应该能看到 8 张核心表加若干辅助表。缺表说明脚本中途报错但被忽略了,翻一下窗口里Msg开头的行。演示数据里的商品图存放在Uploads/目录,用的是相对路径,别把目录删了。
5.2 常见问题排查:五个踩过的坑
坑一:后台录入的中文变成问号
现象是商品名称在后台输入框里打「测试商品」,保存后列表里显示???。原因是数据库的默认排序规则是SQL_Latin1_General_CP1_CI_AS,表字段用了varchar。改成nvarchar并且把排序规则换成Chinese_PRC_CI_AS,已存在的表用ALTER TABLE Products ALTER COLUMN ProductName nvarchar(100)逐列改。参数化查询里SqlParameter会自动带N前缀,但如果有人手工拼 SQL,那就得自己写N'中文'。
坑二:路由全部 404,只有静态页面能开
现象是首页能显示,点任何菜单都报 404。原因八九成是部署到 IIS 时应用程序池用了「经典模式」,MVC 的路由模块不工作。改应用程序池为「集成模式」即可,实在改不了就在Web.config的system.webServer/modules里加runAllManagedModulesForAllRequests="true",但这是兜底方案,会增加所有静态资源的处理开销。
坑三:商品图上传成功,前台却显示裂图
上传接口返回成功,Uploads/目录里也确实有文件,但前台<img>是空白。两个原因叠在一起:一是保存进数据库的是绝对路径D:\...\Uploads\a.jpg,换个环境就不对了;二是Uploads目录没有给IIS_IUSRS写权限,实际写入被重定向到了临时目录。解决方式是存相对路径~/Uploads/xxx.jpg,然后在 IIS 里给目录加写权限,别用Everyone。
坑四:订单金额总是差一分钱
对账时发现订单总额和明细加总差 0.01。原因是早期某段代码用了double计算运费,0.1 + 0.2结果是0.30000000000000004,四舍五入后就偏了。全部换成decimal,并把数据库字段从float改成decimal(18,2),所有Math.Round显式指定MidpointRounding.AwayFromZero。
坑五:两个人同时下单,库存变成负数
现象是并发压测时出现负库存。原因是 DAL 里先SELECT Stock判断再UPDATE,两个请求都读到了旧值。改成 4.2 节那种条件更新,把库存判断写进WHERE子句,受影响行数为 0 就抛异常回滚事务。改完之后压测 200 并发,库存最低停在 0,没有出现负数。
5.3 日志与错误定位:先看黑匣子再看代码
排查线上问题,最怕的是没有任何线索。这个项目的Common/LogHelper按天写文件,落在App_Data/Logs/yyyy-MM-dd.log,Global.asax里挂了Application_Error,所有未捕获异常先记日志再跳错误页。
// ShopMall.Web/Global.asax.cs protected void Application_Error(object sender, EventArgs e) { var ex = Server.GetLastError(); LogHelper.Error($"未处理异常,URL={Request.RawUrl}", ex); // 清掉错误,避免用户看到黄页 Server.ClearError(); Response.Redirect("~/Home/Error"); }LogHelper内部用lock保证并发写不串行错乱,日志内容包含时间、线程 ID、URL、异常堆栈。看日志的顺序是先按时间定位请求,再看堆栈最内层,通常真正的原因在InnerException里,外层那个NullReferenceException只是被包装过的表象。
6. 二次开发:加一个支付回调要动哪几个文件
需求来了:要接一个第三方支付,回调地址接收异步通知。这件事看着吓人,实际改动集中在四处,我按改过的顺序列出来。
| 改造点 | 涉及文件 | 改动量 |
|---|---|---|
| 回调入口 | Controllers/PayController.cs新增NotifyAction | 约 60 行 |
| 状态流转 | OrderService.UpdateStatus复用已有守卫 | 3 行 |
| 支付流水 | 新建PayLogs表 + 一个 DAL 方法 | 约 40 行 |
| 配置项 | Web.config加appSettings三个键 | 3 行 |
核心的Notify方法要处理三件事:验签、幂等、状态更新。
// ShopMall.Web/Controllers/PayController.cs [HttpPost] [AllowAnonymous] // 支付平台回调带的是签名,不走登录态 public ActionResult Notify() { // 1. 读原始报文,不用 Request.Form,避免编码与签名不一致 string body; using (var reader = new StreamReader(Request.InputStream)) body = reader.ReadToEnd(); // 2. 验签失败直接返回 fail,让支付平台稍后重试 if (!PayHelper.VerifySign(body, Request.Headers["X-Sign"])) return Content("fail"); var notify = PayHelper.Parse(body); // 解析出 OrderNo、TradeNo、Amount、TradeStatus var order = _orderService.GetByOrderNo(notify.OrderNo); if (order == null) return Content("fail"); // 3. 幂等:已处理过直接返回 success,避免重复发货 if (order.Status == (int)OrderStatus.Paid || order.Status == (int)OrderStatus.Shipped) return Content("success"); // 4. 金额必须核对,防止篡改报文 if (order.TotalAmount != notify.Amount) return Content("fail"); // 5. 事务内改状态 + 写流水 var ok = _orderService.MarkPaid(order.OrderNo, notify.TradeNo, notify.Amount); return Content(ok ? "success" : "fail"); }幂等判断放在最前面,因为支付平台的回调会重复推送,直到收到success为止。没有这一步,同一笔订单会被标记多次,触发多次发货或者重复加积分。金额核对也不能省,签名只保证报文没被改,但有些支付渠道的签名不覆盖金额字段,自己再比一次最稳。
验证方法不建议直接拿真实支付环境试。我一般的做法是写一个Mock/PayMockController,手动构造报文调Notify,跑三个用例:正常支付、重复推送、金额被改。三个都通过再上联调环境。
改造完记得回头确认一件事:MarkPaid里改状态必须走 4.3 节那个CanTransferTo守卫,别图省事直接UPDATE Orders SET Status = 20。我吃过这个亏,某次春节前的版本上线,一个手工补单的脚本绕过了状态机,把已取消的订单改成了已付款,第二天对账多出十几笔「幽灵订单」,两个同事查了一通宵。从那以后,凡是改订单状态,我都强制走一遍那个方法,哪怕只是一行临时脚本,也先调守卫再动手。希望这些能帮你少绕点路。
本文还有配套的精品资源,点击获取