ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SpringBoot+Vue外卖配送系统全栈实战:从下单到骑手签收的完整链路

SpringBoot+Vue外卖配送系统全栈实战:从下单到骑手签收的完整链路

简介:这是一套面向计算机专业毕业设计场景的外卖配送管理系统完整源码包,采用SpringBoot后端与Vue前端分离架构,数据库基于MySQL,适合正在准备毕设或课程设计的学生参考与二次开发。系统覆盖用户管理、优惠券领取、通知提醒、多支付方式(银行卡、微信、支付宝)以及账号登录与密码找回等核心模块,功能链路较为完整。压缩包共1993个文件,包含388个vue组件、368张png图片、284个js脚本、206个css样式、131个java源文件及1个sql数据库脚本,另有xml配置、jar依赖与mp3、mp4等素材,整体约37.41MB,目录结构清晰,便于按前后端模块检索。已有39人学习下载。读者可据此快速搭建可运行项目,理解前后端交互与数据库设计思路,并在此基础上完成功能扩展与论文撰写。

1. 外卖配送管理系统:从下单到骑手签收,一套 SpringBoot+Vue 全栈骨架怎么搭

外卖配送管理系统这个题目,看着像课程设计,实际做起来会撞上一堆真问题:订单状态怎么流转、骑手位置怎么回传、商家接单超时怎么处理、数据库表怎么设计才不返工。我见过太多人一上来就写 Controller,写到一半发现订单表和配送表对不上,只能推倒重来。这套基于 SpringBoot+Vue 的外卖配送管理系统,核心就是把「用户下单 → 商家接单 → 骑手抢单 → 配送中 → 签收」这条链路用代码固化下来,前端用 Vue 做三端界面(用户端、商家端、骑手端),后端用 SpringBoot 提供 REST 接口,MySQL 存业务数据。适合谁?适合想拿一个完整全栈项目练手的人,也适合需要快速搭出配送类业务原型的小团队。下面我按实际落地顺序,把选型、建表、接口、联调、踩坑一条条拆开讲。

2. 技术选型与工程骨架:为什么用 SpringBoot 3 + Vue 3 而不是老版本

2.1 版本组合的取舍:避开 springboot版本太高 的兼容坑

热词里有个「springboot版本太高」,这确实是高频翻车点。SpringBoot 3.x 要求 JDK 17 起步,而很多教程还在用 JDK 8,直接导致javax.servlet变成jakarta.servlet,老代码复制过来就报ClassNotFoundException。我的建议是:如果团队没有历史包袱,直接上 SpringBoot 3.2.x + JDK 17 + Vue 3.4 + Vite 5,这是目前最稳的组合。如果必须用 JDK 8,那就锁死 SpringBoot 2.7.x,别混着抄 3.x 的代码。

数据库选 MySQL 8.0,字符集用utf8mb4,排序规则utf8mb4_0900_ai_ci。外卖系统里商家名、菜品名经常有生僻字和 emoji,用utf8会丢字符。连接池用 HikariCP(SpringBoot 自带),别额外引 Druid,除非你需要它的监控面板。

前端 Vue 3 用 Composition API +<script setup>,路由用 vue-router 4,状态管理用 Pinia。UI 库选 Element Plus,骑手端如果要做移动端适配,可以再叠一个 Vant。打包用 Vite,构建产物直接扔进 SpringBoot 的src/main/resources/static目录,这就是热词里说的「vue打包放进springboot中」,后面第 5 章会讲具体配置。

2.2 工程目录结构:前后端分离但同仓管理

我一般会用一个 Maven 多模块或者干脆单模块 + 前端独立目录的方式。单模块结构对新手最友好,目录如下:

takeout-system/ ├── src/main/java/com/takeout/ │ ├── controller/ # 接口层 │ ├── service/ # 业务层 │ ├── mapper/ # MyBatis-Plus Mapper │ ├── entity/ # 数据库实体 │ ├── dto/ # 入参出参对象 │ ├── config/ # 配置类 │ └── common/ # 统一返回、异常处理 ├── src/main/resources/ │ ├── application.yml │ ├── mapper/ # XML 映射文件(可选) │ └── static/ # Vue 打包产物放这里 ├── frontend/ # Vue 源码 │ ├── src/ │ ├── package.json │ └── vite.config.js └── pom.xml

