Spring Boot+Vue外卖系统源码解析:订单状态机与Redis缓存设计
2026/9/16 21:15:52 网站建设 项目流程

简介:基于Springboot+Mybatis+Vue构建的餐饮外卖系统设计源码,是一套面向Java全栈学习者、毕业设计及中小餐饮团队的前后端分离项目。系统覆盖外卖员、顾客、菜品、套餐、购物车、订单等核心模块,借助Mysql持久化存储与Redis缓存提升查询效率,适合理解真实外卖业务开发链路。压缩包共209个文件,大小37.24MB,含67个Java后端逻辑文件、22个JavaScript交互脚本、20个HTML页面、18个CSS样式及18个XML配置,另附数据库初始化脚本和Maven依赖配置,便于导入开发环境运行。当前已有98人学习下载。通过源码可看到前后端接口对接方式、订单状态流转与购物车处理细节,目录结构完整,适合作为课程设计、毕业设计或二次开发基础,也能帮助开发者掌握餐饮系统模块拆分与Redis缓存使用。

1. 外卖系统看似简单,拆开才发现订单状态机才是重头戏

做过外卖类项目的人都知道,市面上能跑通的点餐系统demo不少,但真正把外卖员、顾客、菜品、套餐、购物车、订单这六个模块串成一个闭环的源码并不多。这套基于Springboot+Mybatis+Vue的餐饮外卖系统,源码包里有209个文件,包括67个Java文件、22个JavaScript文件、20个HTML文件和18个XML文件,但它的价值不在于文件数量,而在于订单状态流转和购物车数据的持久化策略——这两个点恰恰是很多初学者在自研外卖系统时最容易翻车的地方。后端采用典型的Controller-Service-Mapper三层结构,前端用Vue做单页应用,配合Redis做菜品缓存。对于正在做毕业设计、或者想接手一个真实外卖系统二次开发的Java工程师来说,这份源码值得拆开来逐模块过一遍。

2. Springboot+Mybatis后端骨架搭建与购物车核心逻辑

2.1 Maven依赖与分层架构的选型理由

打开pom.xml,第一眼就能看出这个项目没有引入过于复杂的微服务组件,而是保持了单体应用的克制。核心依赖集中在spring-boot-starter-web、mybatis-spring-boot-starter、redis和mysql-connector上。为什么选Mybatis而不是JPA?因为外卖系统的订单查询条件组合多、变化频繁,比如按状态查、按时间范围查、按配送员id查,Mybatis的动态SQL可以精准控制每一条查询语句的编译结果,避免JPA自动生成的SQL在复杂条件下产生多余的全表扫描。

<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>

mybatis-spring-boot-starter版本锁定在2.2.0,是因为它与Springboot 2.3.x到2.6.x的兼容性最稳。如果你用的Springboot版本高于2.7,建议换成2.3.0以上的Mybatis starter,否则启动时会出现Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required的报错。Redis的starter在后续缓存菜品数据时会用到。

2.2 实体类设计与Mybatis映射

实体的命名和字段设计直接影响后续的Mapper编写质量。以菜品模块为例,Dish实体不仅包含基本的name、price、image字段,还带了一个categoryId作为菜品与分类表的关联键。实操中你会发现,没有这个冗余的categoryId,菜品列表的联表查询会变成N+1次查询——先查所有菜品,再逐条查分类名。

@Data public class Dish { private Long id; private String name; private BigDecimal price; private String image; private Long categoryId; private Integer status; private LocalDateTime createTime; }

对应的DishMapper.xml里,动态SQL的核心在于status字段的可选条件。这个设计意味着前端菜品管理页的筛选、上下架操作、套餐内菜品展示都可以复用同一条查询语句。

<select id="pageQuery" resultType="com.example.entity.Dish"> SELECT * FROM dish <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY create_time DESC </select>

<where>标签会自动去掉第一个多余的AND,避免你手写WHERE 1=1的坏习惯。CONCAT做模糊查询时,#{name}会被预编译成占位符,不会产生SQL注入风险。status的判断加了一个非空校验,如果前端传0(表示下架),Mybatis的if标签会认为0是空值导致条件失效——这里必须用!= null而非!= ''来判断。

2.3 购物车模块的合并与暂存设计

购物车是这个系统里逻辑密度最高的一个模块。前端页面调用的add-order接口,后端接收数据的Car实体包含dishId、userId、number、amount等字段。购物车存在数据库表而不是Redis,原因在于外卖场景下用户可能会清缓存或换设备,购物车内容必须能在重新登录后恢复。

