尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

.NET Core外卖订餐系统进阶之路:从建模到Swagger配置

发布时间:2026/9/26 20:12:02

资讯中心
01
ARTICLE

.NET Core外卖订餐系统进阶之路:从建模到Swagger配置

.NET Core外卖订餐系统进阶之路:从建模到Swagger配置
我是在一个周六的下午把第一个.NET Core外卖订餐系统跑通的。这听起来像句轻描淡写的总结但那个下午之前我已经整整卡在Swagger页面里出不来点开某个接口文档里显示的路径和实际能调通的路径对不上前端同事对着接口文档联调一调就是404。后来查明白是路由前缀和Swagger文档生成规则的问题改了十行代码一切豁然开朗。也正是这种小问题卡三天的经历让我觉得初学者的进阶之旅这个定位特别真实外卖订餐系统这个项目难点从来不是某个语法不会写而是当你把用户、商品、订单、支付、配送这些模块揉在一起时各种工程化的细节会一起涌过来。这篇文章就是围绕这个项目把我从选型到落地过程中积攒的思考完整写了一遍。核心内容包括为什么外卖订餐系统是适合进阶的项目订单系统的数据模型怎么设计RESTful API和Swagger文档怎么配合得不出幺蛾子以及几个我在实际开发中踩过的坑和排查链路。适合正在学.NET Core但已经不想再做图书管理Demo的初学者也适合准备把这类项目写进简历的开发者。文章里所有方案都是我实际用过的没用的我会明确说没试过。1. 为什么外卖订餐系统是进阶路上的黄金项目1.1 从会写接口到会设计系统的跨越很多初学者学.NET Core的方式是跟着教程做增删改查建一张表写一个Controller调一个接口数据进去了就算学会了。这个阶段本身没错但它掩盖了一个关键事实——真实项目里几乎没有哪张表是孤立存在的。外卖订餐系统天然不是一个单表CRUD项目。它有用户端、商家端、配送端三个视角牵扯到用户、店铺、商品、购物车、订单、支付单、配送单、评价这些核心实体。单单下单这一个动作背后就至少要完成校验用户登录态、校验店铺营业状态、校验商品库存、计算价格、生成订单主表、生成订单明细、扣减库存、触发创建支付单。任何一个环节掉链子整个下单流程就会出问题。也正因为这种天然的复杂度它才适合作为进阶项目。你可以在这个项目里练习分层架构、依赖注入、领域建模、状态机设计、数据库事务处理甚至后续扩展Redis缓存、RabbitMQ消息队列、ElasticSearch搜索都不违和。一个项目能承载的技术宽度决定了你能学到什么程度。1.2 .NET Core与.NET Framework新手最纠结的选型问题网上到现在还能看到有人在问学.NET Framework还是.NET Core这个问题放到现在其实答案已经非常明确全新项目直接选.NET Core以及它后续的版本.NET 5之后的统称.NET。我自己的第一次纠结发生在2019年那时候.NET Core 3.1刚发布身边有同事还在维护.NET Framework项目加上部分第三方类库只支持Framework导致很多人对它观望了很久。但对比一下实际差异就明白了对比维度.NET Framework.NET Core / .NET跨平台仅WindowsWindows、Linux、macOSDocker容器化支持困难镜像臃肿原生支持镜像小、启动快性能表现中规中矩持续优化明显优于老框架新特性支持停止演进持续迭代语言新特性优先支持调试与部署依赖IIS环境配置繁琐Kestrel内置可独立部署对外卖订餐系统这种要部署在服务器上的业务系统来说跨平台意味着你可以把服务直接扔进Docker里跑在Linux上成本低得多。哪怕你只是本地开发也完全没必要为存量兼容问题选一个已经停止进化的框架。1.3 做这一个项目到底练出了什么我后来复盘过这个项目带给我的成长集中在五件事上设计能力从写接口变成设计接口。下单、取消、退款、配送状态变更每一个动作都要考虑参数合法性和状态约束。数据建模能力订单明细里要存商品快照而不是外键直接关联商品表这个意识是在做商品改价影响历史订单金额需求时被逼出来的。调试与排错能力一次接口404、一次库存超卖、一次时间显示错乱排查这些问题积累的经验比教程里的示例代码值钱得多。工程化意识日志、异常处理、参数校验、配置管理这些平时觉得没什么技术含量的东西真正做项目时才显得无比重要。扩展视野做完单体版本后自然就开始想如果用户量大了怎么办于是主动去了解Redis、消息队列、分布式事务。所以如果你想进阶与其再做一个博客系统不如选这种有真实业务背景、角色分明、约束多的项目。2. 业务建模先行订单系统的表结构应该怎么设计很多人的习惯是一上来就建表表建完了再想接口怎么写。我的建议恰好反过来先在纸上把业务流程画清楚搞清楚每个动作会产生哪些数据变化再动手建表。订餐系统里业务逻辑最密集的永远是订单相关模块所以先把订单模型设计好。2.1 商品与分类模块别把增删改查想得太简单商品模块看起来最不需要动脑一个分类表、一个商品表字段无非是名称、价格、图片、描述。但如果把外卖的真实场景加进去情况就不一样了。比如分类要不要支持排序、隐藏、按店铺维度区分商品除了在售/下架要不要有售罄状态库存是全局库存还是按门店隔离单品是固定规格还是需要支持加辣、少盐这样的可选属性这些都属于不做也能跑但做了才像个正经项目的需求。我给的参考模型是分类表(Category)和商品表(Food)是一对多关系商品表至少包含Id、店铺Id、分类Id、名称、主图、价格、原价、库存、销量、状态、创建时间这些字段。价格字段在SQL Server里用decimal(18,2)在MySQL和PostgreSQL里用decimal(10,2)——千万不要用double或float存金额哪怕你只是在做练习也请养成这个习惯浮点数在金额计算上会有精度丢失问题。分类表其实可以单独设计也可以做成树形结构来支持多级分类外卖场景通常一级分类就够了但如果你愿意扩展成两级分类也能锻炼递归查询能力。2.2 订单与订单明细一对多关系中的关键约束订单相关的核心表有两张订单主表(Order)和订单明细表(OrderItem)。订单主表记录的是这一次下单整体发生了什么字段大致有订单号、用户Id、店铺Id、订单状态、商品总金额、配送费、包装费、优惠金额、实付金额、收货地址、联系电话、备注、创建时间、支付时间、完成时间。订单明细表记录的是这次订单里都买了什么字段要有订单Id、商品Id、商品名称、商品快照、单价、数量、实付单价、小计金额。为什么要单独拆明细表而不是在主表里存一个组合好的商品字符串因为订单后续需要按商品维度做统计比如哪个菜卖得多哪个店铺的客单价高拆开才能在SQL层面高效聚合。这是最基础的数据库范式设计也直接决定了报表好不好写。订单明细里还有一个容易被新手忽略的点快照。当用户下单时要把商品名称、单价、甚至商品图片都复制一份到明细表里。不然商品改价、改名、甚至下架后历史订单展示就会跟着变这在业务上是不可接受的。最开始我偷懒明细表只存了商品Id后来一改价历史订单金额全部对不上教训非常直接。2.3 订单状态机从已下单到已完成的流转逻辑订单状态是外卖系统里最容易写出脏数据的字段也是最值得花时间设计的地方。不能简单地用一个int或string字段放任自由写入。一个订单从哪里到哪里是合法的需要明确约束。外卖订单的常见状态流转是待支付 - 已支付/已取消已支付 - 商家已接单商家已接单 - 配送中/商家已拒单配送中 - 已完成对应到代码层面我建议用枚举定义状态并单独写一个状态流转校验类而不是在每个Service里散落一堆if判断。比如写一个OrderStatusMachine它内部维护一个当前状态到目标状态是否允许的映射表。任何修改订单状态的操作都先走这个校验器不允许的流转直接抛业务异常。这一步在练习阶段看似多余但当你后期引入退款流程、超时未支付自动取消、商家拒单等扩展时会庆幸当初留了这个口子。状态机的核心价值就是让非法的业务流转在代码层面就没有机会发生。3. 落地实操从接口设计到Swagger文档的完整链路业务模型理清楚之后才能真正进入写代码的阶段。这一章节我会按我实际搭建项目的顺序把分层、数据访问、API风格和Swagger统一前缀一次讲透。3.1 项目骨架如何分层Api、Application、Domain、Infrastructure外卖订餐系统这类业务如果所有代码全塞在Controller里前期爽是爽后期维护会非常痛苦。我的分层习惯是四层Api层Controller、Filter、DTO、Swagger相关配置。这一层只负责接收请求、返回响应。Application层Service接口与实现编排业务逻辑、事务管理。Domain层实体类、枚举、业务规则、状态机。Infrastructure层仓储实现、DbContext、外部服务调用比如支付、短信。初学者最容易犯的错误是Controller1里写了订单逻辑Controller2里也写了订单逻辑结果同一个订单查询逻辑在多个地方被复制。把业务逻辑下沉到Application层之后Controller变得很薄每一段逻辑从哪进来都清清楚楚。依赖注入在这套分层里是最自然不过的基建需要把握三个生命周期生命周期适用场景我的习惯Transient轻量无状态服务几乎不用Scoped一个请求内共享DbContext、应用服务Singleton全局单例Redis客户端、配置对象DbContext一定是Scoped这点必须绑定因为EF Core的变更跟踪器是按上下文实例工作的请求内共享才能保证同一个业务操作共享一个工作单元。3.2 用EF Core做数据访问约定、配置与迁移EF Core在这几年的迭代里已经非常好用但对于订餐系统这种有一定复杂度的事务场景有几个细节值得注意。首先实体类的属性命名、外键导航属性、表名映射这些建议一半靠约定一半靠显式配置。约定能覆盖的比如Id作为主键就不用写配置但我倾向于在OnModelCreating里把关键字段的长度、精度、索引显式声明出来。比如订单号要加唯一索引订单表要在用户Id和创建时间上建联合索引这些在数据量上来之后是命根子。其次迁移工具要会用。在开发环境用dotnet ef migrations add和dotnet ef database update快速迭代表结构等部署到测试环境再多加小心。我发现很多初学者一改实体就直接删库重建这种习惯尽早改掉等项目里有真实数据时这一招会让你冷汗直冒。再次查询要用好Include但别在一次查询里Include太多层。外卖系统首页要展示店铺列表店铺下的部分商品这本来是很自然的对象图查询但如果一口气把店铺、商品、分类、评论全部Include出来EF生成的SQL会非常夸张。我的建议是先按场景拆分查询首页就查店铺概要点进店铺再查商品。3.3 RESTful API设计资源、动词与统一路由前缀接口设计是否合理直接影响前后端联调效率。外卖系统里比较典型的资源有商品、分类、购物车、订单、支付、配送。RESTful风格建议这样组织商品列表GET /api/foods?categoryId1page2商品详情GET /api/foods/{id}创建订单POST /api/orders取消订单POST /api/orders/{id}/cancel支付回调POST /api/payments/notify观察一下就会发现所有路径都加了/api前缀。这个前缀的作用是把业务接口和静态资源、健康检查、Swagger文档等非业务路径隔离开同时后面配网关、做nginx反向代理时前缀可以当作天然的转发条件。在Controller里一个个写[Route(api/foods)]当然可行但容易写漏、写错。更好的方式是统一配置。我实际用过三种方案各有适用场景。3.4 Swagger页面如何配置统一前缀三个可落地方案方案一改路由模板简单粗暴在Startup的Configure方法里用UsePathBase给整个应用挂一个基础路径app.UsePathBase(/api); app.UseSwagger(c { c.RouteTemplate swagger/{documentName}/swagger.json; c.PreSerializeFilters.Add((swaggerDoc, httpReq) { swaggerDoc.Servers new ListOpenApiServer { new OpenApiServer { Url ${httpReq.Scheme}://{httpReq.Host}/api } }; }); });这个方案的优点是实现极快路由不需要在Controller里写api/前缀。缺点是当你用nginx做二层转发时UsePathBase这个前缀在内部请求里会不会保留取决于转发头配置有些场景下Swagger文档里的Server地址会算错。初次练习时可以用但要理解它只是一个临时基座。方案二自定义ControllerModelConvention让路由自动带前缀这个方案是真正把前缀固化到路由规则里所有Controller的路由都会自动加上统一前缀你想要多灵活都行public class RoutePrefixConvention : IControllerModelConvention { private readonly string _prefix; public RoutePrefixConvention(string prefix) { _prefix prefix; } public void Apply(ControllerModel controller) { foreach (var selector in controller.Selectors) { if (selector.AttributeRouteModel null) { selector.AttributeRouteModel new AttributeRouteModel(); } selector.AttributeRouteModel new AttributeRouteModel( new RouteAttribute(_prefix (selector.AttributeRouteModel.Template ?? string.Empty)) ); } } }然后在注册服务时挂上services.AddControllers(options { options.Conventions.Add(new RoutePrefixConvention(api)); });这样可以保证Controller里可以继续写干净的路由比如[Route([controller])]运行时实际生效的路径会自动变成api/xxxSwagger文档里展示的也是带前缀的路径。我在正式项目里更倾向这个方案因为它的行为最可预期调试时直接看生成的路由表就能确认。方案三Swagger文档层面动态修正如果你不想动路由规则只希望Swagger页面显示统一的调用前缀可以只针对Swagger的Server地址做文章。上面的PreSerializeFilters就是这个思路不改Controller里的路由让文档里的服务器地址带上/api。三种方案怎么选我的建议是方案改动范围适用场景UsePathBase全局生效代码少纯开发环境、单体部署ControllerModelConvention绑定到路由规则正式项目、接口前后端长期联调Swagger Server地址修正只影响文档显示路径本身已带前缀只是文档地址不对我目前实际用的组合是ControllerModelConvention保证接口本身有前缀Swagger的RouteTemplate改成api-docs/{documentName}/swagger.json这样Swagger的元数据地址和组织前缀都清晰。这算是我踩过坑之后固定的配置模板。3.5 DTO与实体隔离接口层不说数据库方言还有一件事必须提就是Controller里不要直接返回实体对象。EF Core的实体上往往带着导航属性、状态标记直接序列化返回给前端既可能暴露多余字段又可能因为循环引用直接让序列化抛异常。正确做法是为接口单独定义DTO数据传输对象比如FoodDto只包含前端需要的名称、图片、价格、月售量。Controller内部把实体映射成DTO再用AutoMapper或手写映射方法返回。这样做还有额外好处当数据库字段变化时只要DTO结构稳定前端就感知不到。接口契约稳定是联调顺畅的基础。4. 进阶路上的坑我踩过的那些看着很小的问题做项目遇到坑非常正常关键是能不能把排查思路沉淀下来。我挑了四个印象最深刻的说。4.1 Swagger接口路径对不上一次404引发的彻底排查现象是Swagger页面展示的接口列表里路径显示为/api/order/1但点击Try it out执行后控制台一直返回404。我先检查Controller里的路由特性发现写的是[Route(order)]全局能生效的路由前缀是api理论上Swagger显示是对的。但为什么执行404后来我才发现问题出在Swagger文档生成时使用的DocumentName。我注册了两个Swagger文档一个给C端接口一个给B端接口每个文档过滤了不同Controller。但第一个文档配置时我给它设置的RouteTemplate路径里带了一个version占位符实际请求发出去的URL却和生成文档用的模板URL不一致导致Swagger UI上的请求地址和真实路由不匹配。排查链路是这样的先用浏览器的F12看请求实际发出的完整URL发现请求打到了/api/order/1这看起来没问题。再在Startup.Configure里启用app.UseRouting()之后打印路由表确认/api/order/1确实被路由到了正确的Action。最后检查Swagger的PreSerializeFilters才发现Server地址被改成了带端口号的另一个地址请求被带到了端口不匹配的地址上。这个坑的核心教训是Swagger UI里展示的URL由两部分组成——Server地址 接口路由模板。任何一个不一致点Execute就会失败。排查时先看F12面板不要盯着代码猜。4.2 高并发场景下的库存扣减从自杀式更新到正确姿势外卖订餐系统在秒杀场景下会遇到典型的库存超卖问题。最原始的实现是先查库存再用程序判断再更新这在并发访问下必然出错。比如库存剩1个两个请求同时查出来库存为1都判断允许下单都去更新库存结果就是卖出去2份。我建议的最低限度正确方案是用带条件检查的原子更新EF Core可以这样写var affected await _context.Foods .Where(f f.Id foodId f.Stock count) .ExecuteUpdateAsync(s s.SetProperty(f f.Stock, f f.Stock - count)); if (affected 0) { throw new BusinessException(库存不足); }ExecuteUpdateAsync直接生成一条UPDATE SQL并在WHERE里带上Stock count条件数据库层面的行锁保证并发安全。返回值是受影响的行数如果为0说明更新条件不成立也就是库存不够直接抛异常。如果是练习级项目这个方案已经完全够用。如果还想再进一步可以考虑用Redis的Lua脚本做库存预扣或者引入分布式锁但我觉得先把数据库方案吃透更重要——你至少要能说清楚为什么条件更新能防止超卖。4.3 时区与时间格式化前后端各自解读时间导致显示错乱订单列表页显示的下单时间前端总比数据库里存的时间早8个小时后来才发现是时区设置不一致。服务端用的是服务器本地时间存库时用的是UTC时间前端拿到字符串后用本地时区解析一来一回就错位了。我的习惯是数据库一律存UTC时间API返回的DTO里统一使用DateTimeOffset字段并在序列化时指定ISO 8601格式。前端再基于用户的时区做展示转换。这样不管用户在北京还是纽约看到的时间都是本地化后的正确时间。services.ConfigureJsonOptions(options { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.SerializerOptions.PropertyNamingPolicy JsonNamingPolicy.CamelCase; });时间格式上还会遇到一个常见问题System.Text.Json默认输出的时间格式是2025-01-01T00:00:00如果没有前端做处理直接显示会非常突兀。要么在DTO里用DateTimeOffset?并设置格式化要么前端约定好统一格式化工具。总之不要指望各自随意处理。4.4 日志与异常处理排错时才发现早知道就加了外卖订餐系统有一次线上反馈用户下单失败但没有提示我一看代码发现下单Service里catch了异常后只返回了一个false什么都没记。这种把异常吞掉的做法是项目里最危险的坏味道。我的建议是在Api层加全局异常处理中间件或Filter统一把未捕获异常转成规范错误响应。在Application层使用ILoggerT记录关键业务动作比如订单创建失败支付回调验签失败。在Service入口和出口各打一条日志好的格式包括请求Id、用户Id、订单号、耗时。接入Serilog后可以非常方便地把结构化日志输出到控制台、文件和第三方日志平台排错时按订单号一搜整条调用链就出来了。这个习惯能帮你省下大量联调时间。写到这里我甚至觉得日志应该算在功能需求里不算额外工作。5. 功能跑通之后还能往哪个方向演进项目能下单、能支付、能查看订单状态这只是完成了能用的阶段。如果你不是为了应付作业而是真想在.NET Core这条路上继续往深走我列几个我认为合理的扩展方向。第一把支付回调、订单超时自动取消这些异步流程引入消息队列。支付成功本可以直接同步改订单状态但引入RabbitMQ或Kafka后你能学到如何解耦业务流程、如何处理消费失败重试。第二给系统加上Redis缓存。首页的店铺列表、商品列表是典型的读多写少数据非常适合做缓存。把缓存击穿、穿透、雪崩这三个问题在项目里真实处理一遍对面试和实际工作都很有帮助。第三做性能压测和优化。用BenchmarkDotNet测试Service层方法用EF Core的日志看SQL生成是否合理用连接池和响应压缩提升吞吐量。你会发现能跑和能抗之间差距很大。第四把单体应用拆成几个服务比如用户服务、订单服务、配送服务每个服务独立部署练习服务间认证与API网关。但我的劝告是先别一下拆太细。很多人单体还没做好就开始微服务最后被分布式事务折磨得失去信心。你应该先把单体质量提上去再谈拆分。最后也是我觉得最宝贵的一点记录一下你自己的开发过程哪些地方卡了两个小时哪些设计后来改了三遍这些都是很有价值的经验。如果你也想把这套代码开源建议顺手把README写清楚把Docker Compose配置好让别人clone下来一条命令就能跑起来。能交付一个让别人跑得起来的项目是比任何证书都有说服力的作品。我个人在实际操作中的体会是做外卖订餐系统这类项目最大的收获不在于用了多少新技术而在于你被迫开始思考边界——接口的边界、状态的边界、数据的边界。当你开始为一个非法订单状态流转发愁为一个并发扣减的方案纠结时说明你已经不是在抄代码了。如果这篇文章能让你在动手之前多一些全局视角少走一小段弯路那它就算完成了任务。最后再分享一个运维层面的小技巧开发时把Swagger的RouteTemplate固定成api-docs/{documentName}/swagger.json这个习惯可以避免很多接口文档和真实路由不一致的麻烦等你前端同事来联调时会感谢你多留的这个心眼。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。