后端依赖核心就几个:spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、lombok、hutool-all(工具类)。别引spring-boot-starter-security,除非你真要做权限,否则登录拦截用拦截器 + JWT 就够了,引了 Security 反而要写一堆配置。

<!-- pom.xml 关键依赖,版本按 SpringBoot 父 POM 走 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

逻辑说明:MyBatis-Plus 省掉大量单表 CRUD 的 XML,外卖系统里订单、菜品、骑手信息大部分是单表操作,用它能把开发速度提一倍。参数说明:mybatis-plus-boot-starter版本要跟 SpringBoot 版本匹配,3.5.5 对应 SpringBoot 3.x,如果报NoClassDefFoundError就降级到 3.5.3.1。

2.3 前端环境配置:vue安装及环境配置 的最小步骤

热词里「vue安装及环境配置」是新手第一道坎。Node.js 装 18.x 或 20.x LTS,别装 21 奇数版。装完执行:

node -v # 确认 v18.x 或 v20.x npm install -g pnpm # 用 pnpm 比 npm 快,且省磁盘 pnpm create vite frontend --template vue cd frontend pnpm install pnpm add vue-router pinia element-plus axios pnpm dev

逻辑说明:pnpm create vite生成的是 Vue 3 + Vite 的裸骨架,路由和状态管理要自己加。参数说明:--template vue是 JavaScript 版,如果要 TypeScript 就写--template vue-ts。外卖系统我建议用 JS,因为后端返回的字段名经常变,TS 类型定义维护成本高,新手容易卡在类型报错上。

3. 数据库设计:订单表、配送表、骑手表怎么建才不返工

3.1 核心表结构与字段说明

外卖配送系统的数据库设计,核心就五张表:user(用户)、merchant(商家)、dish(菜品)、orders(订单)、delivery(配送记录)。其中orders和delivery是一对一关系,但不要合并成一张表,因为配送状态和订单状态流转节奏不一样。

表名关键字段说明
userid, phone, password, address, rolerole 区分用户/商家/骑手
merchantid, name, address, statusstatus 控制营业/打烊
dishid, merchant_id, name, price, stockstock 为 0 时前端置灰
ordersid, user_id, merchant_id, total, status, create_timestatus 见下方枚举
deliveryid, order_id, rider_id, status, accept_time, finish_timerider_id 抢单后写入

订单状态枚举我一般这样定:0待支付、1待接单、2待取餐、3配送中、4已完成、5已取消。配送状态单独一套:0待抢单、1已接单、2已取餐、3已送达。两套状态通过order_id关联,别混在一个字段里,否则后面查「配送中超时订单」会写得很痛苦。

CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `merchant_id` BIGINT NOT NULL, `total` DECIMAL(10,2) NOT NULL DEFAULT '0.00', `status` TINYINT NOT NULL DEFAULT '0' COMMENT '0待支付 1待接单 2待取餐 3配送中 4已完成 5已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_merchant_status` (`merchant_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:idx_merchant_status联合索引是为了商家端查「待接单列表」时走索引,不然订单量一上来就全表扫。参数说明:DECIMAL(10,2)存金额,别用FLOAT,否则 0.1+0.2 会出现 0.30000000000000004 的玄学问题。

3.2 订单状态流转的 SQL 与并发控制

订单状态流转最容易出的问题是并发:两个骑手同时抢一单。解决办法是在delivery表上用乐观锁或者UPDATE ... WHERE status = 0的条件更新。

-- 骑手抢单:只有 status=0 时才能抢到 UPDATE delivery SET rider_id = #{riderId}, status = 1, accept_time = NOW() WHERE id = #{deliveryId} AND status = 0;

逻辑说明:这条 SQL 返回的影响行数如果是 0,说明单子已经被别人抢了,直接给前端返回「手慢了」。参数说明:#{riderId}从 JWT 里解析,别信前端传的 riderId,否则可以伪造抢单。这是血泪经验,早期版本我信了前端参数,测试时被同事用 Postman 抢了 20 单。

3.3 数据库增删改查 的 MyBatis-Plus 写法

热词里「数据库增删改查」是基本功。用 MyBatis-Plus 后,单表 CRUD 不用写 SQL:

// 查询商家待接单列表 LambdaQueryWrapper<Orders> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Orders::getMerchantId, merchantId) .eq(Orders::getStatus, 1) .orderByAsc(Orders::getCreateTime); List<Orders> list = ordersMapper.selectList(wrapper);