public void addCart(Cart cart) { LambdaQueryWrapper<Cart> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Cart::getUserId, cart.getUserId()) .eq(Cart::getDishId, cart.getDishId()); Cart exist = cartMapper.selectOne(wrapper); if (exist != null) { exist.setNumber(exist.getNumber() + 1); cartMapper.updateById(exist); } else { cart.setNumber(1); cartMapper.insert(cart); } }

这段逻辑解决的是购物车的同菜品合并问题。用户连续点击两次"加入购物车",不会生成两条记录,而是把number字段从1变成2。这里用了Mybatis-Plus的LambdaQueryWrapper,比纯Mybatis的@Select注解更易读。注意selectOne方法在查询到多条记录时会抛异常,所以userId和dishId在业务上必须保证唯一,前端删除菜品时也要同步清理cart表。

购物车的金额计算不要在前端做。前端传过来的amount字段在add接口处应该被重新计算,以数据库里的Dish价格为准,否则用户抓到HTTP请求后可以自己改价格再下单。这个系统的订单提交接口在查询菜品价格时用了for update悲观锁吗?没有——但没有没关系,Redis缓存菜品数据配合数据库行锁,已经能挡住并发下的超卖问题,后面第4章会展开讲。

3. Vue前端页面结构与vant组件的适配

3.1 前端路由与页面文件映射

前端部分的资源配置很典型,vant.min.css主导移动端风格,main.css和index.css负责整体布局。页面文件共20个HTML,对应Vue单页应用中的不同视图。路由设计上采用了懒加载模式,把商家端和用户端页面按需拆包。

const routes = [ { path: '/', component: () => import('./pages/index.html'), meta: { title: '首页' } }, { path: '/add-order', component: () => import('./pages/add-order/index.html'), meta: { title: '下单页面' } }, { path: '/address', component: () => import('./pages/address/index.html'), meta: { title: '地址管理' } } ];

懒加载的好处是首屏只加载index相关资源,等到用户跳转到add-order页面时才发起对应JS和CSS的请求。meta.title字段可以配合路由守卫动态修改浏览器标签页标题,这个细节很多demo都不会处理。注意这里路径中加了.html后缀,实际Vue-cli构建时会把HTML文件当作模板编译,如果直接跑开发模式需要确认vue.config.js里的pages多入口配置是否匹配。

3.2 axios请求封装与token注入

外卖系统的前后端分离,最关键的是请求拦截器。所有需要登录的接口,比如提交订单、查询购物车、修改地址,都必须携带登录凭证。

axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); axios.interceptors.response.use( response => { const res = response.data; if (res.code === 401) { router.push('/login'); } return res; }, error => { if (error.response.status === 401) { router.push('/login'); } return Promise.reject(error); } );

拦截器把token统一注入到请求头,后端Springboot项目里对应的拦截器会解析Authorization头并校验登录态。response拦截器里对401的统一处理,避免了每个业务页面都写一遍if (res.code === 401)的重复代码。错误分支里还处理了HTTP层面的401,这两层判断都是必须的——业务返回码401和HTTP状态码401是两个概念,前者可能因为是token过期被后端业务逻辑拦截,后者可能是网关层直接拒绝了请求。

3.3 菜品列表与样式预加载

CSS文件数量有18个,这在移动端项目里并不算多。common.css存放全局通用样式,page.css按页面拆分。实操中注意vant.min.css的引入顺序必须放在自定义css之前,否则button组件的默认样式会覆盖你在main.css里写好的圆角改掉。页面中有一些动画效果依赖demo.css,这个文件在打包时体积偏大,如果部署到生产环境,建议用PurgeCSS把未使用的样式类剔除,能减少大约37%的CSS体积。

4. 数据库表结构设计与Redis缓存穿透防护

4.1 db_reggie.sql的核心表拆解

项目根目录下的db_reggie.sql是真正的骨架。打开这个文件,重点关注三张核心表:user顾客表、orders订单表、dish菜品表。orders表里有一个status字段,用0-5六个数字标注订单在用户、商家、外卖员之间的流转状态。

CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, order_no VARCHAR(64) UNIQUE NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2配送中 3已完成 4取消 5退款', address_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id (user_id), INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

order_no设了唯一索引,这是防止重复下单的第一道防线。status上的普通索引服务于外卖员端的待接单查询。TINYINT类型比INT省3个字节,在订单数据量达到百万级别时,这个存储优化能让索引树的层数少一层。DECIMAL(10,2)而不是FLOAT,是因为金额精度不容许浮点误差。配送员表courier和orders表是逻辑关联——orders表里没有存courier_id,而是通过courier_order中间表维护多对多关系,这样设计是为了支持"一个配送员同时配送多单"的业务场景。

4.2 Mybatis二级缓存与Redis一级缓存的配合

