
做外卖平台这类项目最容易被忽略的其实不是某个功能怎么写而是商家、骑手、用户这三个角色之间的订单状态流转到底怎么设计。我前后带团队做过两版类似的项目第一版就是闷头写CRUD结果联调的时候才发现订单状态各自为政商家改一版、骑手改一版前端根本不知道听谁的。这篇文章把我基于SpringBootVue做外卖点餐平台时围绕外卖员和商家两个端口总结的经验全部分享出来包括数据库表怎么设计、订单状态机怎么定、WebSocket推送怎么接、打包部署会踩哪些坑。适合正在做毕业设计、课程项目或者刚进公司接手外卖/同城配送类系统的同学参考。1. 项目定位一个外卖平台里商家和骑手两个端口到底要解决什么问题1.1 角色拆解不是只有用户下单这么简单很多人一看到外卖点餐平台第一反应就是用户选菜、下单、支付、等送达然后就把全部精力放在用户端页面上。但真实的外卖系统最复杂的恰恰是商家端和骑手端。先说商家端。商家登录后台后要做什么接单、确认库存、出餐、呼叫骑手、处理退款。这中间还有一个关键动作——如果某个菜品已经卖完了商家要能实时下架用户端马上看不到。这意味着商品库存和下架状态要有一张独立表格维护而不是在用户每次查询时临时判断。再说骑手端。骑手不是平台分配的工具人现实中更多是抢单模式或者系统自动派单。骑手需要看到订单的取餐地址、送餐地址、预计配送费接单后要能更新配送状态已到店、已取餐、配送中、已送达。每一个状态变化用户端和商家端都要同步看到。所以这个项目虽然名字叫美食外卖点餐平台实际上是一个包含用户、商家、骑手、管理员四类角色的多端系统。管理员负责审核商家入驻、管理骑手资质、处理投诉用户负责下单评价商家和骑手是业务运转的核心执行方。1.2 技术选型SpringBootVue为什么是这个场景的稳妥组合选SpringBootVue不只是因为网上资料多、教程全而是这个组合恰好覆盖了外卖平台的核心技术需求。后端用SpringBoot是因为它天生适合快速搭建RESTful API。外卖平台的业务虽然角色多但本质上都是对订单、商品、用户、配送记录这几张表的增删改查加状态流转SpringBoot的starter机制可以让项目在几分钟内跑起来MyBatis-Plus或者Spring Data JPA都能很好支撑。配合Spring Security或者Sa-Token做鉴权Shiro做权限管理Redis做缓存和验证码存储整套技术栈非常成熟遇到问题搜一下就有答案。前端用Vue是因为外卖平台的后台管理页面和骑手端页面交互密度高、状态变化频繁。Vue的响应式机制特别适合这种场景商家后台的订单列表要实时刷新骑手端的接单按钮要根据订单状态切换样式Vue的组件化和响应式特性让这些逻辑变得非常直观。配合Element UI或者Vant组件库开发效率能翻倍。数据库层面MySQL是标配。订单、商品、用户这些核心数据都必须用关系型数据库保证事务一致性尤其是订单支付、退款这些涉及金额的操作事务是底线。Redis用来存验证码、购物车临时数据、热点商品缓存以及后面要说的分布式登录会话。这套组合的另一个优势是部署简单。前端build之后扔进Nginx或者直接放到SpringBoot的resources/static目录下一个jar包就能跑起来非常适合中小型外卖平台的落地场景。如果以后要扩展SpringBoot也能比较平滑地对接微服务框架。2. 数据库设计围绕订单展开的表结构与状态机2.1 九张核心表的建模思路外卖平台的业务表看起来很多但剥开来看核心是九张表用户表、商家表、骑手表、菜品分类表、菜品表、订单表、订单明细表、购物车表、配送记录表。管理员和权限相关的表另算我用的是RBAC标准五表模型。商家表和骑手表不能只存账号密码还需要冗余一些业务字段否则后续查询会频繁关联。商家表至少要包含店铺名称、店铺logo、联系电话、营业状态正常/打烊、起送价、配送费、评分、月销量。骑手表包含姓名、手机号、接单状态空闲/忙、当前订单ID、评分、接单总数。这些字段在订单列表和派单逻辑里会高频使用冗余存储能避免每次都要join查询。菜品表的设计有个容易踩坑的点价格字段一定要用decimal(10,2)不要用float或者double。外卖平台涉及金额计算浮点类型会产生精度问题虽然单笔订单差异很小但累计对账的时候就会出现分毛不差的尴尬。菜品表的字段包括菜品名称、图片、描述、原价、现价、月销量、库存、上架状态、所属分类、所属商家。订单表是整个系统的核心字段设计必须仔细。我的设计是订单编号、用户ID、商家ID、骑手ID、订单状态、支付状态、支付方式、商品总额、配送费、优惠金额、实付金额、收货人、收货地址、收货人电话、下单时间、支付时间、接单时间、送达时间、备注。订单编号不要用自增ID用时间戳加随机数生成或者用雪花算法否则订单号一长串数字暴露出去很容易被人猜测订单量。订单明细表单独拆出来是因为一个订单包含多个菜品每个菜品有自己的单价和数量。如果塞在订单表里面采用逗号分隔的方式存储查询是方便了但后续做销量统计、退款处理的时候会痛苦到怀疑人生。老老实实拆表订单ID、菜品ID、菜品名称冗余、单价、数量、小计。菜品名称冗余是为了防止菜品被商家删除后订单历史里查不到当时买了什么。2.2 订单状态机的设计六种状态和三种角色订单状态是外卖平台最容易出bug的地方。我第一版设计的时候只定义了待接单、配送中、已完成三个状态结果商家拒单、骑手取不到餐这些业务场景根本没法表达后来重构成了现在这套六状态模型待支付 - 待接单 - 已接单 - 配送中 - 已完成 待接单 - 已取消 配送中 - 退款中 - 已退款更严谨的做法是在数据库里建一张状态流转配置表记录每种状态下哪些角色可以执行哪些操作。不过对于单体项目来说用常量类加switch判断就够了我在代码里是这样定义的public class OrderStatus { public static final int PENDING_PAY 0; // 待支付 public static final int PENDING_ACCEPT 1; // 待接单用户已支付等待商家接单 public static final int ACCEPTED 2; // 商家已接单 public static final int DELIVERING 3; // 配送中 public static final int COMPLETED 4; // 已完成 public static final int CANCELLED 5; // 已取消 public static final int REFUNDING 6; // 退款中 }每次状态变更不要直接update状态字段了事要校验当前状态是不是合法前驱状态。比如配送中只能由已接单流转过来如果用户把钱付了订单还在待支付骑手点开始配送就应该报错。这个校验我建议放在Service层统一处理而不是在Controller里散落判断否则后面每个接口都要写一遍代码重复率极高。这里分享一个实际开发中的经验每个状态变更的地方顺便记一条订单日志。日志不需要复杂的表设计就是订单ID、操作角色类型、操作人ID、变更前状态、变更后状态、操作时间、备注。出了问题查这个日志表比debug翻代码快得多。我见过太多的外卖项目上线后商家说我没收到单、用户说我付了款各执一词最后查日志表一目了然解决纠纷。3. SpringBoot后端多角色鉴权、订单管理与配送分配的实现3.1 基于JWT的多角色统一鉴权外卖平台有用户、商家、骑手、管理员四类角色但在SpringBoot层面本质上都是登录后拿Token访问接口。我用JWT做无状态鉴权好处是前后端分离架构下不需要维护Session缺点是Token被偷了没法主动失效。对于外卖平台这种场景JWT完全够用Redis存储Token黑名单可以弥补主动登出的问题。鉴权流程是这样的登录接口根据角色类型从对应的表用户表、商家表、骑手表、管理员表查询账号密码验证通过后生成JWT。JWT的payload里面放三个关键信息用户ID、角色类型、登录账号。后续每次请求拦截器解析Token取出角色类型配合自定义注解做权限校验。自定义注解我命名为RequireRole用法很简单RequireRole(role RoleType.MERCHANT) PostMapping(/merchant/order/accept) public Result acceptOrder(RequestParam Long orderId) { // 商家接单逻辑 }拦截器里通过反射读取注解上的角色值和Token里解析出的角色对比不一致直接返回403。这样比在Controller里手写if(user.getRole() ! 2)要优雅得多也方便后续加新的角色类型。有一点要特别提醒JWT的密钥不要硬编码在代码里更不要用默认值。我见过有项目secret直接写123456用户拿一个改过payload的Token就能伪装成管理员。正确做法是配置在application.yml里部署的时候通过环境变量注入并且密钥长度至少要32位。3.2 订单分配自动派单和抢单两种模式的取舍商家接单之后的配送任务分配是外卖平台和普通电商系统差异最大的地方。我实现过两种模式各有优劣。自动派单模式订单进入待配送状态后系统根据骑手当前位置、接单状态、历史接单量进行评分把订单分配给评分最高的骑手。这个模式的优点是对骑手公平性可控缺点是后台需要维护骑手位置信息需要骑手端App定时上报GPS坐标项目复杂度会明显上升。抢单模式平台把待配送订单推送给所有空闲骑手骑手在30秒内点击抢单谁先抢到归谁。这个模式不需要维护GPS坐标逻辑简单很多但对骑手的自觉性要求高容易出现抢单后消极配送的情况。我的建议是课程设计或者毕业设计级别的项目用抢单模式就够了把核心精力放在订单状态流转和推送机制上。如果要做自动派单至少需要额外两张表骑手位置记录表、派单规则表还需要接入地图API计算配送距离。抢单模式只需要在订单状态从已接单变为待配送时向骑手端推送一条消息骑手点击接单按钮后更新订单的骑手ID即可。抢单接口有一个并发问题容易被忽视多个骑手同时抢同一个订单。如果直接用update order set rider_id ? where id ?这种SQL两个骑手都能抢成功最后一个覆盖前一个前一个骑手空欢喜一场。解决方式是在更新语句里加上and rider_id is null条件更新受影响行数为0就说明被别人抢走了int rows orderMapper.updateRiderForOrder(orderId, riderId); // 用 MyBatis 的 Update 方法SQL 中带 and rider_id is null if (rows 0) { throw new BusinessException(手慢了订单已被其他骑手接走); }这个方案比分布式锁轻量得多在高并发场景下也能保证抢单的原子性。3.3 Redis在项目中的落地场景Redis在这个项目里至少承担四种职责。登录验证码商家和骑手的登录接口都需要图形验证码验证码存Redis设置五分钟过期校验时从Redis取出比对用后即删。比存Session方便因为验证码的服务端状态天然适合用Redis的过期机制管理。Token黑名单用户退出登录时把JWT的jti唯一标识加入Redis黑名单设置和Token相同的过期时间。这样即使用户的Token在有效期内也无法再次使用。商家菜品缓存用户端首页和商家详情页的菜品数据查询压力最大。菜品上下架、价格变动虽然频繁但可以用Redis缓存加短过期时间比如30秒的方式把数据库压力降下来。每次商家修改菜品后主动删除对应商家的菜品缓存。购物车临时数据用户未登录情况下往购物车加菜数据可以临时放Redis登录后合并入库。这一步能减少很多临时表的操作。实际开发中Redis使用要设置key的规范我习惯用模块名:业务类型:ID的格式比如order:detail:102456、cart:user:10086排查问题的时候一眼就能看出来这个key是干什么的。4. Vue前端商家后台与骑手接单页面的核心交互4.1 商家端后台订单列表的轮询刷新与状态操作商家端后台我用Vue3加Element Plus实现核心页面是订单管理。这个页面的核心交互有两个一是订单列表要实时展示新订单二是商家要能在列表里直接完成接单、拒单、呼叫骑手等操作。订单列表的实时性我第一版用的是前端定时器每5秒钟调用一次查询接口。实现简单但有个问题用户下单高峰期5秒的轮询延迟会让商家觉得单子来得不够快。后来我改成了WebSocket推送新订单事件前端收到消息后把新订单插入列表头部并播放提示音。关于WebSocket的具体实现我在第5部分详细讲。商家对订单的操作我建议把操作按钮和订单状态绑定。比如订单在待接单状态时显示接单和拒单两个按钮在已接单状态时显示呼叫骑手按钮和开始配送按钮如果商家自己配送的话在配送中状态时只显示查看详情按钮。按钮的显隐逻辑放在前端组件里但后端接口也要做状态校验防止有人绕过前端直接调接口乱改状态。商家端还有一个容易被忽略的功能——菜品库存预警。当某个菜品的库存低于阈值比如5份时后台列表标红同时提供一键下架按钮。餐饮行业的实际场景是某个菜卖完了如果不及时下架用户端还能下单商家出不了餐处理起来非常麻烦。这个功能实现起来不复杂查询菜品的时候加一个库存字段的比较前端判断是否标红即可。4.2 骑手端接单页面从轮询到消息推送的演进骑手端的页面我最初设计了两个主要视图可抢订单列表和我的配送任务。可抢订单列表在抢单模式下的推送我用过两种方案。第一种是直接轮询每3秒拉一次可抢订单接口返回商家店铺信息、取餐地址、送餐地址、配送费。优点是实现简单缺点是骑手端一直发请求服务器压力大而且订单被抢走后列表里还残留点了才提示被抢走。第二种方案是WebSocket推送。商家呼叫骑手时后端向所有空闲骑手推送一条新订单消息骑手端的可抢订单列表实时刷新。实测下来消息的实时性和服务器压力都比轮询好很多。但这里要注意一个问题如果某个骑手在电梯里信号不好WebSocket断开了他重连之后需要能拉取到之前推送过的订单。所以我的做法是前端维护一个hasNewOrder的本地状态WebSocket收到消息时置为true切到可抢订单页面时不管有没有消息都主动调一次查询接口保证列表和服务器状态一致。骑手端的配送流程页面本质上是一个状态机驱动的向导页。骑手点击我已到店调用后端接口把订单状态从待取餐改为已到店同时给商家推送一条骑手已到店的通知。点击确认取餐后订单状态变为配送中这时用户端就能看到骑手的实时操作路径。每个按钮点击后前端要做乐观更新——先更新页面状态接口调用失败再回滚这样体验才流畅不然每点一次按钮都转圈骑手会崩溃的。4.3 Vue Router守卫和Axios拦截器的统一处理多角色系统的前端有个痛点不同角色登录后看到的页面完全不同。我用Vue Router的全局前置守卫做路由级权限控制router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); return; } const userRole localStorage.getItem(userRole); if (to.meta.roles !to.meta.roles.includes(userRole)) { next(/403); return; } next(); });路由表里通过meta.roles字段声明哪些角色可以访问这个页面比如/merchant/orders只允许商家角色访问/rider/tasks只允许骑手角色访问。Axios拦截器做两件事请求拦截器统一在Header里附加Token响应拦截器统一处理Token过期和业务错误码。Token过期时清空本地存储并跳转到登录页同时用Element Plus的Message组件弹出提示。这个统一的拦截逻辑做一次后面写接口只需要关注业务本身不用每个接口都写重复的错误处理。5. 实时订单推送WebSocket让三端状态同步的落地方式5.1 推送方案选型为什么没有用轮询全覆盖前面提到了商家端和骑手端都涉及订单状态实时变化。从技术实现角度看有三种方案前端定时器轮询、SSEServer-Sent Events、WebSocket。定时器轮询的优点是实现简单跟前端定时器调用接口就行后端零改动。缺点也很明显实时性控制在轮询间隔内间隔设短了服务器压力大间隔设长了用户体验差。SSE是服务端单向推送基于HTTP长连接实现比WebSocket简单很多普通H5页面和后端SpringBoot都能很好支持。但SSE是单向的客户端不能通过同一个连接向服务端发消息对于外卖平台这种需要频繁交互的场景不够灵活。WebSocket是双向通信服务端可以主动推送客户端也能通过连接发送消息。适合订单状态推送、骑手位置更新、商家接单通知这类实时通信场景。缺点是连接管理有成本尤其在集群部署时需要额外的方案解决跨节点消息广播问题。对于单体架构的外卖平台SpringBoot内置的WebSocket支持就够了不需要引入额外的MQ。我的最终方案是订单状态变化的核心通知走WebSocket列表数据的兜底同步走轮询。两者结合既保证实时性又避免因为WebSocket偶发断线导致的数据不一致。这个设计思路在中小型项目中非常实用。5.2 SpringBoot后端WebSocket的具体实现后端我用Spring的WebSocket模块引入依赖就可以开始写Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(orderWebSocketHandler(), /ws/order) .addInterceptors(new WebSocketAuthInterceptor()) .setAllowedOrigins(*); } Bean public WebSocketHandler orderWebSocketHandler() { return new OrderWebSocketHandler(); } }连接建立后我把用户的角色和用户ID存到WebSocketSession的属性里并注册到内存中的Session管理器。Session管理器用ConcurrentHashMap实现key是用户ID加角色前缀value是WebSocketSession集合。为什么是集合同一个用户可能开着两个标签页每个标签页建立一个WebSocket连接都存进去推送时遍历发送。订单状态变化时后端根据业务规则找到需要通知的对象调用推送方法。比如用户下单支付后需要通知对应商家有新订单商家接单后需要通知用户订单已被接单同时通知骑手有可抢订单。public class OrderWebSocketHandler extends TextWebSocketHandler { private static final MapString, SetWebSocketSession SESSIONS new ConcurrentHashMap(); public static void sendToUser(String userId, String role, String message) { String key role : userId; SetWebSocketSession sessions SESSIONS.get(key); if (sessions null) return; for (WebSocketSession session : sessions) { if (session.isOpen()) { session.sendMessage(new TextMessage(message)); } } } }这里有个高并发下的坑往多个Session发送消息如果某个Session发送失败抛异常会导致其他Session收不到消息。需要捕获异常并且发送失败时把Session从集合中移除避免死循环影响服务性能。5.3 前端Vue的WebSocket处理细节前端我用了一个独立的socket.js模块封装WebSocket连接逻辑核心是自动重连机制。WebSocket在移动网络和弱网环境下连接很脆弱经常出现静默断线。前端需要在onclose事件里做重连重连间隔从1秒开始逐步递增最长不超过30秒避免服务端异常时客户端疯狂重连。function connect() { const token localStorage.getItem(token); const ws new WebSocket(ws://${location.host}/ws/order?token${token}); ws.onmessage (event) { const data JSON.parse(event.data); // 根据消息类型分发处理 EventBus.emit(data.type, data.payload); }; ws.onclose () { setTimeout(() { connect(); }, reconnectInterval); }; ws.onopen () { reconnectInterval 1000; // 重置重连间隔 }; }Token怎么传我放在URL的query参数里后端在WebSocket握手拦截器里解析并校验。有些做法是通过Sec-WebSocket-Protocol请求头传Token但浏览器对自定义协议头限制比较多URL传参最省事。注意Token里可能含有.和-字符URL传参时不需要额外编码但如果Token是放在路径里就需要处理了。前端收到消息后通过EventBus我用的是mitt库分发到各个页面组件。商家后台订单列表组件监听new_order事件骑手端页面监听new_delivery_task事件每个组件只处理自己关心的事件做到解耦。这样新增一个功能不需要改动已有的消息处理逻辑只要加一个新的事件类型就行。6. 联调与部署打包上线过程中的关键点和排错思路6.1 前端打包部署的两种方式和细节外卖平台的前端有两种部署方式我分别说下利弊。第一种是前端独立部署到Nginx通过反向代理把/api开头的请求转发到SpringBoot服务。这种方式的优点是可以单独迭代前端代码不需要重新打jar包而且Nginx可以缓存静态资源性能好。缺点是需要处理跨域问题配置Nginx的时候容易踩坑。Nginx配置大概长这样server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # Vue Router history模式必须加 } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /ws/ { proxy_pass http://127.0.0.1:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }try_files那行非常重要Vue Router如果用history模式刷新某个子路由页面时Nginx会返回404加上try_files重写到index.html就解决了。/ws/的反向代理要设置Upgrade和Connection头否则WebSocket握手会失败。第二种方式是直接把前端打包产物复制到SpringBoot的src/main/resources/static目录下重新打包成jar一个jar包启动所有服务。这种方式适合课程设计和个人项目部署最简单不需要单独配Nginx。缺点是没有独立部署的灵活性而且前端改动一次就需要重新打包整个后端项目。6.2 部署过程中最容易遇到的三个问题第一个问题是数据库连接不上。外卖平台的配置文件里数据库地址、账号、密码都是写在application.yml里的部署到服务器之后如果服务器的MySQL设置了bind-address只允许本机访问或者3306端口被防火墙挡了SpringBoot启动就会直接报Communications link failure。排查思路是先确认telnet数据库IP 3306端口通不通再确认MySQL用户是否允许远程登录。注意MySQL8.0之后的认证方式改成了caching_sha2_password旧的驱动可能不支持需要升级mysql-connector-java的版本。第二个问题是上传的图片无法访问。外卖平台的菜品图片本地开发时存在本地磁盘路径部署到服务器后路径对不上导致图片404。我的建议是图片上传保存到服务器的固定目录比如/data/upload在SpringBoot里配置一个虚拟路径映射把/upload/**映射到磁盘目录。部署文档里一定要写清楚这个路径的创建和权限设置否则换了服务器就踩坑。第三个问题是接口返回的IP地址不对。用户支付回调或者服务端推送的消息里如果包含服务器自身的URL用的是localhost或者内网IP客户端访问不到。排查方法是在部署环境里用curl访问接口看返回的内容里是否有内网IP如果有检查配置文件的域名或外网地址是否正确。6.3 压力测试和并发量评估外卖平台有比较明显的流量波峰集中在午晚高峰。我做了简单的JMeter压测单机部署、4核8G的服务器SpringBoot加MySQL的配置下核心接口订单创建、订单列表查询能扛住每秒200左右的请求量响应时间在500毫秒以内。这个量级对于校园外卖、小型区域外卖平台完全够用。如果后续业务量增长优化顺序我建议是先给订单表加索引重点覆盖商家ID 订单状态 下单时间的联合索引这是商家端查询订单列表最常用的查询条件。然后引入Redis缓存热点数据降低数据库压力。再往后才是引入消息队列削峰、搭建集群等重架构改造。很多项目一上来就搞微服务、分布式事务结果业务没跑起来架构先把自己绕晕了。外卖平台的核心是业务逻辑正确、订单状态清晰架构演进跟着业务走才对。项目做下来我个人最大的体会是外卖平台这类系统真正的难点从来不在于用了什么高深的技术而在于把三个角色的业务流程理清楚把订单状态机定义好再把状态变化通过推送机制及时同步给所有相关方。只要这三件事做扎实了哪怕代码写得朴素一点系统也能稳定运行。反过来如果业务流程没理顺再花哨的技术栈也救不了混乱的逻辑。希望这篇文章能帮你少走一些弯路。