简介:瑞吉外卖项目完整源码,基于 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?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-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 里的 serverTimezone=Asia/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 R<Employee> login(@RequestBody Employee employee, HttpServletRequest request) { // 1. 用 MyBatis Plus 的 LambdaQueryWrapper 按用户名查员工 LambdaQueryWrapper<Employee> 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 id="page" resultType="com.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, List<DishFlavor> 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 R<String> add(ShoppingCart cart) { // 拿到当前登录用户 ID,从 ThreadLocal 里取 Long userId = BaseContext.getCurrentId(); cart.setUserId(userId); // 查询当前用户购物车是否已有这道菜 LambdaQueryWrapper<ShoppingCart> 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 R<String> submit(Orders order) { Long userId = BaseContext.getCurrentId(); // 1. 查询当前用户购物车 LambdaQueryWrapper<ShoppingCart> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ShoppingCart::getUserId, userId); List<ShoppingCart> 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 而不是 double,double 的精度误差在金额计算里是大忌。
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,后端在 8080,Axios 请求默认不带 Cookie,Session 就识别不了。
解决:开发环境下给前端请求配置 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?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-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 List<Category> listWithCache() { String key = "category:list"; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseArray(json, Category.class); } List<Category> 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 的控制台页面,而且账号密码是弱口令。部署前要关掉它或者改成强密码。另一个安全点是登录接口,没有验证码校验,生产环境会被暴力破解。常见做法是加一个简单的算术验证码接口,登录时校验。
我从拆这套源码里得到的最大教训是:不要以为跑通就是会了。跑通只是第一步,能把登录拦截器、事务边界、索引设计这三点讲清楚,才算真正吸收了这套东西的价值。现在每拿到一个新项目,我都会强制走一遍“先看配置、再读登录链路、最后验证事务边界”的流程,省掉很多返工时间。希望帮到你。
本文还有配套的精品资源,点击获取