简介:瑞吉外卖项目源码及数据库资源是一套基于SpringBoot与Mybatis-Plus构建的外卖平台完整实践包,适合正在学习Java Web、微服务或需要课程设计/毕业设计的开发者。项目在Linux环境下运行,已实现用户注册登录、下单支付等核心流程,并将手机短信登录优化为邮箱验证,同时引入Redis缓存与主从数据库读写分离,覆盖企业级开发中的常见技术点。资源包共228个文件,以69个Java源码文件为主,辅以52张PNG截图、21个HTML页面、22个JS脚本、18个CSS样式表及SQL脚本、YML配置等,整体压缩包约32.01MB,目录结构清晰,便于按业务模块阅读和部署。目前已有5187人学习下载。通过源码与数据库脚本,学习者可以直接启动项目,理解订单、商品、用户等模块的代码实现,掌握SpringBoot自动配置、Mybatis-Plus增强CRUD、Redis缓存策略以及Linux环境部署与主从数据库同步的落地方法,是一份颇具参考价值的实战案例。
1. 瑞吉外卖:一套能跑通全流程的外卖系统源码
如果你以为外卖项目只是普通 CRUD,那这套源码会改变你的看法。一个典型的点餐流程,从手机 H5 端选择分类、浏览菜品、加购物车,到后台管理端处理订单、上下架菜品、接收图片上传,整套链路在项目里是完整跑通的。更难得的是,数据库脚本和 Linux 部署说明都在资源包里,本地改几行配置就能起来,非常适合想用 Spring Boot + MyBatis-Plus 做实战练习的 Java 开发者。
这套项目适合两类人:一类是学完 SSM 想看看真实业务怎么组织代码的初学者,另一类是要做毕设或简历项目、需要快速跑通一个带管理端和用户端的完整系统的同学。它把登录鉴权、分页搜索、订单状态流转、文件上传这些高频面试点都揉进了同一个业务里,读一遍代码相当于提前练了一遍面试题。
2. 技术选型与整体架构:Spring Boot + MyBatis-Plus 如何在 Linux 上撑起整套外卖业务
2.1 后端技术栈:Spring Boot 与 MyBatis-Plus 的分工
项目的后端主体是 Spring Boot,ORM 层没有用原生 MyBatis,而是选了 MyBatis-Plus。这里有个很实际的理由:外卖业务的表结构并不复杂,用户、分类、菜品、购物车、订单、订单明细,都是标准的单表操作。用 MyBatis-Plus 提供的 BaseMapper 和 LambdaQueryWrapper,能把大部分 CRUD 从 XML 里解放出来,写代码的速度提升一大截。
项目里还用到了 Redis 做缓存,主要用于验证码存储和菜品缓存;JWT 则负责管理端和用户端的登录令牌。我的判断是,这套组合在生产环境里很常见:Spring Boot 管依赖装配,MyBatis-Plus 管数据访问,Redis 管热点数据,JWT 管无状态鉴权。对于中小型项目来说,这套组合的学习成本和维护成本都比较均衡。
2.2 前端与部署形态:H5 用户端和管理后台怎么共存
项目在代码层面并不是前后端彻底分离的 Vue 工程,而是把前端静态页面放在了 Spring Boot 的 resources/backend 和 resources/front 目录下。backend 是管理端页面,front 是手机 H5 页面,由 Spring Boot 直接加载。这种做法的好处是部署时只要打一个 jar 包,不需要额外维护前端工程,对新手非常友好。
部署目标环境是 Linux。你只需要在服务器上装好 JDK 1.8、MySQL 8、Redis,导入项目自带的 database.sql,再调整 application.yml 里的数据库地址、Redis 地址,执行 mvn package 后就能通过 java -jar 启动。整个部署链路很短,这也是我认为它适合入门实战的原因。
2.3 源码目录结构:从 controller 到 mapper 的包级设计
拿到源码后,先看整体目录结构。项目的包划分很规整,按业务模块分,而不是按技术层堆在一起。最外层的 controller、service、mapper、entity 四个包再辅以 config、common、utils,基本就是整个后端的骨架。
src/main/java/com/xxx/reggie ├── controller # 处理 HTTP 请求,接收参数、返回结果 ├── service # 业务逻辑层,事务和金额计算都在这 ├── mapper # MyBatis-Plus 接口,继承 BaseMapper ├── entity # 数据库表对应的实体类 ├── config # 拦截器、Redis 配置、静态资源映射 ├── common # 通用返回结果、异常处理器、自定义异常 └── utils # JWT 工具类、短信工具类等这个分层对应到一次请求的完整链路:controller 接收前端传来的 JSON,调用 service 层做业务判断,service 通过 mapper 与 MySQL 交互,最后将结果包装成通用返回对象 R 写回前端。common 包里的 R 类是核心,几乎所有接口返回的数据结构都是它,里面封装了 code、msg、data 三个字段。看代码时可以直接从这里入手,理解每个接口的返回约定后再去追具体业务。
3. 核心模块拆解:从登录鉴权到订单提交的完整链路
3.1 登录鉴权:JWT 与拦截器的实现
管理端登录用的是账号密码匹配,登录成功后生成 JWT 令牌,后续请求通过拦截器校验。先看登录接口:
@PostMapping("/login") public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) { // 1. 将页面提交的密码用 MD5 加密后再比对 String password = DigestUtils.md5DigestAsHex(employee.getPassword().getBytes()); LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(Employee::getUsername, employee.getUsername()); Employee emp = employeeService.getOne(queryWrapper); // 2. 判断账号是否存在、密码是否匹配 if (emp == null || !emp.getPassword().equals(password)) { return R.error("登录失败,用户名或密码错误"); } if (emp.getStatus() == 0) { return R.error("账号已禁用"); } // 3. 登录成功,生成 JWT,放入 session 或其他状态存储 Map<String, Object> claims = new HashMap<>(); claims.put("id", emp.getId()); String jwt = JwtUtils.createJwt(claims); request.getSession().setAttribute("employee", emp.getId()); return R.success(jwt); }这段代码里有两个值得注意的点。第一,密码用 MD5 加密,这是在用户注册或初始化数据时已经处理好的,登录时只需要做同样的一次摘要运算再比对。第二,登录成功后没有把完整员工对象塞进 session,而是只存了一个 id,JWT 里也只放了 id,这样做是为了减少 session 体积,同时也方便在拦截器里快速解析当前用户。
拦截器负责在请求进入 controller 之前校验令牌:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1. 从请求头中获取 token String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { return false; } // 2. 解析 token,失败则拦截 try { Claims claims = JwtUtils.parseJwt(token); Long empId = Long.parseLong(claims.get("id").toString()); request.setAttribute("employeeId", empId); return true; } catch (Exception e) { return false; } }拦截器本身不负责业务,只做身份验证,验证通过后把员工 id 放入 request 属性,后续 controller 如果需要拿到当前操作人,直接从 request 里取。这里要说明一个常见设计取舍:用户端(C 端)的登录验证也用 JWT,但 token 的存储位置可以选 Redis 或本地存储。项目里默认把用户端 token 放在 Redis,目的是支持退出登录时立即失效,而管理端使用 session 存储,两种方案的差异值得对比阅读。
3.2 菜品分类与管理:MyBatis-Plus 分页查询怎么写
菜单管理是外卖系统里最典型的 CRUD,但其中有一个高频考点:分页查询。MyBatis-Plus 的分页通过 PaginationInterceptor 实现,使用时直接构造 Page 对象:
@GetMapping("/page") public R<Page<Category>> page(int page, int pageSize, String name) { // 1. 构造分页对象 Page<Category> pageInfo = new Page<>(page, pageSize); // 2. 构造查询条件:名称模糊查询 + 按 sort 排序 LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.like(name != null && !name.isEmpty(), Category::getName, name); queryWrapper.orderByAsc(Category::getSort); // 3. 执行分页查询 categoryService.page(pageInfo, queryWrapper); return R.success(pageInfo); }分页的关键在第二步的 like 条件拼接,第一个参数是 boolean,当 name 为空时自动跳过这个条件,避免了手写 if 判断的繁琐。orderByAsc 指定排序字段,这里按 sort 升序排,sort 在表结构里是一个 integer 字段,用来控制菜品分类在页面上显示的顺序。
分页返回的 Page 对象自带 total、records、current、size 四个字段,前端可以直接使用。这里有一个实际开发中的习惯:不要每次都把整个实体返回给前端,像 status、updateTime 这类字段如果前端不需要,可以用 Map 或 DTO 做字段裁剪,不过这个项目里为了简化,直接返回了实体对象。
3.3 购物车与订单提交:事务在高并发下的取舍
订单提交是整个项目里业务最重的接口,因为它同时涉及到购物车清空、订单表插入、订单明细表插入、金额计算,任何一个环节出错都会造成数据不一致。核心代码在 service 层:
@Transactional public void submit(Orders orders) { // 1. 获取当前用户 id Long userId = BaseContext.getCurrentId(); ShoppingCart shoppingCart = getCart(userId); // 2. 查询购物车列表 List<ShoppingCart> carts = shoppingCartMapper.selectList( new LambdaQueryWrapper<ShoppingCart>() .eq(ShoppingCart::getUserId, userId) ); // 3. 计算总金额 BigDecimal amount = new BigDecimal("0"); for (ShoppingCart cart : carts) { amount = amount.add(cart.getAmount().multiply(new BigDecimal(cart.getNumber()))); } orders.setAmount(amount); orders.setUserId(userId); orders.setNumber(String.valueOf(System.currentTimeMillis())); // 4. 插入订单表和明细表 orderMapper.insert(orders); for (ShoppingCart cart : carts) { OrderDetail detail = new OrderDetail(); detail.setOrderId(orders.getId()); detail.setDishName(cart.getDishName()); detail.setDishImage(cart.getDishImage()); detail.setAmount(cart.getAmount()); orderDetailMapper.insert(detail); } // 5. 清空购物车 shoppingCartMapper.delete( new LambdaQueryWrapper<ShoppingCart>().eq(ShoppingCart::getUserId, userId) ); }这个方法的重点是 @Transactional 注解。没有它,假设订单明细插入失败,订单主表却已经插入成功,就会出现一笔没有明细的脏订单。这里有另一个实际取舍:项目里默认使用浏览器 session 判断当前用户,而不是从 JWT 里解析用户 id,这是因为用户端的登录状态维护在 Redis,session 是外围的,你要看代码时需要同时理解这两条线。
金额计算用的是 BigDecimal,不要用 double。外卖金额涉及分的小数运算,double 的精度问题会造成几毛钱差异,这在真实项目中是绝对不允许的。这里的数量乘单价再累加,顺序是先乘后加,不能反过来,否则四舍五入的位置不同。
3.4 文件上传与图片回显:本地存储的边界
菜品需要图片,所以项目里有一个文件上传接口。常见实现是接收 MultipartFile,将文件保存到本地磁盘目录,并返回相对路径给前端:
@PostMapping("/upload") public R<String> upload(MultipartFile file) { // 1. 获取原始文件名,提取后缀 String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); // 2. 生成新文件名,避免重名 String fileName = UUID.randomUUID().toString() + suffix; // 3. 保存文件到本地目录,如果目录不存在则创建 File dir = new File("/usr/local/img/"); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, fileName)); // 4. 返回给前端的访问路径 return R.success("/common/download?name=" + fileName); }这里有个明显的坑:文件被保存在本地磁盘,但用户访问的是 /common/download 这个接口,由它负责从磁盘读取文件并响应给浏览器。对应的下载接口需要处理文件流:
@GetMapping("/download") public void download(String name, HttpServletResponse response) { File file = new File("/usr/local/img/" + name); FileInputStream fis = new FileInputStream(file); IOUtils.copy(fis, response.getOutputStream()); }在 Linux 部署时,/usr/local/img/ 目录必须存在,否则会抛 FileNotFoundException。这就是为什么我建议进入项目后第一时间搜索所有路径硬编码,把它们提取到配置文件里。项目里这个路径是写死的,属于典型的快速开发遗留问题,二次开发时值得改造。
4. 常见问题与避坑记录:从本地环境到 Linux 部署的五个典型坑
4.1 数据库连接失败:MySQL 8 的驱动与时区问题
现象:启动项目后访问接口,后台报 Communications link failure 或者 Unknown database,但 MySQL 服务明明是正常的。
原因:application.yml 里数据库连接的 URL 少了时区参数。MySQL 8 对时区的校验比 5.7 严格,不写 serverTimezone 就会在建立连接时抛出异常。另一个原因是没有 createDatabaseIfNotExist=true 参数,导致如果数据库不存在连接直接失败。
解决:把 URL 改成以下格式:
url: jdbc:mysql://localhost:3306/reggie?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true改完后重启项目,注意这里的前提是 MySQL 8 的驱动版本要和 JDK 版本匹配。如果用的是 MySQL 5.7,驱动类名是 com.mysql.jdbc.Driver,MySQL 8 则必须用 com.mysql.cj.jdbc.Driver,两者不能混用。
4.2 静态资源访问 404:拦截器把图片拦截了
现象:管理端页面能打开,但菜品图片全部加载不出来,浏览器控制台显示 /common/download 请求返回 401 或 404。
原因:拦截器配置时只放行了部分路径,没有把图片下载接口和静态资源目录加入白名单。启动类或配置类里如果你对资源处理器做了拦截,却没有显式放行静态资源,请求就会先被拦截器拦下,返回 401,页面无法展示图片。
解决:检查 WebMvcConfigurer 的实现类,确认 addInterceptors 中排除了以下路径:
excludePathPatterns("/common/**", "/backend/**", "/front/**", "/user/login", "/user/sendMsg")同时需要在同一个配置类里重写 addResourceHandlers,把静态资源路径映射到 classpath 下对应目录。这个坑非常隐蔽,因为本地开发时静态资源可能通过 IDEA 的资源复制功能能正常访问,但打包成 jar 后路径变化就暴露了。
4.3 Linux 上启动失败:端口被占用或内存不足
现象:在服务器上执行 java -jar reggie.jar 后,进程启动几秒后就退出,日志显示 Port 8080 was already in use,或者干脆报 OutOfMemoryError。
原因:第一是服务器上已经有一个 Tomcat 或其他进程占用了 8080 端口。第二是默认 JVM 堆内存设置过大,云服务器 1G 内存往往跑不动默认参数。
解决:先检查端口占用情况:
netstat -nltp | grep 8080如果被占用,可以换端口启动,也可以通过 kill 旧进程解决。内存问题就用显式参数启动:
java -Xms128m -Xmx512m -jar reggie.jar我这里踩过一次很深的坑:在某云服务器上 512M 内存启动 Spring Boot 项目,不加限制直接 OOM,后来把初始化堆设为 128M,最大堆设为 512M,并且关闭了 Tomcat 的 session 持久化,才稳定跑起来。
4.4 Redis 联调失败:序列化方式不一致
现象:用户端发送短信验证码接口报错,提示序列化失败或无法找到类型,管理端登录后偶尔能进,偶尔又提示未登录。
原因:Redis 默认使用的 JDK 序列化器,会把对象序列化成二进制数据。如果项目里手动设置了 Jackson 序列化方式,但是读取时用 RedisTemplate 的默认配置,或者反过来,同一个 key 被不同序列化器读写,就会数据错乱,甚至反序列化直接抛异常。
解决:在 RedisConfig 里统一使用 StringRedisSerializer 和 GenericJackson2JsonRedisSerializer。注意,所有的键用 String 序列化,值用 JSON 序列化,并且实体类要实现 Serializable 接口。如果已经写入了错误序列化的数据,需要先 flushdb 清空测试环境缓存,再重启项目验证。
4.5 前端请求跨域:CORS 配置的优先级问题
现象:单独部署前端页面时,浏览器报 CORS 错误,管理端接口能通,但用户端请求全部被 CORS 拦截。
原因:Spring Boot 项目里可能同时存在两种跨域配置方式,一种是通过 CorsFilter,另一种是通过 WebMvcConfigurer 的 addCorsMappings。当两者同时配置时,匹配顺序不同会导致其中一个失效。另外,如果你的拦截器没有对 OPTIONS 请求放行,预检请求会直接失败。
解决:删除重复的跨域配置,只保留一种。如果非要使用拦截器实现 CORS 处理,一定要在 preHandle 方法里先判断:
if (request.getMethod().equals("OPTIONS")) { return true; }这样预检请求不会进入业务逻辑,浏览器才会继续发起真实请求。这个坑在本地开发时因为用同一个端口往往暴露不出来,一上服务器分端口部署就会立刻触发。
5. Linux 部署实战:把瑞吉外卖跑在云服务器上
5.1 环境准备:JDK 8 / MySQL 8 / Redis 的安装
在开始部署之前,先确认你的云服务器有一个公网 IP,安全组开放 8080 端口。很多新手部署失败不是因为代码问题,而是安全组没有放行端口,这个细节别忽略。接着安装基础环境:
# Ubuntu / Debian 系列 apt update apt install -y openjdk-8-jdk mysql-server redis-server这里我建议把 MySQL 和 Redis 安装在同一台服务器上,因为项目的 application.yml 默认连接 localhost,这样配置改动最少。安装后检查各服务的版本:
java -version mysql --version redis-server --versionMySQL 8 安装后需要设置 root 密码,并创建一个专门的业务数据库,这个步骤不要跳过。如果你用的是云厂商提供的 MySQL 服务,就直接在控制台创建实例,然后把连接地址记下来,后面填到配置文件里。
5.2 数据库初始化:执行项目自带的 SQL 脚本
资源包里包含一个完整的 SQL 脚本,通常文件名类似 database.sql 或 schema.sql。它里面建好了全部业务表,包括员工表、分类表、菜品表、套餐表、购物车表、订单表和订单明细表,同时插入了一些初始数据,比如管理端账号和几个测试用的菜品分类。
导入数据库很简单,有两种方式。第一种是命令行方式:
mysql -u root -p进入 MySQL 后执行:
source /root/upload/database.sql;第二种方式是把 SQL 文件上传到服务器后,用以下命令直接导入:
mysql -u root -p < /root/upload/database.sql导入完成后,用 show tables 命令确认表结构是否存在。这里我特别提醒一句:执行 SQL 之前先打开文件看一下最开始的建库语句。如果脚本里包含 CREATE DATABASE reggie 这样的语句,你就不需要手动创建数据库;如果只包含 CREATE TABLE,那么你要先执行 CREATE DATABASE reggie 再切换进去。
5.3 修改配置并打包:application.yml 里的三处改动
用 IDEA 打开项目根目录,找到 src/main/resources/application.yml。部署到 Linux 上只需要改三个地方:
spring: datasource: url: jdbc:mysql://你的服务器IP:3306/reggie?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: 你自己设置的密码 redis: host: 127.0.0.1 port: 6379第一处是数据库连接地址,如果你在服务器上装的是本地 MySQL,url 可以保持 localhost 不动,但密码必须改成你刚刚设置的 root 密码。第二处是 Redis 地址,本地安装就不用改。第三处是文件上传路径,建议把前面提到的 /usr/local/img/ 改成你自己创建的目录,比如 /data/reggie/img,然后提前创建好这个目录并赋予写权限。
修改完成后,在 IDEA 终端里执行 Maven 打包命令:
mvn clean package -DskipTests打包成功后,target 目录下会生成 reggie.jar。把这个 jar 包和 SQL 脚本一起上传到服务器,比如放在 /opt/reggie 下。
5.4 启动服务与验证流程
启动前先做一件事,创建图片目录:
mkdir -p /data/reggie/img然后启动项目:
cd /opt/reggie nohup java -Xms128m -Xmx512m -jar reggie.jar > app.log 2>&1 &启动后不要急着关终端,先看启动日志:
tail -f app.log看到 Started RegieApplication 这样的日志后,打开浏览器访问管理端地址:
http://你的服务器IP:8080/backend/index.html如果页面可以打开并正常登录,说明部署成功。再找一个菜品图片链接访问一下,验证文件上传下载接口是否正常。整个过程下来,你会理解为什么我强调要一步步看日志,很多问题通过日志都能直接定位到原因。
6. 二次开发:给瑞吉外卖加一个"历史订单导出"功能
改造成本最低的功能,我认为是历史订单导出。假设你想把某个时间段的订单明细导出成 Excel,核心只需要三步:写一个查询接口、生成 Excel 文件、提供下载链接。
先看查询接口,订单明细在 order_detail 表,订单主表是 orders。按时间段和状态筛选,用 MyBatis-Plus 的 LambdaQueryWrapper 拼接条件:
@GetMapping("/export") public void export(HttpServletResponse response, String begin, String end) { LambdaQueryWrapper<Orders> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.between(Orders::getOrderTime, begin, end); queryWrapper.eq(Orders::getStatus, 3); // 已完成状态 List<Orders> ordersList = ordersService.list(queryWrapper); // 使用 EasyExcel 或 POI 导出列表 // 设置响应头 Content-Disposition,让浏览器下载 }导出功能在项目中可能要引入新的依赖,比如 EasyExcel。这个库比 POI 轻量很多,对于入门项目来说是更合适的选择,几十行代码就能搞定导出,不需要像原生 POI 那样手动创建行和单元格。
改造时你需要同时注意前端对接。管理端订单列表页面加一个"导出"按钮,点击后新开窗口访问上述接口。一个常见的坑是导出文件名如果包含中文,需要做 URL 编码,否则浏览器下载时会乱码。
我从这个项目里学到的习惯是:所有导出、上传、异步任务这类操作,尽量用独立的路径和独立的方法,不要和已有业务耦合在一起。项目本身的包结构很规整,你可以在 controller 下新建一个 ReportController,在有需要时再逐步扩展。希望这个思路能帮到真正拿到源码、准备开始改项目的你。
本文还有配套的精品资源,点击获取