Spring Boot+Vue外卖点餐项目:Service层设计与订单状态机实战

发布时间:2026/9/11 1:50:55
Spring Boot+Vue外卖点餐项目:Service层设计与订单状态机实战 简介这套Java毕业设计外卖点餐系统采用Spring Boot与Vue前后端分离架构集成Vant移动端组件与Element UI管理端组件适合计算机相关专业学生用于毕业设计、课程实践也适合初级开发者学习主流技术栈整合与项目工程化组织方式。资源共282个文件核心包括Java后端源码、Vue前端页面、XML配置文件、JS脚本与SQL数据库脚本另配有png、jpg等界面图片与markdown说明文档压缩包整体仅14.17MB结构清晰便于按需取用。目前已有5238人学习下载属于轻量且热度较高的项目素材。获取后可得到完整的前后端工程骨架、数据库初始化脚本、启动运行配置文件以及界面预览素材订单服务等业务实现类展示了分层开发思路配合演示动图与目录说明能有效支撑从环境搭建、功能调试到毕业设计文档撰写的全流程。该项目既适合直接二次开发也适合作为学习主流Java Web技术栈的入门范本。1. 一个 Spring Boot Vue 外卖点餐项目值得关注的不是页面而是 Service 层如果你拿到这个压缩包先点开OrderServiceImpl.java和ShopServiceImpl.java而不是急着看index.html说明你已经抓住了这类全栈项目的命门页面只是表象服务层的职责划分和订单状态流转才是答辩时能被追问十分钟的地方。这是一个典型的三端闭环——用户端用 Vant 组件做点餐页、运营端用 Element UI 做管理页、Spring Boot 后端统一出接口数据围绕商家、菜品、订单三张主表展开。适合两类人准备毕业设计、想把“功能完整”讲成“设计正确”的学生以及想借一套全栈脚手架快速改造成预约、排队、商城系统的在职开发者。下面按数据模型、后端服务、前端协作、本地联调的顺序拆开讲。2. 数据模型与工程骨架Shop、Dish、Orders 如何支撑双前端2.1 前后端分离分离的到底是什么前后端分离表面上拆的是工程实际上拆的是“接口契约”。Vant 移动端和 Element UI 管理端共用同一套 Spring Boot 接口后端只要保证 URL 和 JSON 结构不变前端路由怎么跳、按钮怎么排都不影响核心流程。这个项目的压缩包里既有index.html和favicon.ico也有mvnw.cmd和maven-wrapper.jar说明演示版本把前端构建产物放进了后端发布目录开发时则走 dev server 与 8080 端口分开联调。看后端源码的组织方式典型结构应该是这样com.example.takeout ├── controller ├── service │ ├── impl │ │ ├── OrderServiceImpl.java │ │ └── ShopServiceImpl.java ├── mapper ├── entity └── dtoController 层做参数接收和结果包装真正的业务逻辑落在service下的两个实现类里。这个分层在毕业设计里不是可选项而是必选项因为一旦把金额计算、状态判断写在 Controller 里后续加一个微信小程序端就要重写一套逻辑而微软的 Service 层方案可以直接复用。2.2 商家、菜品、订单三张表的设计要点外卖系统最核心的三张主表是shop商家、dish菜品、orders订单另外还需要一张order_item存订单明细。建表语句的常见版本如下CREATE TABLE shop ( shop_id bigint NOT NULL AUTO_INCREMENT, shop_name varchar(64) NOT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1营业 0打烊, PRIMARY KEY (shop_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE dish ( dish_id bigint NOT NULL AUTO_INCREMENT, shop_id bigint NOT NULL, dish_name varchar(64) NOT NULL, price decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (dish_id), KEY idx_shop (shop_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;订单主表需要同时挂用户和商家并保存订单总金额和状态CREATE TABLE orders ( order_id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id bigint NOT NULL COMMENT 下单用户ID, shop_id bigint NOT NULL COMMENT 商家ID, total_amount decimal(10,2) NOT NULL, remark varchar(255) DEFAULT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2制作中 3配送中 4已完成 -1已取消, create_time datetime NOT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (order_id), KEY idx_shop_status (shop_id, status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;明细表里有两个字段值得注意dish_name和price是从菜品表冗余过来的。用户在 12:00 点的菜商家在 13:00 改价订单里记录的仍是下单那一刻的价格和名称这是电商订单系统的常规做法。CREATE TABLE order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, dish_id bigint NOT NULL, dish_name varchar(64) NOT NULL COMMENT 冗余菜品名, price decimal(10,2) NOT NULL COMMENT 下单时价格快照, num int NOT NULL, PRIMARY KEY (id), KEY idx_order (order_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;orders表刻意用数值型status而不是字符串好处是代码里比较状态只是order.getStatus() 1建表注释里写清含义就不会混淆。shop与dish是一对多orders与order_item也是一对多两张从表都通过外键字段加索引查询链路不会被全表扫描拖慢。2.3 Maven Wrapper 是项目里最容易忽略的基础设施压缩包里的mvnw.cmd和maven-wrapper.jar解决的是团队协作的 Maven 版本一致性问题。只要本地有 JDK不需要预装 Maven直接执行cd backend mvnw.cmd spring-boot:runmacOS 或 Linux 下执行./mvnw spring-boot:run。Wrapper 会读取.mvn/wrapper/maven-wrapper.properties指定的 Maven 版本自动下载到本地仓库再运行避免“我本地能跑、你本地报错”的经典问题。提示如果启动时提示 JDK 版本不匹配先看pom.xml里的java.version是 8 还是 17再调整本机 JDK而不是盲目升级 Spring Boot 版本。3. 订单与商铺的服务实现事务边界、状态机与库存扣减3.1 OrderServiceImpl一次下单背后的完整链路OrderServiceImpl是这个项目里最值得读的一个类因为下单不是简单往表里插一条记录而是“校验商家状态 → 后端重算金额 → 落订单主表 → 落明细表”四步动作。常见实现是Service RequiredArgsConstructor public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final ShopMapper shopMapper; private final DishMapper dishMapper; Override Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 校验商家是否营业 Shop shop shopMapper.selectById(dto.getShopId()); if (shop null || shop.getStatus() ! 1) { throw new BizException(店铺不存在或已打烊); } // 2. 后端重新计算金额前端传的金额只作展示 BigDecimal total BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { Dish dish dishMapper.selectById(item.getDishId()); total total.add(dish.getPrice() .multiply(BigDecimal.valueOf(item.getNum()))); } // 3. 写入订单主表状态为待支付 Orders order new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setShopId(dto.getShopId()); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 4. 批量写入订单明细 for (OrderItemDTO item : dto.getItems()) { OrderItem orderItem new OrderItem(); orderItem.setOrderId(order.getOrderId()); orderItem.setDishId(item.getDishId()); orderItem.setDishName(dishMapper.selectById(item.getDishId()).getDishName()); orderItem.setPrice(dishMapper.selectById(item.getDishId()).getPrice()); orderItem.setNum(item.getNum()); orderItemMapper.insert(orderItem); } return convertToVO(order); } }这段逻辑有三个关键点第一金额必须在后端重算不能让前端传总价否则用户改请求体就能零元购第二Transactional(rollbackFor Exception.class)保证四步操作要么全部成功、要么全部回滚不会出现“主表有订单、明细表为空”的脏数据第三商家状态校验前置打烊状态下直接抛业务异常。这里有个可以优化的点dishName和price在第 4 步中各自查了一次菜表循环里如果点了 10 个菜就要查 10 次。常见做法是先把菜品按 ID 批量查出来MapLong, Dish dishMap dishMapper.selectBatchIds( dto.getItems().stream().map(OrderItemDTO::getDishId).toList() ).stream().collect(Collectors.toMap(Dish::getDishId, d - d));这样一次 SQL 拿到全部菜品再循环组装明细接口响应时间在菜品种类多时能明显下降。3.2 订单状态机数值状态与流转边界订单状态是这个项目另一个高频考查点。一般流程是status 值含义触发动作代码位置0待支付用户提交订单createOrder1已支付模拟支付回调payOrder2制作中商家接单acceptOrder3配送中商家/骑手发货deliverOrder4已完成用户确认收货completeOrder-1已取消超时或用户取消cancelOrder实现状态流转时常见错误是直接用不相邻的状态硬改例如从“待支付”直接跳到“已完成”。严谨做法是在更新 SQL 里加状态条件UPDATE orders SET status #{newStatus}, update_time NOW() WHERE order_id #{orderId} AND status #{expectedStatus}expectedStatus在代码里是上一个状态。这个 UPDATE 影响行数为 0 时说明当前状态不是预期值直接抛异常。这种“乐观状态更新”不引入额外锁也保证了状态机的顺序合法比先查询再判断更优雅。3.3 ShopServiceImpl菜单组装与营业状态过滤ShopServiceImpl主要解决“用户进店看到什么”的问题它要把店铺信息和菜单一次性组装好给前端Service public class ShopServiceImpl implements ShopService { private final ShopMapper shopMapper; private final DishMapper dishMapper; Override public ShopDetailVO getShopDetail(Long shopId) { Shop shop shopMapper.selectById(shopId); if (shop null) { throw new BizException(店铺不存在); } ListDish dishList dishMapper.selectByShopIdAndStatus(shopId, 1); ShopDetailVO vo new ShopDetailVO(); BeanUtils.copyProperties(shop, vo); vo.setDishList(dishList); return vo; } }dishMapper.selectByShopIdAndStatus(shopId, 1)中的第二个参数固定传 1也就是说只查询上架菜品。这里把“上架/下架”的过滤逻辑放进 SQL 而不是 Java既减少了传输数据量也避免了下架菜品出现在点餐页。BeanUtils.copyProperties做属性拷贝是 Spring 的常规用法字段少的场景下完全够用。如果要在店铺列表页显示“月售 xx 单”通常做法是在 SQL 里聚合订单明细SELECT shop_id, COUNT(*) AS month_sales FROM orders WHERE create_time DATE_FORMAT(NOW(), %Y-%m-01) AND status IN (1, 2, 3, 4) GROUP BY shop_id注意这里过滤掉了status 0待支付和status -1已取消因为只有真实进入交易流程的订单才计入销量。这个细节可以在答辩时作为“业务完整性”的佐证。3.4 库存扣减用一条 SQL 解决超卖外卖不比秒杀但仍然存在“最后一份菜品被两个人同时下单”的可能。常见的错误做法是先查询库存再判断是否大于 0最后 UPDATE这在高并发时会超卖。推荐做法是条件更新UPDATE dish SET stock stock - #{num} WHERE dish_id #{dishId} AND stock #{num}代码里判断UPDATE返回的影响行数是 1 则扣减成功是 0 则库存不足直接提示用户“菜品已售罄”。这一条 SQL 完成了“检查库存 扣减”的原子操作不需要分布式锁是毕业设计里性价比最高的并发优化手段。4. Vant 点餐端与 Element UI 管理端一套接口两种页面4.1 Vant 移动端点餐页列表、SKU 与购物车Vant 是面向移动端的 Vue 组件库点餐页最核心的交互是“菜品列表 加入购物车”。常见布局是template div classdish-list van-card v-fordish in dishList :keydish.dishId :titledish.dishName :pricedish.price template #footer van-stepper :model-valuecart[dish.dishId] || 0 change(val) updateCart(dish, val) / /template /van-card /div /templatevan-stepper是数量步进器cart对象以dishId为键、数量为值。购物车本质上不需要后端参与前端组件维护一个内存对象即可提交订单时把cart展开成items数组传给后端。移动端接口调用建议单独封装到一个api模块里// src/api/order.js import request from /utils/request export function createOrder(data) { return request.post(/order/create, data) }这里只暴露了createOrder一个函数页面里写import { createOrder } from /api/order即可。把接口地址收敛到一个文件里后端路径改了只改这一处比在组件里散落axios.post可维护得多。4.2 Element UI 管理端订单表格与状态筛选管理端面向运营人员核心诉求是把订单列清楚、能改状态。el-table是最常用的表格组件template div classorder-manage el-table :dataorderList border stripe el-table-column proporderNo label订单号 width180 / el-table-column proptotalAmount label金额 / el-table-column label状态 template slot-scope{ row } el-tag :typestatusTypeMap[row.status] {{ statusTextMap[row.status] }} /el-tag /template /el-table-column el-table-column label操作 width200 template slot-scope{ row } el-button sizemini typeprimary :disabledrow.status ! 1 clickhandleDeliver(row) 发货 /el-button /template /el-table-column /el-table /div /template管理端通过statusTextMap把数值状态映射为中文statusTypeMap映射为 Element UI 的标签颜色。按钮里的:disabledrow.status ! 1是前置条件控制只有已支付订单才允许发货和 Spring Boot 后端的乐观状态更新形成前后双重校验。4.3 登录与鉴权的统一封装这个项目涉及两种角色骑行移动端是普通用户管理端是运营人员token 是区分身份的主要手段。前端拦截器统一做两件事请求带上 token、响应 401 时跳登录。常用写法// src/utils/request.js import axios from axios import { Toast } from vant import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { router.push(/login) } else { Toast.fail(请求失败请稍后重试) } return Promise.reject(error) } ) export default service这段代码里baseURL配成/api而不是完整地址是为了配合开发环境的代理转发Bearer token是常见的 Authorization 格式后端用拦截器统一解析。把拦截器写在utils/request.js里移动端和管理端各自引用即可。5. 本地联调JDK、Maven Wrapper、MySQL 与常见报错5.1 环境确认与版本影响开始之前先确认本机环境java -version node -v npm -v mysql --version后端是 Spring Boot 工程JDK 8 或 11 足以应付绝大多数毕业设计如果pom.xml里是 Spring Boot 3.x则需要 JDK 17。前端 Vue 项目的 node 版本建议不低于 16太低会装不上最新依赖。5.2 数据库初始化与数据源配置用 Navicat 或命令行创建数据库并执行项目的 SQL 脚本mysql -uroot -p CREATE DATABASE takeout DEFAULT CHARACTER SET utf8mb4; USE takeout; SOURCE /path/to/takeout.sql;utf8mb4必须指定否则 emoji 表情和生僻字会报编码错误。接着改后端的数据源配置spring: datasource: url: jdbc:mysql://localhost:3306/takeout?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverserverTimezoneAsia/Shanghai不能省不然 Java 和 MySQL 的时间会差 8 小时订单创建时间看起来像在下班后。JDK 8 项目如果用的旧驱动driver-class-name需要写成com.mysql.jdbc.Driver新版驱动cj结尾是标配。5.3 启动顺序与跨域/代理配置先启动后端再启动前端cd backend mvnw.cmd spring-boot:run前端另开一个终端cd frontend-mobile npm install npm run dev开发环境最大的坑是跨域。前端跑在 5173后端在 8080直接请求会被 CORS 拦截。常见做法是前端配本地代理Vite 项目的配置// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })原理是浏览器只请求了前端 dev server 的/api路径dev server 再把请求转发给 8080浏览器看不到跨域请求自然不会被拦截。生产环境则交给 Nginx 做反向代理效果相同。5.4 常见报错对照表报错现象原因处理方式Port 8080 was already in use端口被占用关掉占用进程或修改application.yml的server.portFailed to load ApplicationContext数据库连接失败检查 MySQL 是否启动、账号密码是否正确java.sql.SQLException: Unknown database takeout数据库没建执行CREATE DATABASE takeout ...npm ERR! ERESOLVE unable to resolve dependency tree依赖版本冲突执行npm install --legacy-peer-deps404 thrown for GET /api/shop/list代理没有匹配到路径检查代理中的/api前缀和后端context-path是否一致Invalid bound statement (not found)MyBatis 接口与 XML 映射不匹配检查mapper.xml的 namespace 和 mapper 接口路径其中npm ERR! ERESOLVE在 Vue 2 Vant 2 Element UI 的老项目中非常常见因为 Babel 与 eslint 的 peer dependency 互相冲突。--legacy-peer-deps是用来绕过检查的通用钥匙但不代表依赖真的兼容装完后一定要启动 dev server 跑一跑页面。6. 进阶技巧订单超时未支付的自动关单外卖场景里用户提交订单后不付款不能让他永远占着库存。常见做法是给“待支付”状态加一个过期时间用一个定时任务把超时订单改成已取消。具体实现在 Spring Boot 里加一个定时任务类Component Slf4j RequiredArgsConstructor public class OrderTimeoutTask { private final OrderMapper orderMapper; Scheduled(fixedDelay 60000) public void closeExpiredOrders() { // 15 分钟前创建且未支付的订单改为已取消 Date expireTime new Date(System.currentTimeMillis() - 15 * 60 * 1000L); int count orderMapper.closeExpiredOrders(expireTime); if (count 0) { log.info(自动关闭超时订单 {} 单, count); } } }对应的 SQL 只更新状态为 0 的订单避免误伤已支付记录UPDATE orders SET status -1, update_time NOW() WHERE status 0 AND create_time #{expireTime}Scheduled(fixedDelay 60000)表示上一次任务执行完后隔 60 秒再执行一次。相比fixedRatefixedDelay在任务执行时间长于间隔时不会产生堆积。启动类上需要加EnableScheduling否则定时任务不生效。一个必须注意的边界定时任务轮询适合数据量不大的场景订单表几十万条时全表 UPDATE 会很慢。常见优化是用LIMIT分批更新比如每次只取 200 条待关闭订单处理避免长时间锁表。另外关闭订单时如果没检查菜品库存是否归还会导致支付页显示有货、实际下单时库存不足——库存归还逻辑要和关单在同一个事务里处理。如果更严格的实时性要求可以再配合 Redis下单时SET order:{orderNo} 1 EX 900利用 key 过期机制触发关闭。但 Redis key 过期事件默认不开启需要在 redis.conf 里配置notify-keyspace-events Ex并且过期事件不保证准时只适合做辅助兜底。毕业设计场景下Scheduled轮询已经足够把上面这条乐观 UPDATE 讲清楚答辩时就能从“会写 CRUD”提升到“考虑了数据一致性和并发边界”的档位上。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询