Spring Boot+MyBatis外卖项目源码:从0到1运行与优化指南
简介瑞吉外卖项目完整源码基于 Spring Boot、Java 与 MyBatis 构建面向初入后端开发的程序员及毕业设计选题者覆盖从环境搭建到功能实现的完整流程。项目修复了课堂演示遗留的多处问题补齐订单状态同步、地址默认管理、分页查询性能优化等核心模块全部功能测试通过可直接运行适合作为外卖类应用二次开发的基础。压缩包共 662 个文件包含后端 Java 源码与 class 编译文件、Mapper 映射文件、application 配置、前端页面、图片、字体及多种静态资源整体约 117MB目录结构清晰按模块组织便于研读。已有 8318 人学习下载。阅读并部署该项目可掌握 Spring Boot 自动配置、MyBatis 持久层操作、前后端数据交互等核心技能积累真实项目中的排错与优化经验对课程设计、求职项目展示均有直接参考价值。1. 拿到外卖项目源码后快速建立竞争力的一条路很多人手里存了一份瑞吉外卖源码包Spring Boot MyBatis 是它的骨架却卡在“不知道从哪看起、跑起来全是报错、讲不出业务亮点”。这份资源的价值不在代码量而在于它把一套完整的外卖业务链路——员工登录、菜品分类、套餐组合、购物车、订单流转——全部落在一个可运行的工程里。适合三类人刚学完 Java 基础想练手的学生准备面试需要项目经历的初级开发者以及想快速给毕设搭一套可用系统的人。下面我按自己拆项目的顺序把它从源码包变成你能讲清楚、能改、能部署的东西。2. 技术栈与工程结构Spring Boot MyBatis 的经典形态2.1 为什么选这套组合而不上微服务这套项目没有用 Spring Cloud也没有引入复杂的注册中心和网关而是老老实实地用 Spring Boot MyBatis MySQL 单应用架构。这个选型本身就是教学项目的最佳平衡点Spring Boot 负责自动化配置和 Web 层MyBatis 负责 SQL 控制权MySQL 存业务数据。你不需要在“服务拆了几个”“消息队列用的哪种”上花太多精力而应该聚焦在业务闭环上。我一般会建议看源码的人先接受一个前提单机架构不等于低端。对一个日单量在千级的外卖系统单应用加合理索引完全够用。这个项目让你学会的是“一个请求从浏览器到数据库的全过程”而不是被分布式概念干扰。它的依赖也干净——核心就是 spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、druid 连接池、lombok外加一个分页插件。把这些依赖逐个读懂比盲目堆积技术栈有用得多。2.2 后端目录结构与前端资源的边界拿到源码包后先看后端 Java 目录。典型结构是 controller、service、mapper、entity 四层外加 config 和 common 两个辅助包没有过度设计。controller 层只做参数接收和结果封装service 层写业务判断mapper 层对应数据库操作entity 里是表映射对象。我要提醒你注意 common 包里那几个类比如统一返回结果 R、异常处理类、还有 BaseContext 这类用 ThreadLocal 存用户信息的工具类。很多面试问题就藏在这里——比如“怎么在 service 里拿到当前登录用户”答案就是 BaseContext。前端资源一般放在 src/main/resources/static 下页面是 HTML Vue Axios通过后台接口取数据。前后端分离但没有拆成两个工程是教学项目最常见的折中。看的时候要注意 static 里按模块分的目录backend 是管理端页面frontend 是用户端页面这样你就知道登录后跳转的页面路径是哪里来的。2.3 核心数据表及其业务含义数据库的表设计是这套源码的精华。我建议你按业务顺序建表理解不要按字母表看。员工表 employee 存管理员账号密码密码用 MD5 加密存储分类表 category 里 type 字段区分菜品分类和套餐分类1 是菜品分类2 是套餐分类菜品表 dish 和菜品口味表 dish_flavor 是父子关系口味表用 dish_id 关联套餐表 setmeal 和套餐菜品关系表 setmeal_dish 是多对多关系一个套餐包含多个菜品。购物车表 shopping_cart、订单表 orders、订单明细表 order_detail 是链路后端。其中有个很容易忽略的字段——orders 表里的 number 是订单号order_detail 里的 dish_flavor 字段存的是菜品的口味文本比如“少冰,微糖”。这种“把非结构化数据直接存成文本”的做法在实际开发里非常常见也值得你在面试时提一句。3. 把源码跑起来环境准备、数据库初始化与验证步骤3.1 环境版本对齐JDK、Maven、MySQL 怎么配先别急着导代码环境不对会浪费你一小时。这套源码实际要求 JDK 8 以上、Maven 3.5 以上、MySQL 5.7 或 8.0。我的建议是 JDK 用 8 或 11不要直接上 17因为老版本项目里某些依赖在 JDK 17 下会有模块访问报错。Maven 用 IDEA 内置的就行但仓库镜像要配好不然下载依赖能卡半小时。# 检查本机版本三个命令必须都能输出版本号 java -version mvn -version mysql --version版本检查的意义在于提前暴露问题。比如你本机装的是 MySQL 8.0而项目里的驱动版本很老之后连接会报认证插件错误。如果出现这种问题升级驱动版本到 8.0.x 即可。Maven 的 settings.xml 里建议加上国内镜像具体配置各环境不同但核心就是让依赖下载走镜像仓库。3.2 创建数据库并初始化表与基础数据这是最容易出问题的一步。源码包里通常会带一个 db 目录或 sql 目录里面是建库脚本。我建议用命令行导入而不是图形化工具拖拽因为你能清楚看到报错在哪一行。-- 建库语句注意字符集必须用 utf8mb4否则存 emoji 会报错 CREATE DATABASE IF NOT EXISTS reggie DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 使用数据库后执行项目提供的 sql 文件 USE reggie; SOURCE /your/path/regie.sql;这里核心参数是 utf8mb4不是 utf8。原因是菜品备注、地址信息里可能出现特殊符号utf8 字符集存不下四字节字符。导入完成后用SHOW TABLES;验证表数量再查一下 employee 表里有没有初始账号。如果 sql 文件执行报错大概率是版本语法差异常见的是 MySQL 8 不支持某些旧索引写法手动把报错语句里的索引定义删掉再跑。3.3 修改配置并启动三个必看的启动参数数据库就绪后打开 application.yml这是整个工程的“总闸”。三个必改位置是数据源地址和账号密码以及 Redis 相关配置如果有的话。端口默认 8080如果被占用就改掉。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/reggie?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf-8 username: root password: 你的密码 redis: host: localhost port: 6379 password: database: 0 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.reggie.entity configuration: map-underscore-to-camel-case: true这里有两个参数值得多说一句。第一个是 url 里的 serverTimezoneAsia/Shanghai不写的话 MySQL 8 下会报 CST 时区错误第二个是 map-underscore-to-camel-case它把数据库的下划线字段自动映射成驼峰属性比如 employee 表的 id_card 字段映射成 idCard。如果把这两项配错项目能启动但业务全乱。启动方式是右键运行主类看到 Tomcat started 字眼就说明启动成功。启动成功后不要急着点页面。先调一个最简单的接口验证后端活着浏览器访问登录接口的地址如果返回 JSON 而不是 404说明工程没问题接下来才进入业务联调。4. 核心链路落地登录鉴权、菜品管理、购物车与订单4.1 员工登录与 Token 拦截器的实现登录功能是整套系统的入口也是最值得手写一遍的模块。它的实现逻辑是前端把用户名密码用 JSON 传到后端后端查出 employee 表里的记录比对密码成功则把员工 ID 存到 Session 里然后重定向到后台首页。PostMapping(/login) public REmployee login(RequestBody Employee employee, HttpServletRequest request) { // 1. 用 MyBatis Plus 的 LambdaQueryWrapper 按用户名查员工 LambdaQueryWrapperEmployee wrapper new LambdaQueryWrapper(); wrapper.eq(Employee::getUsername, employee.getUsername()); Employee dbEmployee employeeService.getOne(wrapper); // 2. 没查到就返回失败注意这里不区分“用户不存在”和“密码错误” if (dbEmployee null) { return R.error(登录失败账号不存在); } // 3. 比对密码MD5 加密后与库里存的密文比较 String rawPassword DigestUtils.md5DigestAsHex(employee.getPassword().getBytes()); if (!rawPassword.equals(dbEmployee.getPassword())) { return R.error(登录失败密码错误); } // 4. 登录成功把用户 ID 放进 Session方便后续拦截器判断 request.getSession().setAttribute(employee, dbEmployee.getId()); return R.success(dbEmployee); }逻辑说明这段代码的关键在第三步数据库里存的不是明文密码而是 MD5 密文这是最基本的密码保护手段。实际生产环境会用 BCrypt 加盐但教学项目用 MD5 足够讲清楚流程。参数说明LambdaQueryWrapper 是 MyBatis Plus 提供的查询构造器eq 方法对应 SQL 的 WHERE 条件。request.getSession() 拿到的 Session 默认存活 30 分钟后续拦截器就是通过检查这个属性来判断用户是否登录。有了登录逻辑必然要有拦截器。拦截器的作用是保护需要登录才能访问的接口它实现了 Spring 提供的 HandlerInterceptor 接口在 preHandle 方法里从 Session 拿用户 ID。public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 检查 Session 里有没有 employeeId Object employeeId request.getSession().getAttribute(employee); if (employeeId ! null) { return true; // 已登录放行 } // 未登录返回 JSON 提示而不是重定向页面 response.getWriter().write({\code\:0,\msg\:\NOT_LOGIN\}); return false; }这段代码的思路是“先拦截后端接口而不是只拦页面”。它的好处是任何未登录的 Ajax 请求都会收到一个明确的 JSON 提示前端收到这个提示后会统一跳转到登录页。实际项目里还可以在拦截器里加 token 刷新逻辑但作为基础理解Session 方案已经足够。4.2 菜品与套餐管理的 MyBatis 查询要点菜品管理模块涉及分类、菜品、口味三张表的联动查询。这里最能体现 MyBatis 的 XML 写法功底也是你面试时能主动讲深的部分。以套餐分页查询为例它需要连表查出套餐的分类名称SQL 写在 resources/mapper/SetmealMapper.xml 里。select idpage resultTypecom.example.reggie.dto.SetmealDto SELECT s.*, c.name AS categoryName FROM setmeal s LEFT JOIN category c ON s.category_id c.id WHERE s.name LIKE CONCAT(%, #{name}, %) AND s.status #{status} ORDER BY s.update_time DESC /select逻辑说明这里用了 LEFT JOIN 把套餐表和分类表关联起来查出套餐本身字段的同时把分类名称也查出来。注意 WHERE 条件里的 LIKE 用的是 CONCAT 拼接而不是直接写LIKE %${name}%前者是预编译参数能防 SQL 注入直接拼接字符串是实际开发中常见的安全漏洞也是面试官喜欢追问的点。参数说明resultType 指向一个 DTO 类这个类继承了实体类并额外加了 categoryName 字段。这就是“扩展实体”模式——当查询结果比表字段多时不修改实体类而是新建一个 DTO 来承接。这在电商系统里非常常见。菜品新增的流程就更有代表性了。前端提交菜品信息时同时会提交一份口味列表后端要先插菜品主记录拿到自增 ID再循环插入口味表。Transactional public void saveWithFlavor(Dish dish, ListDishFlavor flavors) { // 1. 插入菜品主表MyBatis Plus 会自动把自增 ID 回填到 dish 对象 dishService.save(dish); // 2. 遍历口味列表给每个口味补上 dishId for (DishFlavor flavor : flavors) { flavor.setDishId(dish.getId()); } // 3. 批量插入口味表 dishFlavorService.saveBatch(flavors); }逻辑说明这段代码的核心在于 Transactional 注解。为什么必须加因为这里涉及两张表的写入如果菜品插成功而口味插失败数据库里就会残留一条没有口味的菜品记录导致前端展示异常。加上事务之后两张表的操作同生共死要么都成功要么都回滚。参数说明saveBatch 是 MyBatis Plus 自带的批量插入方法它会生成一条多 VALUES 的 INSERT 语句比循环单条插入性能高一个量级。实际项目中如果有上万条口味数据还能在 XML 里用 foreach 标签手动拼 SQL。4.3 购物车与订单提交的主链路代码购物车模块的逻辑相对简单核心是“先查再插存在就累加数量”。用户每次点加号后端先按 user_id 和 dish_id 查购物车表有记录就把 number 加一没有就新建一条。public RString add(ShoppingCart cart) { // 拿到当前登录用户 ID从 ThreadLocal 里取 Long userId BaseContext.getCurrentId(); cart.setUserId(userId); // 查询当前用户购物车是否已有这道菜 LambdaQueryWrapperShoppingCart wrapper new LambdaQueryWrapper(); wrapper.eq(ShoppingCart::getUserId, userId); wrapper.eq(ShoppingCart::getDishId, cart.getDishId()); ShoppingCart one shoppingCartService.getOne(wrapper); if (one ! null) { // 已有则数量加一 one.setNumber(one.getNumber() 1); shoppingCartService.updateById(one); } else { // 没有则数量初始为 1 cart.setNumber(1); shoppingCartService.save(cart); } return R.success(添加成功); }逻辑说明你在实际面试中如果被问到“购物车用什么数据结构存储”可以回答数据库表存储而不是内存 Map理由是订单系统要求可靠性用户关闭浏览器后购物车数据不能丢。这里还有个细节就是 BaseContext它是用 ThreadLocal 保证同一个线程内共享用户 ID 的工具类可以在 service 层省去一直传参的麻烦。订单提交是最复杂的一段。用户提交订单时后端要做的事包括查购物车、算总金额、生成订单号、保存订单主表、保存订单明细、清空购物车。这个链路足够长正好用来演示事务的力度。Transactional public RString submit(Orders order) { Long userId BaseContext.getCurrentId(); // 1. 查询当前用户购物车 LambdaQueryWrapperShoppingCart wrapper new LambdaQueryWrapper(); wrapper.eq(ShoppingCart::getUserId, userId); ListShoppingCart carts shoppingCartService.list(wrapper); if (carts null || carts.size() 0) { return R.error(购物车为空不能下单); } // 2. 组装订单主表数据 order.setUserId(userId); order.setNumber(String.valueOf(System.currentTimeMillis())); // 用时间戳生成订单号 order.setOrderTime(LocalDateTime.now()); order.setPayStatus(0); // 未支付 double total 0.0; for (ShoppingCart cart : carts) { total cart.getAmount() * cart.getNumber(); } order.setAmount(new BigDecimal(total)); // 3. 这里还需要保存订单明细、扣减菜品库存、清空购物车 // 篇幅原因不再展开但注意所有写操作都在事务内 return R.success(下单成功); }这段代码展示的是订单主表的数据组装。真正完整的实现里还有两个关键点订单号生成规则和生产环境不同教学项目用 System.currentTimeMillis() 简化但真实项目必须用雪花算法或数据库序列因为并发下时间戳会重复订单金额用 BigDecimal 而不是 doubledouble 的精度误差在金额计算里是大忌。5. 避坑排查五个让新手卡壳的典型问题5.1 静态资源 404 与拦截器白名单现象后端启动正常登录接口也通但访问http://localhost:8080/backend/index.html页面白屏控制台报 404。原因登录拦截器把所有请求都拦了preHandle 返回 false 放行了接口却忘了放行静态资源。排查时你会看到前端页面引用 CSS、JS、图片全部 404因为拦截器没有配置静态资源白名单。解决在拦截器注册配置里把静态资源路径加进 excludePathPatterns。常见做法是.addPathPatterns(/**).excludePathPatterns(/backend/**, /frontend/**, /common/**)。注意这里的/common/**是放行验证码、文件下载这类公共接口。从那以后我在写拦截器时第一步就先把静态资源白名单写好而不是等 404 再补。5.2 Mapper 接口扫描不到启动直接报错现象启动 Spring Boot 应用时报错提示找不到某个 Mapper 接口的 Bean或者报 Invalid bound statement。原因三种可能。第一启动类上没加 MapperScan 注解第二加了注解但包路径写错第三mapper 接口和 XML 文件路径不匹配。其中第三种最隐蔽——接口在 com.example.mapper 包XML 却放在 resources 目录的另一个包路径下MyBatis 按 mapper-locations 配置找 XML 时会落空。解决统一这一组配置启动类加MapperScan(com.example.reggie.mapper)XML 文件放在resources/mapper/目录下application.yml 里 mybatis.mapper-locations 配成classpath:mapper/*.xml。我一般还会在 XML 的 namespace 属性上逐一确认与接口全限定名一致这是最容易被复制粘贴改错的点。5.3 分页查询总数不准与依赖缺失现象套餐管理页面能加载第一页数据但“总记录数”显示为 0或者点击第二页报 ClassNotFoundException。原因分页功能依赖 PageHelper 或 MyBatis Plus 的分页插件。教学项目里常见的是后者但拦截器没注册。总数为 0 是因为分页插件没生效SQL 执行的是全量查询再手动截取。解决新建 MybatisPlusConfig 配置类注册 PaginationInnerInterceptor。代码很简单关键是确认你引入的是mybatis-plus-boot-starter而不是裸的mybatis-spring-boot-starter后者没有分页插件。还有一点分页插件配置在 JDK 高版本下有时会被误报为“反射访问非法”可以加一行 java 启动参数--add-opens解决。5.4 前端联调 Cookie 丢失登录态反复跳现象本地启动后登录成功跳转到首页但点任意一个菜单又跳回登录页无限循环。浏览器控制台看不到明显报错。原因前端页面和后端接口在不同端口或不同域名下调试Cookie 跨域丢失。教学项目用的是同源部署前后端都在 8080但如果你用前端工程单独启动调试比如前端跑在 8081后端在 8080Axios 请求默认不带 CookieSession 就识别不了。解决开发环境下给前端请求配置 withCredentials: true同时后端配置 CORS 时不能只用*通配符要指定具体来源域名并设置 allowCredentials(true)。如果是同源部署就没有这个问题。我调试时吃过这个亏现在只要遇到“登录成功但马上跳回登录页”第一反应就是看浏览器 Application 标签里的 Cookie 有没有值。5.5 数据库连接报时区错误现象启动时日志报The server time zone value йʱ is unrecognized或者运行中第一次查询数据库就抛异常。原因MySQL 8.0 的 JDBC 驱动要求显式指定服务器时区而项目默认配置没带 serverTimezone 参数驱动就用系统时区去解析解析失败就抛错。解决在 application.yml 的数据库 url 上追加参数。jdbc:mysql://localhost:3306/reggie?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf-8。这里还有另一个更隐蔽的点如果你连接的是云数据库实例时区可能不是北京时间那就要先执行SELECT NOW()确认数据库时间再决定 url 里的时区值。不要盲目抄本地配置。6. 从教程项目到可用版本三个值得动手的改造点6.1 用 Redis 替换缓存与 Session 存储教学项目里缓存用的是 Caffeine 或直接查库Session 存在 Tomcat 内存。实际你可以把这两块都改成 Redis。改造方法是引入 spring-boot-starter-data-redis 依赖在 Service 层查菜品分类时先查 Redis查不到再查数据库然后回填缓存。Session 层面用 Spring Session Data Redis 替换默认 Session 存储只需要加一个注解和配置就可以实现“重启不丢登录态”。Service public class CategoryService { Autowired private StringRedisTemplate redisTemplate; public ListCategory listWithCache() { String key category:list; String json redisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseArray(json, Category.class); } ListCategory list this.list(); // 查数据库 redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 30, TimeUnit.MINUTES); return list; } }这个改造的价值在于让你理解缓存穿透和缓存过期。实际使用时要把 key 设计成带条件后缀比如按 type 区分不然菜品分类和套餐分类会互相覆盖。6.2 订单表与购物车的索引、分页改造订单表在数据量上来后会遇到查询慢的问题。教学项目里 orders 表没建索引实际生产环境一定要给 user_id、order_time、status 建联合索引。改造语句很简单但效果显著——按用户查历史订单的 SQL 能从全表扫描变成索引命中。ALTER TABLE orders ADD INDEX idx_user_time (user_id, order_time);索引顺序有讲究把等值查询的 user_id 放前面范围查询的 order_time 放后面。没有这个认知很多人直接把 order_time 放第一位结果索引失效还要被误导。分页改造同样重要深分页时用游标方案而不是LIMIT 10000, 10。6.3 部署配置与安全加固教程项目默认开启了 Druid 的控制台页面而且账号密码是弱口令。部署前要关掉它或者改成强密码。另一个安全点是登录接口没有验证码校验生产环境会被暴力破解。常见做法是加一个简单的算术验证码接口登录时校验。我从拆这套源码里得到的最大教训是不要以为跑通就是会了。跑通只是第一步能把登录拦截器、事务边界、索引设计这三点讲清楚才算真正吸收了这套东西的价值。现在每拿到一个新项目我都会强制走一遍“先看配置、再读登录链路、最后验证事务边界”的流程省掉很多返工时间。希望帮到你。本文还有配套的精品资源点击获取