☰
ASP.NET电商网站全链路实战:库存并发、支付幂等与订单状态机
2026/10/30 5:07:09 网站建设 项目流程

简介:这份 ASP.NET 电子商务网站项目面向学习 Web 开发的学生与初学者,可作为课程设计、期末作业或小型电商系统练习的参考案例,帮助理解从页面展示到后台数据管理的完整开发流程。压缩包共 59 个文件,约 400KB,包含 7 个 aspx 页面、12 个 cs 后台代码、4 个 ascx 用户控件、4 个 mdb Access 数据库,以及 css 样式、sitemap 站点地图和 config 配置等,另有较多 gif、jpg 图片资源,整体结构保留了典型 Web Forms 项目的组织方式。已有 143 人学习,读者可从中参考商品展示、用户注册登录、购物车与订单处理等模块的页面组织、控件复用和数据库连接思路,也可借此梳理 ASP.NET 状态管理、身份验证与站点配置的基本用法,适合用来对照自己的课程作业并查漏补缺。

1. 从一笔“对不上账”的订单说起

答辩前一晚,购物车里加了两件商品,结算页金额却少算了一件;后台把商品改成下架,前台列表还挂着;两个人同时点最后一件库存,两边都下单成功。这类问题通常与代码写得多不多无关,根源多在于需求边界一开始就没定死。这套 ASP.NET 电子商务网站方案瞄准的是“能跑通一笔完整交易”这件事:商品与 SKU 分离、购物车双态、下单事务扣库存、支付回调幂等、后台订单状态机,一条链路从头走到尾。它适合两类人:要交毕设或课程设计、需要一套能演示能答辩的完整站点的同学;以及要出一份可交付电商方案的从业者。后面按需求、库表、架构、核心模块、避坑、验收的顺序推进,每一段都落到能直接抄的结构和代码上。

2. 需求拆解与数据库设计:八张表撑起一条交易链路

电商站最容易失控的地方不是页面多,而是数据关系乱。先把角色边界划清楚,再把表定死,后面写控制器和视图基本是体力活。我一般会要求自己在动第一行代码之前,先在纸上把这八张表画出来,画不出来就说明需求还没想明白。

2.1 三类角色与用例边界

买家侧的用例其实只有六个:浏览分类、搜索商品、看商品详情(选规格)、加购物车、下单支付、查我的订单。运营侧是商品上下架、SKU 与库存维护、订单发货。管理员侧只保留账号与权限、订单异常处理。把这三类拆开的意义在于,它直接决定了后面控制器怎么切分、权限过滤器挂在哪一层。

很多同学一上来就做“万能后台”,结果商品编辑页里塞了订单发货按钮,权限判断写在 Razor 视图里,答辩时老师一问“这个按钮谁能点”就答不上来。比较稳妥的做法是按角色划分区域:前台Areas/Shop、后台Areas/Admin,两边共用领域层,但各自有独立的控制器和视图。

用例还要标注“谁触发、写哪张表、失败时回滚什么”。比如“下单”这个用例,触发者是买家,写 Order、OrderItem、ProductSku(扣库存)、Payment(生成待支付流水),任一步失败必须整体回滚。这张用例表在写代码时会反复用到,比任何 UML 图都实用。

提示:把“库存扣减”单独标成一条用例,而不是塞在“下单”里,答辩时被问到并发问题,你能直接翻到这一页。

2.2 八张核心表与字段取舍

商品和 SKU 必须分开。Product存标题、主图、分类、状态这些与规格无关的信息;ProductSku存具体规格、价格、库存。只做单规格商品的站点可以合并,但只要涉及“颜色/容量/套餐”,合并的表一定会在下单时出问题。

