简介基于SpringbootMybatisVue构建的餐饮外卖系统设计源码是一套面向Java全栈学习者、毕业设计及中小餐饮团队的前后端分离项目。系统覆盖外卖员、顾客、菜品、套餐、购物车、订单等核心模块借助Mysql持久化存储与Redis缓存提升查询效率适合理解真实外卖业务开发链路。压缩包共209个文件大小37.24MB含67个Java后端逻辑文件、22个JavaScript交互脚本、20个HTML页面、18个CSS样式及18个XML配置另附数据库初始化脚本和Maven依赖配置便于导入开发环境运行。当前已有98人学习下载。通过源码可看到前后端接口对接方式、订单状态流转与购物车处理细节目录结构完整适合作为课程设计、毕业设计或二次开发基础也能帮助开发者掌握餐饮系统模块拆分与Redis缓存使用。1. 外卖系统看似简单拆开才发现订单状态机才是重头戏做过外卖类项目的人都知道市面上能跑通的点餐系统demo不少但真正把外卖员、顾客、菜品、套餐、购物车、订单这六个模块串成一个闭环的源码并不多。这套基于SpringbootMybatisVue的餐饮外卖系统源码包里有209个文件包括67个Java文件、22个JavaScript文件、20个HTML文件和18个XML文件但它的价值不在于文件数量而在于订单状态流转和购物车数据的持久化策略——这两个点恰恰是很多初学者在自研外卖系统时最容易翻车的地方。后端采用典型的Controller-Service-Mapper三层结构前端用Vue做单页应用配合Redis做菜品缓存。对于正在做毕业设计、或者想接手一个真实外卖系统二次开发的Java工程师来说这份源码值得拆开来逐模块过一遍。2. SpringbootMybatis后端骨架搭建与购物车核心逻辑2.1 Maven依赖与分层架构的选型理由打开pom.xml第一眼就能看出这个项目没有引入过于复杂的微服务组件而是保持了单体应用的克制。核心依赖集中在spring-boot-starter-web、mybatis-spring-boot-starter、redis和mysql-connector上。为什么选Mybatis而不是JPA因为外卖系统的订单查询条件组合多、变化频繁比如按状态查、按时间范围查、按配送员id查Mybatis的动态SQL可以精准控制每一条查询语句的编译结果避免JPA自动生成的SQL在复杂条件下产生多余的全表扫描。dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.2.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependencymybatis-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菜品列表的联表查询会变成N1次查询——先查所有菜品再逐条查分类名。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 idpageQuery resultTypecom.example.entity.Dish SELECT * FROM dish where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if if testcategoryId ! null AND category_id #{categoryId} /if /where ORDER BY create_time DESC /selectwhere标签会自动去掉第一个多余的AND避免你手写WHERE 11的坏习惯。CONCAT做模糊查询时#{name}会被预编译成占位符不会产生SQL注入风险。status的判断加了一个非空校验如果前端传0表示下架Mybatis的if标签会认为0是空值导致条件失效——这里必须用! null而非! 来判断。2.3 购物车模块的合并与暂存设计购物车是这个系统里逻辑密度最高的一个模块。前端页面调用的add-order接口后端接收数据的Car实体包含dishId、userId、number、amount等字段。购物车存在数据库表而不是Redis原因在于外卖场景下用户可能会清缓存或换设备购物车内容必须能在重新登录后恢复。public void addCart(Cart cart) { LambdaQueryWrapperCart 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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: 8max-active控制最大连接数外卖系统在午高峰时段并发访问菜品列表的QPS会陡增max-active设置过小会导致连接等待超时。lettuce连接池默认的timeout是5秒如果Redis部署在独立服务器网络延迟在100ms以上建议调整到8000ms以上否则高峰期可能出现RedisConnectionFailureException。在菜品模块缓存策略是keycategoryIdvalue菜品JSON列表。为了防止缓存穿透——也就是恶意用一个不存在的分类id循环请求——Service层要主动把空值也写入缓存。Component public class DishCacheHelper { Autowired private StringRedisTemplate redisTemplate; Autowired private DishMapper dishMapper; public ListDish getDishesByCategory(Long categoryId) { String key dish:category: categoryId; String json redisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseArray(json, Dish.class); } ListDish 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.profilesdev数据库初始化脚本执行后需要确认三张核心表里有没有种子数据。有些版本的脚本没插入菜品分类数据导致打开前端首页时分类菜单是空的这是本地环境最常见的假死问题。检查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页面里的响应式数据流对应起来吃透这份源码能顶得上自己从零写两个月。本文还有配套的精品资源点击获取