
1. 为什么是外卖系统看似普通的业务藏着进阶需要的复杂度先说一段我自己的卡壳经历。语法刷完了、增删改查的Demo做完了、甚至跟着教程写过一个小型的博客系统但真要说能独立设计并交付一个系统心里是完全没底的。那些教程有个共同问题——它们把真实世界的混乱过滤得太干净了。订单只有一条状态、用户只有一种角色、数据不会同时被两个人改。等你真正面对一个多角色、多状态、多约束的业务时才发现会写代码和会做系统之间隔着一整片空白地带。选外卖订餐系统做进阶项目正是因为它的复杂度恰好落在这片空白地带里。往小了看它就是下单、支付、配送的CRUD往大了拆它要处理商家与用户的数据隔离、订单状态机的严密约束、库存扣减的并发安全、取消订单的资金回滚、配送路径的状态同步——这些全都是真实商业系统每天在跑的核心问题。而且外卖业务有个非常大的优势规则你从日常生活里全都知道。用户怎么下单、商家怎么接单、骑手怎么配送不需要额外理解任何行业术语可以把全部精力放在怎么用代码表达业务上而不是先花两周学业务背景。对一个想从. NET Core初学者往上进阶的人来说这几乎是性价比最高的实战项目选题。1.1 从能写代码到会做系统的中间地带复盘之后我发现初学者要迈过这段中间地带欠缺的往往不是新语法而是三种思维。结构思维解决方案里该有几个项目、谁依赖谁、边界怎么划而不是把几百行代码全堆在一个Controller里。状态思维一个订单从创建到完成要经历哪些状态每条流转路径是否合法非法流转怎么拦截。一致性思维多个用户同时抢同一个限量菜品时库存怎么扣才不超卖客户端网络异常重试时怎么保证不产生两笔订单。这三种思维没有一个能从语法教程里直接学来都得靠一个规模适中的真实项目去逼出来。外卖系统正好合适业务规则足够多多到不思考架构就会乱数据关系足够复杂复杂到设计表结构时会反复权衡。但它又不至于像电商平台那样一上来就要求你掌握分布式事务、消息队列、分库分表这些重型设施导致初学者直接在基建里迷失连完成一个小功能的正反馈都拿不到。1.2 外卖系统的三类角色与业务闭环外卖系统带来的第一个认知冲击是它同时存在三类用户角色用户端、商家端、配送端。每类角色能看到的资源范围不同、操作目标不同、数据权限也不同。用户下单时商家要能接单骑手要能看到配送任务用户要能实时看到订单状态——这就迫使你在设计API和权限体系时从第一天就考虑按角色做数据隔离而不是把所有接口做成全员可用。这个经验在做任何多端项目时都用得上学的是一整套多角色系统的设计思路。具体落地我建议把项目拆成两个阶段推进。第一阶段做MVP也就是最小可行版本用户注册登录、浏览餐厅和菜品、创建订单、模拟支付、商家确认订单、骑手标记送达全流程用同步调用串起来先把主链路彻底跑通。第二阶段再做升级引入Redis缓存热点菜品数据、用后台任务处理超时未支付订单、给关键接口加幂等控制、做容器化部署。这样的节奏能保证你始终在一个能跑起来的版本上迭代而不是闷头憋两个月憋出一个永远达不到预期的大工程。特别提醒一句千万别一上来就加Redis、加消息队列、加微服务。第一版就用最简单的同步事务把业务跑通然后针对具体的性能瓶颈做演进。我见过太多初学者在架构图上花了三个星期最后一行业务代码没写。先跑通再优化这个顺序不能乱。2. 架构草图用分层的代价换未来三个月不返工项目动手之前先把解决方案的物理结构定下来。这个决定看起来不起眼实际上直接影响后续几个月的开发效率。我用四个项目作为起点这个划分至今仍然是我做中小型系统的默认选择。Takeout.Domain领域层。订单、菜品、用户、餐厅这些实体和核心业务规则。它是纯C#类库不引用任何框架。Takeout.Application应用层。用例编排、DTO定义、服务接口抽象。比如提交订单这个用例的具体步骤在这里实现。Takeout.Infrastructure基础设施层。EF Core的DbContext、仓储实现、Redis客户端、日志组件等把和外部世界打交道的事情全部收在这个项目里。Takeout.Api表现层。Controller、Filter、管道配置负责接收HTTP请求、做参数校验、返回响应。依赖方向是单向的Api引ApplicationApplication引DomainInfrastructure引Application但它和Api互不知道对方存在。也就是说Domain是金字塔尖谁都不依赖它所有最核心的业务规则都收在这里。这个设计的价值等你做单元测试的时候就体会到了——你可以只Mock掉Infrastructure把业务逻辑跑在有内存数据库的测试环境里完全不需要启动整个Web服务。这套结构的代价是前期多建几个类库、多写几层映射代码但换来的是一条清晰的边界业务规则不会被ORM特性、HTTP上下文这些东西污染。2.1 别让DTO和数据库实体互相混淆初学者最容易踩的坑是把数据库实体直接当作接口的请求和响应模型往外返回。实体里有导航属性、有内部状态、甚至可能有不应该暴露给前端的字段直接丢出去既不安全也不可控。正确做法是定义DTOData Transfer Object只把视图真正需要的字段映射出去。比如订单列表只需要订单号、餐厅名、总金额、状态、下单时间这几个字段那就建一个OrderListItemDto写一个映射函数从实体转过来。public record OrderListItemDto( Guid Id, string OrderNo, string RestaurantName, decimal TotalAmount, OrderStatus Status, DateTime CreatedAt);手动写这种字段映射确实有点啰嗦但初学阶段我不建议立刻引入AutoMapper。先把实体到DTO的对应关系亲手过一遍你才能真正理解为什么DTO存在。等字段多了、映射恶心到不行的程度再决定要不要用工具简化那时候你做技术选型的判断依据也是真实痛点而不是听说这个库很火。2.2 依赖注入与仓储模式不是为了炫技仓储模式在初学阶段一直有争议有人觉得多此一举。但在这个项目里我强烈建议保留原因很实际你的业务代码需要在一些特殊约束下运行比如查库存时要加锁、扣库存时要开事务、查询用户时要按租户隔离。这些细节如果不通过仓储接口隔离掉就会散落在各个用例代码里将来换数据库或者加缓存的时候你要改的地方是灾难级别的。public interface IMenuItemRepository { TaskMenuItem? GetByIdAsync(Guid id, CancellationToken ct); Taskint DeductStockAsync(Guid id, int quantity, CancellationToken ct); }注册依赖时生命周期一定要选对。AddScoped用于DbContext和仓储一次请求内共享同一个实例AddSingleton用于无状态的配置对象和工具类AddTransient用于轻量服务每次获取都拿新实例。新手很容易把这几条搞混尤其注意DI容器里注册为Singleton的对象如果有可变状态并发问题会非常隐蔽。2.3 EF Core数据模型先理清关系再写迁移数据访问层我直接用EF Core通过Fluent API配置实体关系。第一版表结构至少要覆盖这些Users、Restaurants、MenuItems、Orders、OrderItems、DeliveryInfos订单状态变更历史也建议提前留一张表后面排查问题会非常有用。以订单实体为例public class Order { public Guid Id { get; set; } public string OrderNo { get; set; } public Guid UserId { get; set; } public Guid RestaurantId { get; set; } public decimal TotalAmount { get; set; } public OrderStatus Status { get; set; } public DateTime CreatedAt { get; set; } public DateTime? PaidAt { get; set; } public string? CancelReason { get; set; } public ICollectionOrderItem Items { get; set; } }配合几组关键配置builder.ToTable(Orders); builder.Property(o o.OrderNo).HasMaxLength(32).IsRequired(); builder.Property(o o.TotalAmount).HasPrecision(18, 2); builder.HasMany(o o.Items) .WithOne(i i.Order) .HasForeignKey(i i.OrderId);配置写完后依次执行Add-Migration InitialCreate和Update-Database生成数据库。这里有一条必须记住的规矩修改字段一定要新建迁移绝对不要直接去改已有迁移文件或者手动改数据库否则一旦团队协作或后续回滚迁移链断裂会浪费你一整天时间。3. 订单状态机整个系统的业务中枢如果要我给这个项目挑一个最重要的设计那一定是订单状态机。外卖系统里的所有逻辑——支付、接单、出餐、配送、取消、退款——全部围绕订单状态的流转展开。状态定义不清楚后面每个功能都会长出一层if else代码很快就烂成一锅粥。3.1 七个状态、六条合法路径我最终确定的订单状态如下状态含义触发动作后续可流转状态PendingPayment待支付用户提交订单Paid / CancelledPaid已支付待接单支付回调成功Preparing / CancelledPreparing备餐中商家接单Delivering / CancelledDelivering配送中骑手开始配送CompletedCompleted已完成用户确认收货或自动完成无Cancelled已取消用户取消/超时/商家拒单Refunding已支付订单Refunding退款中已支付订单取消后发起退款Completed退款完成合法流转路径只有上面列出的这些其他一律视为非法。把所有规则收敛到一个类里public class OrderStateMachine { private static readonly DictionaryOrderStatus, OrderStatus[] _transitions new() { [OrderStatus.PendingPayment] new[] { OrderStatus.Paid, OrderStatus.Cancelled }, [OrderStatus.Paid] new[] { OrderStatus.Preparing, OrderStatus.Cancelled }, [OrderStatus.Preparing] new[] { OrderStatus.Delivering, OrderStatus.Cancelled }, [OrderStatus.Delivering] new[] { OrderStatus.Completed }, [OrderStatus.Cancelled] new[] { OrderStatus.Refunding } }; public static bool CanChange(OrderStatus current, OrderStatus next) _transitions.TryGetValue(current, out var allowed) allowed.Contains(next); }任何修改订单状态的地方入口统一走这个方法非法流转直接抛异常或返回业务错误码。状态机的价值就在于此把业务规则收敛到一个位置而不是让每个Controller各写各的判断。3.2 状态变更落库先验证、再更新、必要时带并发条件状态变更不是一个简单的Update操作至少要经历三个步骤加载订单、验证当前状态是否允许目标状态、在事务里更新状态。如果两个请求同时把一个待支付订单一个改成已支付、一个改成已取消后写库的请求会覆盖前一个的结果脏数据就产生了。解决办法是在更新语句里带上当前状态作为条件var affected await _context.Orders .Where(o o.Id orderId o.Status expectedStatus) .ExecuteUpdateAsync(s s.SetProperty(o o.Status, newStatus)); if (affected 0) { // 状态已经被其他请求修改按并发冲突处理或者返回订单状态已变更 }这段API是EF Core 7.0引入的。如果你用的是6.x就先查出实体、在内存里改状态再SaveChanges但要注意常规做法下那两个请求仍然是读-改-写模式需要配合乐观并发标记比如一个Version字段才能做到不丢更新。选哪种方案取决于并发要求关键是要意识到不加条件的更新在这个业务里是危险的。3.3 超时未支付与商家拒单的处理现实世界有两个场景是同步事务没法覆盖的用户下单后一直不支付以及商家因为食材不够拒单。超时取消用后台任务最合适。. NET Core自带的BackgroundService就能做注册一个托管服务每30秒扫描一次超时的PendingPayment订单批量置为Cancelled。关键点是SQL要带上条件WHERE Status PendingPayment AND CreatedAt deadline而且每次只取一小批比如200条避免一次加载全表把内存打爆。商家拒单则是主动行为走商家端接口触发一次取消已支付订单的操作这里会进入退款流程。如果只是模拟支付退款可以同步完成但对接真实支付平台时退款确认依赖异步回调就必须引入Refunding这样的中间状态等回调回来再流转到终点。这三块做完订单相关的核心业务就立住了。接下来让这三个角色通过API开口说话。4. 接口层与身份认证给三个角色各开一扇门进入API层很多初学者会犯一个根本性错误接口URL随意、字段命名随意、校验逻辑随手写在Controller第一行。等前端联调你就明白了接口契约混乱造成的沟通成本远超想象。所以先花一点时间把接口设计统一起来。4.1 把接口当成一份对外合同来设计我定的接口大概长这样方法路径角色说明POST/api/auth/register匿名用户注册POST/api/auth/token匿名登录获取TokenGET/api/restaurants用户餐厅列表带分页GET/api/restaurants/{id}/menus用户某餐厅的菜单POST/api/orders用户创建订单带幂等键GET/api/orders用户我的订单列表GET/api/orders/{id}用户/商家/骑手订单详情按角色过滤字段PUT/api/orders/{id}/confirm商家接单PUT/api/orders/{id}/deliver骑手开始配送几个容易踩的坑直接说路径用复数名词动作通过HTTP动词表达URL里不要出现动词别写/api/ConfirmOrder嵌套资源不超过两层/api/restaurants/{id}/menus到底了再深就要考虑重新建模字段命名风格选定一种就不要混用时间字段统一传ISO 8601格式别传时间戳联调时能省掉一堆时区换算的争论。接口设计这件事看起来是约定俗成实际上是整个团队哪怕只有你一个人协作效率的第一决定因素。4.2 JWT认证从发Token到校验Token的完整链路有了三类角色必然要有认证授权。我选JWT因为实现简单、天然无状态非常适合接口服务。先安装Microsoft.AspNetCore.Authentication.JwtBearer然后在Program.cs里配置认证服务builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30), ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Key])) }; });Issuer、Audience、Key三个值放进appsettings.json。Key一定要用足够长的随机字符串至少16个字符上线前记得通过环境变量覆盖别用默认值。签发Token时把当前用户的Id和角色塞进Claimvar claims new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Role, user.Role.ToString()) }; var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.UtcNow.AddHours(2), signingCredentials: new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config[Jwt:Key])), SecurityAlgorithms.HmacSha256)); return new JwtSecurityTokenHandler().WriteToken(token);然后在Controller和Action上用特性标注权限[Authorize(Roles Merchant)] [HttpPut({id:guid}/confirm)] public async TaskIActionResult ConfirmOrder(Guid id) { ... }这里必须强调一句JWT的Payload只是Base64编码不是加密前端随手就能解码看到内容。所以任何敏感数据都不要写进Token过期时间也别设太长2小时是一个比较合理的起点。想要更安全Refresh Token机制配合短时效Access Token会是下一个进阶点。4.3 统一响应模型和参数校验为了让前端不用每个接口各猜一套错误格式我定义了一个统一响应包装public class ApiResultT { public bool Success { get; set; } public int Code { get; set; } public string Message { get; set; } public T? Data { get; set; } }成功时Success为true、Code为0业务失败时Code用业务错误码比如4001表示库存不足、4002表示订单已超时系统异常统一走ExceptionFilter返回500和一句不影响安全的通用信息。前端只需要判断Success和Code处理逻辑会清晰很多。参数校验方面推荐FluentValidation把每个请求的校验规则独立成类。一条非常实际的教训校验失败返回的Message要写成用户能看懂的提示而不是给开发者看的异常信息。库存不足请减少购买数量远比对象引用未设置为对象实例有用。5. 最容易翻车的两件事超卖和重复支付前面几章做完系统在单用户、低并发场景下已经能顺畅跑了。但外卖系统有个特点热门餐厅在饭点会被几十个用户同时抢菜。并发一上来两个经典问题马上暴露一个叫超卖一个叫重复支付。这一章是全篇我最想让你认真看的部分因为我在这两个问题上通宵过不止一次。5.1 库存扣减三种锁的实测对比先说超卖。一道菜库存剩1份两个用户同时下单两个事务都读到库存1都执行UPDATE MenuItems SET Stock Stock - 1最后库存变成-1但两个订单都显示下单成功——这就是经典超卖。解决方案有三个级别方案原理优点缺点适用场景乐观锁UPDATE带Stock quantity条件实现简单无锁等待高并发下失败率高需客户端重试并发量可控悲观锁查询时锁行UPDLOCK严格串行化逻辑直观锁等待影响吞吐强一致约束的关键资源Redis分布式锁SETNX/RedLock跨进程、跨服务实例需要额外组件要注意锁过期时间已有多实例部署我的建议是第一阶段用乐观锁SQL层面就一行var affected await _context.MenuItems .Where(m m.Id menuItemId m.Stock quantity) .ExecuteUpdateAsync(s s.SetProperty(m m.Stock, m m.Stock - quantity)); if (affected 0) { throw new BizException(4001, 库存不足); }Stock quantity这个条件加上受影响行数判断能保证任何情况下库存都不会被扣成负数。等后面真的有多实例部署需求了再把热点菜品的扣减迁到Redis分布式锁上。这里必须说一句逆耳的话初学者练手项目不要为了看起来高级硬上Redis分布式锁。单机部署下乐观锁性能完全够用先把代码写对、把并发思维建立起来再谈架构演进。架构永远是为真实问题服务的不是为简历服务的。5.2 幂等设计客户端重试不会搞出两笔订单第二个坑是网络抖动导致的重复提交。用户在提交订单的瞬间断了网前端自动重试同一个订单被创建了两次支付平台因为回调超时重发通知订单被重复支付。解决方案是幂等键Idempotency Key。用户在创建订单时前端生成一个UUID放在请求头或请求体里作为这次操作的唯一凭证。服务端在创建订单前先查幂等表var existing await _idempotencyRepo.GetByKeyAsync(request.IdempotencyKey); if (existing ! null) { return Ok(existing.Order); // 直接返回第一次创建的结果不重复创建 } var order await _orderService.CreateAsync(request); await _idempotencyRepo.SaveAsync(request.IdempotencyKey, order.Id);注意幂等键的保存和订单的创建必须在同一个事务里否则并发下依然可能创建两次。支付回调那边同理用订单号加支付流水号做唯一约束重复回调直接返回成功不再重复处理。幂等设计是我认为初学者最容易忽视、但在生产环境价值最高的一课。5.3 事务边界与补偿思路下单动作至少涉及三件事创建订单头、创建订单明细、扣减库存。这三件事必须在一个数据库事务里要么全成功要么全回滚。await using var transaction await _context.Database.BeginTransactionAsync(); try { _context.Orders.Add(order); _context.OrderItems.AddRange(order.Items); await _context.SaveChangesAsync(); // 扣库存乐观锁条件更新 var affected await DeductStockAsync(menuItemId, quantity); if (affected 0) throw new BizException(4001, 库存不足); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }先插订单再扣库存这个顺序看着不起眼实际上有讲究。因为未来一旦把库存拆到独立服务、用消息队列异步扣减顺序就决定了失败时的补偿方向。现在把两者锁在同一个事务里是为了先把事务一致性的直觉建立起来将来做分布式拆分时才不会手足无措。6. 部署遇坑记录从本地跑通到容器上线系统在本地能跑距离能上线还有不少路。这一章把我在部署阶段踩过的坑按优先级写下来希望你能在它们变成通宵之前就绕开。6.1 Docker化时的三个典型问题第一是时区。容器默认UTC时间如果你直接存DateTime.Now日志和订单时间会显示成比北京时间晚8小时。虽然可以用容器环境变量TZAsia/Shanghai解决但我更建议所有时间在数据库统一存UTC展示时再转本地时区。将来做数据分析和多地区部署你会感谢这个决定的。第二是连接字符串。本地用localhost容器里服务名变了连接字符串不能硬编码。用环境变量覆盖配置docker-compose里写CONNECTIONSTRINGS__DEFAULTServerdb;DatabaseTakeout;User Idsa;Password...。ASP.NET Core的配置系统会把双下划线自动映射成配置层级这是官方设计好的机制。第三是数据库迁移脚本的执行时机。我强烈建议启动时调用db.Database.Migrate()或者在容器编排里单独跑一个迁移Job而不是手工在宿主机上执行。否则你换了一台机器部署数据库结构还是旧的接口会立刻报一堆字段不存在的错误。6.2 日志与配置出事了给排查留一条出路生产环境只靠Console输出是完全不够的。我推荐引入Serilog把结构化的日志写到文件和控制台带上时间戳、请求路径、用户Id、耗时。关键节点必须打日志订单创建、状态变更、支付回调以及异常Filter里记录完整堆栈。我的一条实操经验每个关键接口的入口和出口各打一条日志入口记录参数摘要出口记录状态码和耗时。线上出问题时你往往就是靠这两条日志还原整个调用链。配置方面最低要求是把连接字符串、JWT Key这类敏感信息从appsettings.json里移出来改用环境变量或密钥管理工具注入。日志级别通过环境变量控制排查问题时临时调到Debug定位完再调回去不要在生产环境长期开Debug。6.3 上线前的初学者检查清单最后这节给准备部署上线的同行一份逐项检查清单全部来自我实际翻过车的经验数据库迁移是否已生成能否在空库上重复执行成功。JWT Key长度是否达标是否已通过环境变量注入而不是默认值。订单状态变更接口是否做了权限校验用户能不能操作商家的接口。下单接口的幂等键机制是否生效并发下是否还会产生重复订单。热门菜品库存扣减是否用了带条件的UPDATE而不是先查后改。日志是否覆盖关键操作出入口异常是否写入了独立日志。容器时区是否统一数据库是否都存UTC、展示层再转换。CORS策略是否只对真实前端域名开放而不是AllowAnyOrigin。开发环境的Seed数据是否与生产隔离测试账号有没有混进来。这九项我建议打印出来贴在工位旁每次上线前逐条打勾。你会发现绝大多数线上事故都能在这张清单里找到对应的一项。整个项目走到这里你已经从跟着教程抄代码变成独立完成一个多角色、有状态机、有并发控制、能容器化部署的业务系统。这段旅程里踩过的坑比任何一套教程给的都值。最后说一条我个人体会最深的事第一次做这种规模的项目千万别指望一次设计就完美。先让系统用最简单的方案端到端跑通再沿着真实的痛点和瓶颈去做第二轮、第三轮迭代。好的设计是在修改中变好的不是在一开始就定格下来的。你把第一个版本完整跑起来的那一刻才是这个项目真正的起点。