-- 商品主表:只放与规格无关的公共信息 CREATE TABLE Product ( Id INT IDENTITY(1,1) PRIMARY KEY, CategoryId INT NOT NULL, Title NVARCHAR(120) NOT NULL, Subtitle NVARCHAR(200) NULL, MainImage NVARCHAR(300) NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0 草稿 1 上架 2 下架 CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() ); -- SKU 表:价格和库存挂在这一层,行版本用于乐观并发 CREATE TABLE ProductSku ( Id INT IDENTITY(1,1) PRIMARY KEY, ProductId INT NOT NULL, SpecText NVARCHAR(120) NOT NULL, -- 例如 "颜色:黑;容量:256G" Price DECIMAL(10,2) NOT NULL, Stock INT NOT NULL, RowVersion ROWVERSION -- 并发令牌 ); -- 订单主表:金额一律 DECIMAL,禁止 FLOAT CREATE TABLE [Order] ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL UNIQUE, UserId INT NOT NULL, TotalAmount DECIMAL(12,2) NOT NULL, Status TINYINT NOT NULL, -- 0 待支付 1 已支付 2 已发货 3 已完成 4 已取消 CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() ); -- 支付流水:OutTradeNo 唯一索引是幂等的第一道防线 CREATE TABLE Payment ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL, OutTradeNo VARCHAR(64) NOT NULL, Amount DECIMAL(12,2) NOT NULL, PaidAt DATETIME2 NULL ); CREATE UNIQUE INDEX UX_Payment_OutTradeNo ON Payment(OutTradeNo);

字段取舍上有三个血泪经验。第一,金额全用DECIMAL(12,2),FLOAT和DOUBLE在累加时会给你演出“差一分钱”的玄学。第二,订单状态用TINYINT加常量枚举,不要用字符串,否则后面改文案要全表更新。第三,OrderNo单独生成而不是拿自增主键,自增 ID 会暴露订单量,也不方便按日期分表。

剩下四张表是Category(分类,带ParentId做两级)、CartItem(登录用户购物车)、OrderItem(订单明细,快照商品标题与单价)、AppUser(账号与角色)。OrderItem一定要存商品标题和下单时单价,不要只存SkuId——商品改价改名之后,历史订单必须还能显示当时的价格。

2.3 领域模型与 EF Core 映射

表定完,实体类基本是照抄,但有两处必须手动配置:金额精度和并发令牌。EF Core 默认会把decimal映射成decimal(18,2),多数场景够用;如果你自定义了精度,就要在OnModelCreating里显式声明,否则迁移生成的列类型可能和手工建表脚本对不上。