逻辑说明:LambdaQueryWrapper用方法引用代替字符串字段名,重构时改字段名不会漏改 SQL。参数说明:orderByAsc按创建时间升序,保证先来的订单先显示。如果数据量大,要加分页,用Page<Orders>对象,别一次性selectList查全部。

4. 后端接口与前端联调:从下单到签收的完整链路

4.1 下单接口:参数校验与库存扣减

下单接口是核心,要处理三件事:校验菜品库存、计算总价、生成订单和配送记录。我一般把这三步放在一个@Transactional方法里。

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderDTO dto, Long userId) { // 1. 校验并扣减库存 for (OrderItemDTO item : dto.getItems()) { int affected = dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (affected == 0) { throw new BizException("菜品[" + item.getDishName() + "]库存不足"); } } // 2. 计算总价(后端算,不信前端传的 total) BigDecimal total = orderItemService.calcTotal(dto.getItems()); // 3. 插入订单 Orders order = new Orders(); order.setUserId(userId); order.setMerchantId(dto.getMerchantId()); order.setTotal(total); order.setStatus(1); // 直接待接单,简化支付流程 ordersMapper.insert(order); // 4. 插入配送记录 Delivery delivery = new Delivery(); delivery.setOrderId(order.getId()); delivery.setStatus(0); deliveryMapper.insert(delivery); return buildOrderVO(order); }

逻辑说明:库存扣减用UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},靠数据库行锁保证不超卖。参数说明:rollbackFor = Exception.class确保任何异常都回滚,包括检查型异常。总价必须后端算,前端传的 total 只能用来做展示对比,不能入库。

4.2 骑手位置回传与前端地图展示

骑手端要定时上报位置,后端存 Redis(别存 MySQL,写入太频繁)。前端用地图组件展示骑手位置,热词里「mapbox vue」和「canvas 2d vue」都有人搜,但外卖系统用高德或百度地图的 JS API 更省事,因为国内坐标不用转换。

// 骑手端:每 10 秒上报一次位置 setInterval(() => { navigator.geolocation.getCurrentPosition(pos => { axios.post('/api/rider/location', { lat: pos.coords.latitude, lng: pos.coords.longitude }) }) }, 10000)

逻辑说明:setInterval在页面隐藏时会降频,移动端浏览器可能挂起,所以实际生产要用 WebSocket 或至少加visibilitychange监听。参数说明:10 秒是折中值,太频繁耗电,太慢用户看到的位置滞后。后端收到后写 Redis,key 用rider:location:{riderId},过期时间 60 秒,前端查的时候如果 key 不存在就显示「位置更新中」。

4.3 前端路由与接口封装