Mybatis的二级缓存默认关闭,且文档明确说明不推荐在多表联查时开启,因为关联表的更新会导致缓存脏数据。这个项目选择把Redis作为缓存层,重点缓存菜品分类和菜品列表等读多写少的数据。

spring: redis: host: 127.0.0.1 port: 6379 timeout: 5000ms lettuce: pool: max-active: 16 max-idle: 8

max-active控制最大连接数,外卖系统在午高峰时段并发访问菜品列表的QPS会陡增,max-active设置过小会导致连接等待超时。lettuce连接池默认的timeout是5秒,如果Redis部署在独立服务器,网络延迟在100ms以上,建议调整到8000ms以上,否则高峰期可能出现RedisConnectionFailureException

在菜品模块,缓存策略是key=categoryId,value=菜品JSON列表。为了防止缓存穿透——也就是恶意用一个不存在的分类id循环请求——Service层要主动把空值也写入缓存。

@Component public class DishCacheHelper { @Autowired private StringRedisTemplate redisTemplate; @Autowired private DishMapper dishMapper; public List<Dish> getDishesByCategory(Long categoryId) { String key = "dish:category:" + categoryId; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseArray(json, Dish.class); } List<Dish> dishes = dishMapper.selectByCategoryId(categoryId); // 空值也缓存,设置90秒过期 redisTemplate.opsForValue().set(key, JSON.toJSONString(dishes), 90, TimeUnit.SECONDS); return dishes; } }

空值缓存的有效期设为90秒,比正常菜品的缓存时间短,这样既能挡住瞬时穿透,又不会在菜品真被添加时长时间查到空列表。另一个容易忽略的点是:Dish对象里的LocalDateTime字段需要配置Jackson的JavaTimeModule,否则序列化JSON时会抛InvalidDefinitionException。在项目里找到ObjectMapper的配置类,手动注册JavaTimeModule即可。

4.3 订单号生成策略

订单表里order_no字段的生成方式,直接影响了短信通知和支付回调的关联效率。源码里的生成工具用了时间戳加随机数的组合:

public static String generateOrderNo() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); }

这种生成的订单号长度是18位,在单机部署下够用。但如果后续业务扩展到多实例,需要考虑引入雪花算法。不过这个作业级别的项目里,时间戳加随机数的最大好处是调试方便——看到订单号能直接判断出下单时间,查看日志时不用再查create_time字段。

5. 从IDEA启动到线上部署的完整验证路径

5.1 本地启动的步骤与常见坑

clone下源码后,按下面顺序依次执行能最快把系统跑起来。

mysql -uroot -p < db_reggie.sql redis-server --daemonize yes mvn spring-boot:run -Dspring-boot.run.profiles=dev

数据库初始化脚本执行后,需要确认三张核心表里有没有种子数据。有些版本的脚本没插入菜品分类数据,导致打开前端首页时分类菜单是空的,这是本地环境最常见的"假死"问题。检查category表是否为空,为空则手动插入几条测试数据,否则前端所有菜品列表都会因为联表查不到分类名而报错。

前端部分如果直接用Vite启动,注意vue.config.js里的devServer需要配置代理。

devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } }

后端接口的访问前缀若没有统一加/api,代理后路径匹配不上会导致404。这里的pathRewrite/api前缀去掉后再转发给后端,后端Controller里的@RequestMapping则不需要额外配置context-path。

5.2 订单缓存与状态更新的时序问题

订单模块的高频操作是用户支付后更新订单状态。这个操作涉及的缓存key是"user:orders:userId"。我一般建议只在用户查询订单列表时走缓存,订单状态更新时直接穿透到MySQL,并主动删除缓存key。因为Redis的过期时间不好把控,一旦定义为30分钟,用户支付后30分钟内查订单列表看到的状态还是"待支付",体验极差。删除缓存比更新缓存更安全——下一次查询会触发回源数据库。

@Transactional public boolean updateOrderStatus(Long orderId, Integer targetStatus) { int rows = orderMapper.updateStatus(orderId, targetStatus); if (rows > 0) { String key = "user:orders:" + orderMapper.selectById(orderId).getUserId(); redisTemplate.delete(key); } return rows > 0; }

这段代码加@Transactional是为了保证订单状态更新和缓存删除的一致性。注意事务提交后Redis的删除操作才真正被执行——如果有其他线程正好在高并发窗口期读到旧缓存,依赖缓存里订单状态做业务判断的逻辑需要容忍极短时间的数据滞后。

这个系统的完整价值在于,它不是单纯的CRUD练习,而是把外卖行业里订单分配、购物车合并、菜品缓存这几个难点融到了同一个Maven工程里。顺着src目录一条条拆下去,把Controller层的入参校验、Mapper层的动态SQL、Vue页面里的响应式数据流对应起来吃透,这份源码能顶得上自己从零写两个月。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询