public class ProductSku { public int Id { get; set; } public int ProductId { get; set; } public string SpecText { get; set; } = string.Empty; public decimal Price { get; set; } public int Stock { get; set; } // 映射到 SQL Server 的 rowversion 列,EF 用它做乐观并发 public byte[] RowVersion { get; set; } = Array.Empty<byte>(); public Product Product { get; set; } = null!; } public class ShopDbContext : DbContext { public DbSet<Product> Products => Set<Product>(); public DbSet<ProductSku> Skus => Set<ProductSku>(); public DbSet<Order> Orders => Set<Order>(); protected override void OnModelCreating(ModelBuilder b) { b.Entity<ProductSku>(e => { e.Property(x => x.Price).HasPrecision(10, 2); e.Property(x => x.RowVersion).IsRowVersion(); // 并发令牌 e.HasIndex(x => x.ProductId); }); b.Entity<Order>(e => { e.Property(x => x.TotalAmount).HasPrecision(12, 2); e.HasIndex(x => x.OrderNo).IsUnique(); // 订单号唯一 }); } }

IsRowVersion()这一行的作用是:EF 在UPDATE时会自动把RowVersion加进WHERE条件,更新影响 0 行就抛DbUpdateConcurrencyException。它解决的是“两个人同时改同一行”的问题,但对“同时下单抢库存”还不够——那个要靠第 4 章的条件更新语句。

迁移命令按顺序走一遍就能生成库:

dotnet ef migrations add InitShop -p ./src/ECommerce.Infrastructure -s ./src/ECommerce.Web dotnet ef database update -p ./src/ECommerce.Infrastructure -s ./src/ECommerce.Web

-p指向包含DbContext的项目,-s指向启动项目,两个参数写反是新手最常踩的坑,报错信息还特别绕。生成完记得打开迁移文件扫一眼,确认decimal精度和唯一索引都进去了,再执行database update。

3. 总体架构与分层:ASP.NET 项目怎么切才不糊成一团

默认模板生成的 MVC 项目只有Controllers / Models / Views三个文件夹,做课程作业够用,做一套能交付的电商站就不够了。业务一多,控制器里会同时出现 SQL 查询、库存计算、发邮件、拼 HTML,改一个需求要翻五个文件。分层的目标不是好看,是让“改价格规则”这件事只动一个地方。

3.1 五层结构与依赖方向

我一般切成五个项目:Web(控制器、视图、过滤器)、Application(用例服务、DTO、校验)、Domain(实体、枚举、领域规则)、Infrastructure(EF Core、仓储实现、外部支付网关)、Tests。依赖方向只有一个:Web → Application → Domain,Infrastructure 实现 Domain 定义的接口,由 DI 容器在启动时注入。

关键约束是 Domain 不引用任何 ORM 和框架包。这样订单状态机、金额计算这些核心规则可以脱离数据库单测,答辩现场演示“取消已发货订单会被拒绝”时,跑一个单元测试比点十次页面有说服力。

Application层用“用例服务”而不是“万能仓储”。比如OrderAppService只暴露CreateOrder、PayCallback、Ship、Cancel四个方法,控制器里就是一行调用。很多项目的控制器动辄三百行,本质是把用例逻辑写在了 HTTP 层,换一个入口(比如后台批量发货)就得复制一遍。

3.2 请求管道与中间件顺序

ASP.NET Core 的中间件是顺序敏感的,注册顺序错了会出现“静态文件 404”“登录后还是游客”“异常页不生效”这类问题,排查起来像黑匣子。下面这段是电商站比较稳的顺序:

var app = builder.Build(); // 1. 异常处理必须最外层,才能兜住后面所有中间件 app.UseExceptionHandler("/Home/Error"); app.UseStatusCodePagesWithReExecute("/Home/StatusCode/{0}"); // 2. HTTPS 跳转放在静态文件之前,避免静态资源被反复重定向 app.UseHttpsRedirection(); // 3. 静态文件:放在路由之前,命中就直接返回,不进 MVC app.UseStaticFiles(); // 4. 路由 → 认证 → 授权,三者顺序不能换 app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); // 5. 自定义中间件:记录请求耗时与订单号,方便后续排查 app.Use(async (ctx, next) => { var sw = Stopwatch.StartNew(); await next(); if (sw.ElapsedMilliseconds > 500) app.Logger.LogWarning("慢请求 {Path} {Ms}ms", ctx.Request.Path, sw.ElapsedMilliseconds); }); app.MapControllerRoute("areas", "{area:exists}/{controller=Home}/{action=Index}/{id?}"); app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}"); app.Run();

几个参数的含义:{area:exists}里的exists是路由约束,只有存在的 Area 才会匹配,避免和前台控制器同名冲突;UseStatusCodePagesWithReExecute让 404 走统一页面而不是白屏;那个自定义中间件只打印超过 500ms 的请求,生产环境日志量可控。

一个常见误用是把UseStaticFiles()写在UseAuthorization()后面,结果后台管理页面的 CSS 也要过一遍权限校验,未登录时样式全丢。另一个是把UseAuthentication()和UseAuthorization()顺序写反,表现是所有[Authorize]标记的页面全部 401,但登录明明是成功的。

3.3 依赖注入与配置管理

连接字符串、支付密钥、图片存储路径这些东西一律进appsettings.json,代码里只读IOptions<T>。硬编码连接字符串的项目,部署到另一台机器上必定翻车。

{ "ConnectionStrings": { "ShopDb": "Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True" }, "Payment": { "Gateway": "Sandbox", "NotifyUrl": "https://example.com/pay/notify", "TimeoutSeconds": 10 }, "Paging": { "DefaultSize": 20, "MaxSize": 60 } }
// 按生命周期注册:DbContext 用 Scoped,配置用 Options 模式 builder.Services.AddDbContext<ShopDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("ShopDb"))); builder.Services.Configure<PaymentOptions>(builder.Configuration.GetSection("Payment")); builder.Services.Configure<PagingOptions>(builder.Configuration.GetSection("Paging")); // 用例服务与仓储:接口在 Domain,实现在 Infrastructure builder.Services.AddScoped<IOrderAppService, OrderAppService>(); builder.Services.AddScoped<IProductRepository, ProductRepository>(); builder.Services.AddScoped<ICartService, CookieCartService>();

AddScoped意味着每个 HTTP 请求一个实例,DbContext必须用它,写成AddSingleton会出现“上下文已被释放”的报错。Paging.MaxSize这个上限很必要——前端传pageSize=100000时不加限制,一条查询就能把内存打满,这是我在压测时踩过的坑。

4. 核心模块实现:购物车、下单、支付回调、商品列表

前面三章是地基,这一章是真正会被老师或甲方点开看的部分。四个模块按交易顺序排列,每个都给出可直接运行的实现和参数说明,改改命名空间就能进项目。

4.1 购物车:Cookie 与数据库双态

未登录用户用 Cookie 存购物车,登录后合并进数据库,这是电商站的标准做法。只做数据库购物车的话,游客加购会直接失败;只做 Cookie 的话,换设备就丢。双态方案的关键是“合并策略”:同一个 SKU 数量相加,超出库存则截断到库存上限。

public interface ICartService { Task<List<CartLine>> GetLinesAsync(); Task AddAsync(int skuId, int qty); Task MergeToUserAsync(int userId); // 登录成功后调用 } public class CookieCartService : ICartService { private const string CookieName = "shop_cart"; private readonly IHttpContextAccessor _http; private readonly ShopDbContext _db; // Cookie 里存 "skuId:qty|skuId:qty" 这种紧凑格式,省字节也省解析 public async Task AddAsync(int skuId, int qty) { var map = ReadCookie(); map[skuId] = map.GetValueOrDefault(skuId) + qty; WriteCookie(map); await Task.CompletedTask; } public async Task MergeToUserAsync(int userId) { var map = ReadCookie(); foreach (var (skuId, qty) in map) { var sku = await _db.Skus.FindAsync(skuId); if (sku is null) continue; // 商品已删除则丢弃 var finalQty = Math.Min(qty, sku.Stock); // 数量不能超过库存 var item = await _db.CartItems .FirstOrDefaultAsync(x => x.UserId == userId && x.SkuId == skuId); if (item is null) _db.CartItems.Add(new CartItem { UserId = userId, SkuId = skuId, Qty = finalQty }); else item.Qty = Math.Min(item.Qty + finalQty, sku.Stock); } await _db.SaveChangesAsync(); _http.HttpContext!.Response.Cookies.Delete(CookieName); // 合并完清掉 } }

Math.Min(qty, sku.Stock)这一句是防呆:游客在 Cookie 里攒了 10 件,登录时库存只剩 3 件,合并后应该是 3 而不是 10。Cookie 要设HttpOnly = true和SameSite = Lax,前者防脚本读取,后者保证从外部链接跳回来时 Cookie 还在。

购物车还有一个容易忽略的点:加购时要不要校验商品状态。我的做法是加购只校验 SKU 是否存在,下单时才校验Product.Status == 1。原因是运营临时下架商品时,用户购物车里的东西不应该凭空消失,而是在结算页给出“该商品已下架”的提示——这是体验问题,也是答辩时容易被追问的细节。

4.2 下单事务与库存扣减

下单是整个系统里唯一必须“要么全成要么全败”的操作。流程是:校验库存 → 写订单主表 → 写订单明细 → 扣库存 → 生成待支付流水。五步在一个事务里,任何一步抛异常全部回滚。

库存扣减不能写成“先查库存再更新”,两个请求同时读到库存 1,都判断够,都更新,结果卖了两件。正确写法是带条件的原子更新,用受影响行数判断是否成功:

public async Task<OrderResult> CreateOrderAsync(int userId, List<CartLine> lines) { await using var tx = await _db.Database.BeginTransactionAsync(); var order = new Order { OrderNo = GenerateOrderNo(), // 形如 20240512000001 + 随机位 UserId = userId, Status = 0, CreatedAt = DateTime.UtcNow }; decimal total = 0; foreach (var line in lines) { var sku = await _db.Skus.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == line.SkuId); if (sku is null || sku.Stock < line.Qty) return OrderResult.Fail($"商品 {line.SkuId} 库存不足"); // 关键:条件更新,库存不足时影响行数为 0,不依赖先查后改 var affected = await _db.Database.ExecuteSqlInterpolatedAsync($@" UPDATE ProductSku SET Stock = Stock - {line.Qty} WHERE Id = {line.SkuId} AND Stock >= {line.Qty}"); if (affected == 0) return OrderResult.Fail($"商品 {line.SkuId} 扣减失败,请重试"); total += sku.Price * line.Qty; order.Items.Add(new OrderItem { SkuId = line.SkuId, Title = sku.SpecText, // 快照,商品改名不影响历史订单 UnitPrice = sku.Price, Qty = line.Qty }); } order.TotalAmount = total; _db.Orders.Add(order); await _db.SaveChangesAsync(); await tx.CommitAsync(); return OrderResult.Ok(order.OrderNo); }

ExecuteSqlInterpolatedAsync会自动把参数转成 SQL 参数,不要用字符串拼接,否则就是注入漏洞。affected == 0是并发失败信号,直接返回“请重试”比抛异常友好。事务用await using保证异常时释放。

订单号生成有个小技巧:yyyyMMddHHmmss加 6 位随机数,长度 20,既按时间有序又基本不会撞。用自增 ID 当订单号的话,别人下单两次就能推算出你一天卖了多少单。

注意:BeginTransactionAsync之后如果提前return,事务会随await using自动回滚,但如果你在 using 块外捕获了异常又继续执行,事务可能已经处于不可用状态。养成“事务块内不做 try-catch 吞异常”的习惯。

4.3 支付回调的幂等处理

支付平台的回调是“至少一次”语义,同一笔支付可能通知你三次。没有幂等处理的系统会重复加库存、重复改状态,甚至把已发货订单改回已支付。幂等的实现分两层:数据库唯一索引兜底,业务代码做状态机守卫。

public async Task<bool> HandleNotifyAsync(PayNotifyDto dto) { // 第一层:验签,防止伪造回调(具体算法按支付方文档) if (!_signer.Verify(dto)) return false; // 第二层:OutTradeNo 唯一索引,重复插入会抛异常,捕获后视为已处理 var exists = await _db.Payments.AnyAsync(x => x.OutTradeNo == dto.OutTradeNo); if (exists) return true; var order = await _db.Orders.FirstOrDefaultAsync(x => x.OrderNo == dto.OrderNo); if (order is null) return false; // 第三层:状态机守卫,只有待支付的订单才能变成已支付 if (order.Status != 0) return true; order.Status = 1; _db.Payments.Add(new Payment { OrderNo = dto.OrderNo, OutTradeNo = dto.OutTradeNo, Amount = dto.Amount, PaidAt = DateTime.UtcNow }); await _db.SaveChangesAsync(); return true; }

三层防护各管一件事:验签管“是不是真的支付方发的”,唯一索引管“同一条通知只落一次库”,状态机管“已发货的订单不能被改回去”。少任何一层,压测或联调时都会出问题。

回调接口还要注意两点:一是必须返回支付方要求的格式(通常是纯文本success),返回 JSON 会被判定为失败并持续重试;二是回调接口不能要求登录态,必须允许匿名访问,否则支付方永远调不通。这两点在[AllowAnonymous]和返回值上各改一行就能解决,但每年都有项目栽在这里。

4.4 商品列表:分页、筛选与查询优化

列表页是访问量最大的页面,也是最容易写出 N+1 查询的地方。常见的错误写法是查出商品列表后在视图里循环访问item.Skus.Min(x => x.Price),200 条商品就是 201 次查询,页面加载三秒起。

正确做法是一次性投影出列表需要的字段:

public async Task<PagedResult<ProductCardDto>> QueryAsync(ProductQuery q) { var size = Math.Clamp(q.PageSize, 1, _paging.MaxSize); // 上限保护 var query = _db.Products.AsNoTracking() .Where(x => x.Status == 1); // 只查上架商品 if (q.CategoryId > 0) query = query.Where(x => x.CategoryId == q.CategoryId); if (!string.IsNullOrWhiteSpace(q.Keyword)) query = query.Where(x => x.Title.Contains(q.Keyword)); var total = await query.CountAsync(); var items = await query .OrderByDescending(x => x.CreatedAt) .Skip((q.PageIndex - 1) * size) .Take(size) .Select(x => new ProductCardDto { Id = x.Id, Title = x.Title, Image = x.MainImage, // 子查询取最低价,SQL 层完成,不产生 N+1 MinPrice = x.Skus.Min(s => (decimal?)s.Price) ?? 0, TotalStock = x.Skus.Sum(s => (int?)s.Stock) ?? 0 }) .ToListAsync(); return new PagedResult<ProductCardDto>(items, total, q.PageIndex, size); }

AsNoTracking()关掉变更跟踪,纯读场景能省 20% 到 30% 的时间。Select投影只取列表需要的四五个字段,比查完整实体再映射轻得多。Math.Clamp挡住pageSize=99999这种恶意参数。

Contains在 SQL Server 上会翻译成LIKE '%关键词%',走不了索引,商品量上万后明显变慢。课程设计阶段够用;如果要做全文检索,通常的做法是加一张关键词表或者接搜索引擎,但那属于加分项,不必在毕设阶段硬上。

5. 避坑与排查:五个最容易翻车的点

这一章是我带项目时反复遇到的清单,每一条都对应过真实的报错或答辩现场被问住的瞬间。现象、原因、解决按顺序写,遇到问题可以直接对号入座。

5.1 库存卖成负数

现象:并发下单后,ProductSku.Stock出现 -1,或者卖出数量大于初始库存。

原因:代码写成“先SELECT查库存,判断够不够,再UPDATE减库存”。两个请求在同一时刻读到库存 1,都判断通过,都执行减法。

解决:改成带条件的原子更新UPDATE ... SET Stock = Stock - @qty WHERE Id = @id AND Stock >= @qty,用ExecuteSqlInterpolatedAsync的返回值判断影响行数,为 0 就说明抢失败了。如果业务允许,也可以给Stock加CHECK (Stock >= 0)约束做最后一道保险,让数据库直接拒绝脏数据。

5.2 列表页慢得像卡住

现象:商品列表页打开要三到五秒,日志里同一请求打了上百条 SQL。

原因:EF Core 的导航属性在循环里被逐个访问,或者开启了延迟加载,每次item.Skus都触发一次查询。

解决:列表查询一律AsNoTracking()加Select投影,需要的字段在 SQL 层一次算完。排查时打开 EF 的 SQL 日志,看一次请求发了几条语句,超过五条就该改。开发环境可以在OnConfiguring里加LogTo(Console.WriteLine),一眼就能看出问题。

5.3 结算金额差一分钱

现象:购物车三件商品,单价相加和订单总额对不上,差 0.01。

原因:金额字段用了float或double,二进制浮点无法精确表示 0.1 这类十进制小数,累加后产生误差。

解决:数据库用DECIMAL(12,2),C# 用decimal,EF Core 里显式HasPrecision。运费、优惠券、积分抵扣一律用decimal参与运算,最后统一Math.Round(total, 2, MidpointRounding.AwayFromZero)。已经上线改不动的表,至少要在应用层做一次四舍五入对齐。

5.4 用户登录后购物车空了

现象:本地调试一切正常,部署到服务器后,用户加购完过一会儿购物车就清空,或者登录后加购直接丢失。

原因:购物车和登录态存在进程内 Session 里,应用池回收或换一台服务器,Session 就没了。

解决:购物车按第 4 章的双态方案,游客存 Cookie、登录后入库。登录态如果必须用 Session,就改成分布式缓存并把数据序列化出去。部署到多台机器时,还要在负载均衡上开粘性会话,否则用户第二次请求就落到另一台机器上,表现就是“刚登录又变游客”。

5.5 支付回调把订单状态改乱了

现象:同一笔订单被加了两次库存,或者已经发货的订单状态突然退回“已支付”。

原因:回调接口没有任何幂等判断,支付方重试一次就执行一次业务;或者状态流转没有守卫,任何状态都能被改成任意状态。

解决:OutTradeNo建唯一索引,插入重复直接捕获异常当作已处理;业务代码里判断order.Status != 0就跳过。订单状态机建议单独写一个CanTransit(from, to)方法,所有改状态的地方都走它,取消订单、发货、退款共用一套规则,测试也好写。

6. 验收与答辩:怎么证明这套站真的跑通了

验收不是把首页点一遍就完事。我带过的项目里,最容易在答辩现场出问题的往往是“演示到一半,第二个人同时下单,库存错了”这种需要并发才能复现的场景。所以验收清单要按“单点功能、并发行为、异常路径”三类分开准备。

先按功能清单逐项过,每项都要能指出对应的表和数据变化:

验收项操作步骤通过标准
商品上下架后台改状态为下架前台列表消失,直接访问详情页提示已下架
购物车合并游客加 2 件,登录(库存 1 件)购物车显示 1 件,Cookie 被清除
下单扣库存下单 1 件ProductSku.Stock减 1,Order与OrderItem各新增记录
库存不足库存 1 件时下 2 件返回“库存不足”,订单表无新记录
支付回调重复同一OutTradeNo调两次第二次返回成功,Payment表仍只有一条
状态机守卫对已发货订单再调支付回调订单状态保持“已发货”不变

并发行为单独验一次。写个简单脚本,用 20 个并发请求抢同一件库存 1 的商品,跑完检查Stock是否为 0、订单数是否为 1:

# 用 ab 做并发下单,-c 并发数,-n 总请求数,-p 携带下单参数 ab -n 20 -c 20 -p order.json -T application/json \ -H "Cookie: shop_auth=YOUR_TOKEN" \ http://localhost:5000/api/order/create

跑完到数据库里SELECT Stock FROM ProductSku WHERE Id = 1,再SELECT COUNT(*) FROM [Order]。如果库存是 0 且订单只有一条,说明条件更新那一步真的生效了;如果库存变负或者订单出现多条,回到 5.1 重看。这个脚本我一般会留在仓库的scripts/目录里,答辩被问到并发就直接跑一遍,比口头解释有说服力。

异常路径也要走一遍:支付回调返回错误格式、下单时商品刚好被下架、后台删除一个有订单关联的商品。这些路径不要求页面好看,但必须不抛 500 白屏。我通常的做法是给每个控制器加一个全局异常过滤器,把未处理异常记日志并跳转到统一错误页,答辩时演示一个“故意造出来的异常”反而加分。

答辩讲的时候,别从技术栈开始讲。先讲一条完整交易链路:用户怎么找到商品、怎么加购、下单时系统做了什么、支付回调怎么保证不重复。讲完这条线,再打开代码指到第 4 章那三段核心代码,说明为什么库存要条件更新、为什么回调要三层防护。老师追问细节时,把避坑清单里的现象和原因说出来,比背概念管用。

从那以后,我每次交付这类项目,都会强制自己先跑一遍“并发抢库存 + 重复回调”这两个用例,跑通了再谈界面美化。页面做得再漂亮,账对不上就是不及格。这套顺序和清单你可以直接拿去改,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询