Vue 路由按角色分三个布局:用户端/user/*、商家端/merchant/*、骑手端/rider/*。登录后根据 role 跳不同首页。接口封装用 axios 拦截器统一加 token。

// frontend/src/utils/request.js import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( res => res.data, err => { if (err.response?.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(err) } ) export default request

逻辑说明:请求拦截器加 token,响应拦截器统一处理 401 跳登录。参数说明:baseURL: '/api'配合 Vite 的server.proxy做开发代理,生产环境由 Nginx 或 SpringBoot 静态资源转发。别在拦截器里弹 Element Plus 的 Message,因为拦截器可能在非组件环境执行,会报错。

5. 避坑与排查:外卖配送系统落地时最容易翻车的 5 个点

5.1 现象:订单状态更新了但前端没变 → 原因:缓存没清 → 解决:更新后主动删 Redis key

订单状态变更后,如果前端查的是 Redis 缓存,会出现「数据库改了但页面还是旧状态」。我一般在updateOrderStatus方法最后加redisTemplate.delete("order:detail:" + orderId)。别用@CacheEvict注解,因为 key 的生成规则容易写错,手动删更可控。

5.2 现象:骑手抢单后订单列表不刷新 → 原因:前端轮询间隔太长 → 解决:用 WebSocket 或缩短轮询

早期我用 30 秒轮询,骑手抢完单商家端要等半分钟才看到。后来改成 WebSocket 推送,SpringBoot 用@ServerEndpoint或 STOMP,前端用new WebSocket()。如果不想引 WebSocket,至少把轮询降到 5 秒,并且只在「待接单」页面轮询,别全局轮询。

5.3 现象:Vue 打包后刷新页面 404 → 原因:history 路由模式没配后端 fallback → 解决:SpringBoot 加转发

Vue 用createWebHistory后,直接访问/merchant/orders会 404,因为 SpringBoot 找不到这个路径。解决办法是加一个配置类,把所有非/api开头的请求转发到index.html:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/**") .addResourceLocations("classpath:/static/") .resourceChain(true) .addResolver(new PathResourceResolver() { @Override protected Resource getResource(String path, Resource location) throws IOException { Resource resource = location.createRelative(path); if (resource.exists() && resource.isReadable()) return resource; return new ClassPathResource("/static/index.html"); } }); } }

逻辑说明:自定义PathResourceResolver,文件存在就返回文件,不存在就返回index.html,交给 Vue 路由处理。参数说明:classpath:/static/是 Vue 打包产物默认放的位置,如果改了vite.config.js的build.outDir,这里要同步改。

5.4 现象:MySQL 连接超时 → 原因:HikariCP 默认连接数太小 → 解决:调 maxPoolSize

外卖系统高峰期并发下单,默认maximumPoolSize=10不够用,会报Connection is not available。在application.yml里调:

spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 30000

逻辑说明:maximum-pool-size按数据库max_connections的 70% 设,别设满。参数说明:connection-timeout30 秒是等待连接的超时,不是查询超时,查询超时用 MyBatis 的defaultStatementTimeout。

5.5 现象:前端传的时间格式后端解析失败 → 原因:时区或格式不匹配 → 解决:统一用 ISO 8601

Vue 的new Date()转 JSON 是 ISO 格式,SpringBoot 默认能解析,但如果前端用了dayjs格式化成了yyyy-MM-dd HH:mm:ss,后端LocalDateTime会报错。统一约定:接口传时间一律用 ISO 8601(2024-01-01T12:00:00),前端展示时再格式化。在 DTO 字段上加@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")。

6. 进阶技巧:把 Vue 打包产物塞进 SpringBoot 并做一键启动

最后一章讲一个具体技巧:怎么把 Vue 打包产物和 SpringBoot 打成一个可执行 JAR,双击就能跑,不用装 Nginx。这个在交付给客户或者做课程设计答辩时特别有用。

6.1 Vite 构建配置与产物路径

先改frontend/vite.config.js,把构建产物直接输出到 SpringBoot 的静态资源目录:

// frontend/vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], base: '/', // 部署在根路径 build: { outDir: '../src/main/resources/static', // 输出到后端静态目录 emptyOutDir: true // 每次构建清空旧文件 }, server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

逻辑说明:outDir指向后端static目录,emptyOutDir防止旧文件残留导致刷新出旧页面。参数说明:base: '/'是部署在根路径,如果部署在/takeout/子路径,要改成base: '/takeout/',同时后端server.servlet.context-path也要同步。

6.2 Maven 打包与一键启动脚本

在pom.xml里加spring-boot-maven-plugin,然后写一个build.sh:

#!/bin/bash # build.sh:一键构建前后端并打包 set -e echo ">>> 构建前端..." cd frontend && pnpm install && pnpm build && cd .. echo ">>> 打包后端..." mvn clean package -DskipTests echo ">>> 完成,JAR 在 target/ 目录" java -jar target/takeout-system-1.0.0.jar

逻辑说明:set -e让脚本在任何一步失败时停止,避免前端构建失败还继续打包后端。参数说明:-DskipTests跳过测试,生产构建建议加上测试,但课程设计阶段可以跳。JAR 启动后访问http://localhost:8080就是 Vue 页面,/api开头的请求走 SpringBoot 接口。

6.3 验证清单与我的习惯

打包完别急着交付,按这个清单过一遍:

检查项预期结果失败时看什么
访问/显示登录页看 static 目录有没有 index.html
登录后刷新不 404看 WebConfig 的 fallback 是否生效
下单接口返回订单号看控制台 SQL 和库存字段
骑手抢单只有一人成功看 UPDATE 影响行数
打包 JAR 大小50-80MB超过 100MB 检查是否把 node_modules 打进去了

我自己的习惯是:每次改完前端,先pnpm build再启动后端,别用pnpm dev联调完就直接打包,因为开发环境的代理和生产环境的路径经常不一致。这个坑我踩过三次,每次都是「本地好好的,打包就白屏」。希望帮到你。

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

返回列表