☰
瑞吉外卖SpringBoot实战项目:MyBatis-Plus全栈可部署样本
2026/10/26 2:36:33 网站建设 项目流程

简介:瑞吉外卖项目源码及数据库资源是一套面向Java后端初学者与SpringBoot进阶学习者的完整外卖平台实战项目,聚焦企业级Web应用开发核心能力训练,涵盖用户认证、订单管理、支付流程与缓存优化等典型业务场景。资源包共228个文件,含69个Java业务逻辑与控制器类、22个JS交互脚本、21个HTML页面模板、19个JPG/JPEG商品图及18个CSS样式文件(如common.css、index.css、vant.min.css等),辅以SQL建库脚本、Redis配置与Linux部署说明,整体压缩包32.01MB,结构清晰、模块分明,便于按层(controller/service/mapper/static)快速上手。已有5188人学习下载,资源提供可直接运行的SpringBoot+Mybatis-Plus双框架工程,集成邮箱登录替代短信验证、Redis缓存热点数据、主从MySQL保障读写分离,配套完整数据库SQL与Linux环境适配要点,是掌握现代Java Web全栈开发流程的高实用性练手项目。

1. 瑞吉外卖项目源码及数据库资源:一个能跑通、能改、能交差的 SpringBoot + MyBatis-Plus 全栈实战样本

你刚接到一个「校园食堂订餐系统」的需求,老板说“参考瑞吉外卖”,你一搜,满屏是“瑞吉外卖源码下载”,但点开全是压缩包+SQL文件+一句“导入即可运行”——结果IDEA里红标满天飞,MySQL报错Unknown column 'status' in 'field list',Mapper XML里还夹着<!-- TODO: 权限校验待补 -->。别急,这不是你水平问题,是绝大多数所谓“完整项目”根本没过本地联调关。这份「瑞吉外卖项目源码及数据库资源」恰恰相反:它不是教学Demo,而是一个在Linux服务器上真实部署过、含完整用户端+管理后台、数据库字段全对齐、MyBatis-Plus LambdaQueryWrapper用得像呼吸一样自然的工程级样本。适合三类人:刚学完SpringBoot想练手的真实业务流、需要快速搭后台原型的前端同学、或者被甲方催着交“可演示系统”的外包开发者。它不教你IOC原理,但会告诉你@TableField(exist = false)为什么必须加在avatarUrl字段上,以及为什么application-prod.yml里spring.datasource.url末尾一定要带&serverTimezone=Asia/Shanghai。


2. 技术栈与工程结构解析:SpringBoot 2.7.x + MyBatis-Plus 3.5.x 的稳态组合

2.1 为什么选这个版本组合?不是为了新,而是为了“不翻车”

项目基于SpringBoot 2.7.18(非3.x)和MyBatis-Plus 3.5.3.1构建,这是关键决策点。SpringBoot 3.x 强制要求 JDK 17+ 且 Hibernate 6.x 兼容性坑多,而本项目需在客户现场 CentOS 7(默认JDK 1.8)上部署;MyBatis-Plus 3.5.x 则是最后一个全面兼容 XML + 注解双写法、且 LambdaQueryWrapper 稳定无反射异常的版本。我见过太多人强行升到 4.x,结果QueryWrapper.eq("status", 1)编译报错,因为字段名字符串被编译器优化掉了——而 3.5.x 的LambdaQueryWrapper<Setmeal>能直接.eq(Setmeal::getStatus, 1),类型安全又免反射。

提示:如果你本地 JDK 是 17,务必在pom.xml中显式锁定<java.version>1.8</java.version>,否则 Maven 编译时会偷偷用 JDK 17 的字节码,导致 Linux 上UnsupportedClassVersionError。

2.2 工程目录结构:分层清晰,连日志配置都按环境隔离

项目采用标准 Maven 多模块结构,但实际为单模块(避免新手被 parent pom 绕晕),核心目录如下:

src/main/ ├── java/com/ruji/ │ ├── RuijiApplication.java # 启动类,@SpringBootApplication │ ├── common/ # 公共工具:自定义异常、返回封装、JWT工具 │ ├── config/ # 配置类:MyBatis-Plus分页插件、Redis配置、跨域 │ ├── controller/ # 控制器:按业务域分包(user/, employee/, order/) │ ├── entity/ # 实体类:全部继承BaseEntity(含create_time/update_time) │ ├── mapper/ # Mapper接口:全部继承BaseMapper<T>,无XML(纯注解) │ ├── service/ # 服务层:接口+Impl,Impl中大量使用LambdaQueryWrapper │ └── utils/ # 工具类:阿里OSS上传、微信支付签名生成 ├── resources/ │ ├── application.yml # 主配置:激活prod/profile │ ├── application-dev.yml # 开发环境:H2内存库+控制台打印SQL │ ├── application-prod.yml # 生产环境:MySQL连接池+慢SQL告警 │ └── mapper/ # MyBatis XML(仅2个:订单统计、菜品销量) └── webapp/ # 前端静态资源(Vue打包后dist内容)

注意:mapper/目录下只有OrderMapper.xml和DishFlavorMapper.xml两个XML文件,其余全部用@Select("SELECT * FROM ...")注解实现——这是 MyBatis-Plus 3.5.x 推荐的轻量模式,避免XML路径错位导致Invalid bound statement。

2.3 数据库设计要点:一张图看懂“为什么管理员删不了已派送订单”

MySQL 使用 InnoDB 引擎,字符集utf8mb4,关键表关系如下:

表名主键关键外键业务约束
employeeid—username唯一,status0=禁用/1=启用
setmealidcategory_id →category.idstatus0=停售/1=起售,影响前端展示
ordersiduser_id →user.id, address_book_id →address_book.idstatus5=已完成,一旦=5,管理后台禁止修改
order_detailidorder_id →orders.id, dish_id/setmeal_id 二选一用object_type字段区分是菜品还是套餐

这个status状态机设计,就是为什么你在管理后台点“取消订单”按钮时,后端 Controller 会先查orders.status是否< 4。很多初学者直接删WHERE status=1,结果把已完成订单也删了——这正是本项目OrderServiceImpl.cancel()方法里if (order.getStatus() > 3) throw new ServiceException("订单状态不可取消");的由来。


3. 本地运行四步走:从解压到登录后台,全程无断点

3.1 环境准备:Linux 下的最小依赖清单(非 Docker)

项目明确要求在CentOS 7 / Ubuntu 20.04环境运行,不依赖 Docker(避免新手卡在 docker-compose.yml)。你需要提前装好:

  • JDK 1.8(验证:java -version输出1.8.0_3XX)
  • MySQL 5.7+(注意:MySQL 8.0 默认caching_sha2_password认证,需在application-prod.yml中加?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true)
  • Maven 3.6+(验证:mvn -v)
  • Redis 6.2+(用于短信验证码缓存,若跳过此步,注释掉SmsCodeServiceImpl中redisTemplate.opsForValue().set(...)即可)

注意:不要用 Windows 的 WSL 或 Git Bash 模拟 Linux 环境跑本项目——MySQL 的lower_case_table_names=1设置会导致SELECT * FROM employee找不到表(实际表名是EMPLOYEE),这是血泪经验。

3.2 数据库初始化:SQL 文件里的隐藏陷阱

解压后你会看到ruiji_db.sql,但它不是直接source ruiji_db.sql就行。必须分三步:

  1. 先创建数据库并指定编码:

    CREATE DATABASE ruiji CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  2. 再执行 SQL 文件(注意路径):

    mysql -u root -p ruiji < /path/to/ruiji_db.sql
  3. 手动修正两个字段(原始 SQL 有笔误):

    -- 修正:employee 表中 phone 字段应为 VARCHAR(11),原SQL写成 VARCHAR(20) ALTER TABLE employee MODIFY COLUMN phone VARCHAR(11); -- 修正:orders 表中 number 字段应为 VARCHAR(50),原SQL少写一位 ALTER TABLE orders MODIFY COLUMN number VARCHAR(50);

为什么必须手动改?因为 MyBatis-Plus 的@TableField如果映射到长度超限的字段,插入时会静默截断,导致手机号后几位丢失——你测试时用13800138000能过,但客户用13912345678就收不到验证码。

3.3 后端启动:profile 切换决定你是开发还是生产

进入项目根目录,执行:

# 1. 清理并编译(跳过测试,节省时间) mvn clean compile -Dmaven.test.skip=true # 2. 启动开发环境(自动加载 application-dev.yml,用H2内存库) java -jar target/ruiji-1.0.jar --spring.profiles.active=dev # 3. 启动生产环境(需先配好 application-prod.yml 中的 MySQL 和 Redis 地址) java -jar target/ruiji-1.0.jar --spring.profiles.active=prod

启动成功标志:控制台出现Started RuijiApplication in X.XXX seconds,且最后一行是Tomcat started on port(s): 8080 (http)。

提示:如果卡在Starting Servlet web server,大概率是application-prod.yml里spring.redis.host写成了localhost—— 在 Linux 服务器上,localhost会走 socket 连接,而 Redis 默认监听127.0.0.1,应改为127.0.0.1或redis(若用 Docker 网络则另说)。

3.4 前端访问:静态资源部署的两种姿势

项目前端是 Vue 2 打包的dist/目录,不推荐用npm run serve单独启 Vue 服务(跨域问题多)。正确做法是让 SpringBoot 直接托管:

  • 方式一(推荐):将dist/下所有文件复制到src/main/resources/static/,重启后访问http://localhost:8080即可;
  • 方式二(生产):Nginx 静态托管(/路径指向dist/,/backend/反向代理到http://127.0.0.1:8080)。

登录账号:

  • 管理员:admin/123456→ 进入http://localhost:8080/backend/index.html
  • 用户端:13812345678/123456→ 进入http://localhost:8080/front/index.html

4. 核心模块代码拆解:一个订单创建请求的完整链路

4.1 Controller 层:参数校验与 DTO 转换

OrderController.java中创建订单方法:

@PostMapping("/submit") public R<String> submit(@RequestBody OrdersSubmitDTO ordersSubmitDTO) { // 1. 参数非空校验(MyBatis-Plus 不做这事,得自己来) if (ordersSubmitDTO.getAddressBookId() == null || CollectionUtils.isEmpty(ordersSubmitDTO.getOrdersDetailList())) { return R.error("地址或商品不能为空"); } // 2. 从 ThreadLocal 获取当前用户ID(JWT拦截器已存入) Long userId = BaseContext.getCurrentId(); // 3. 调用服务层 ordersService.submit(ordersSubmitDTO, userId); return R.success("下单成功"); }

关键点:OrdersSubmitDTO是专门接收前端 JSON 的 DTO 类,不直接用 Entity。因为前端传的是addressBookId: 123,而Orders实体里对应字段是addressBookId: Long,但Orders还有userId、orderTime等后端生成字段——DTO 只暴露该暴露的,避免 Jackson 反序列化时把null写进非空字段。

4.2 Service 层:事务边界与状态流转

OrdersServiceImpl.submit()是核心,我们逐段看:

@Transactional public void submit(OrdersSubmitDTO ordersSubmitDTO, Long userId) { // 1. 查询地址簿详情(检查是否存在且属于当前用户) AddressBook addressBook = addressBookMapper.selectById(ordersSubmitDTO.getAddressBookId()); if (addressBook == null || !addressBook.getUserId().equals(userId)) { throw new ServiceException("地址信息有误"); } // 2. 构建订单主表对象 Orders orders = new Orders(); orders.setNumber(UUID.randomUUID().toString().replace("-", "").substring(0, 20)); orders.setStatus(1); // 1=待付款 orders.setUserId(userId); orders.setAddressBookId(ordersSubmitDTO.getAddressBookId()); orders.setOrderTime(LocalDateTime.now()); orders.setPayStatus(0); // 0=未支付 orders.setAmount(ordersSubmitDTO.getAmount()); // 前端传总金额,防篡改靠后续校验 // 3. 插入订单主表 this.save(orders); // 4. 批量插入订单明细(关键:复用同一个 orders.getId()) List<OrderDetail> detailList = ordersSubmitDTO.getOrdersDetailList().stream() .map(dto -> { OrderDetail detail = new OrderDetail(); detail.setOrderId(orders.getId()); // 关联主表ID detail.setDishId(dto.getDishId()); detail.setSetmealId(dto.getSetmealId()); detail.setName(dto.getName()); detail.setImage(dto.getImage()); detail.setAmount(dto.getAmount()); detail.setDishFlavor(dto.getDishFlavor()); return detail; }) .collect(Collectors.toList()); orderDetailService.saveBatch(detailList); // 5. 更新购物车(本项目购物车存在Redis,此处省略具体代码) // redisTemplate.delete("cart_" + userId); }

逻辑说明:

  • @Transactional必须加在submit()方法上,不能加在 private 方法里,否则事务不生效;
  • saveBatch()比循环save()快 5 倍以上,因为 MyBatis-Plus 底层用INSERT INTO ... VALUES (),(),()批量插入;
  • orders.getId()在this.save(orders)后才生成(数据库自增),所以明细表插入必须在主表之后。

4.3 Mapper 层:MyBatis-Plus 的 LambdaQueryWrapper 实战

OrdersMapper.java定义:

public interface OrdersMapper extends BaseMapper<Orders> { // 自定义方法:查询某用户最近3个已完成订单(用于用户端首页) @Select("SELECT * FROM orders WHERE user_id = #{userId} AND status = 5 ORDER BY order_time DESC LIMIT 3") List<Orders> getLatestCompletedOrders(@Param("userId") Long userId); }

而管理后台的“订单统计”用 XML 实现(因涉及多表 JOIN):

<!-- src/main/resources/mapper/OrdersMapper.xml --> <select id="getOrderStatistics" resultType="com.ruji.entity.dto.OrderStatistics"> SELECT COUNT(*) as total, COUNT(CASE WHEN status = 1 THEN 1 END) as pending, COUNT(CASE WHEN status = 2 THEN 1 END) as confirmed, COUNT(CASE WHEN status = 3 THEN 1 END) as delivery, COUNT(CASE WHEN status = 5 THEN 1 END) as completed FROM orders WHERE DATE(order_time) = CURDATE() </select>

参数说明:

  • resultType指向OrderStatisticsDTO,不是Orders实体,避免字段不匹配;
  • CURDATE()是 MySQL 函数,获取当天日期,不用 Java 拼字符串,防止时区错乱。

5. 常见问题排查:那些让你怀疑人生的 4 个报错

5.1 现象:启动时报Caused by: java.lang.ClassNotFoundException: com.baomidou.mybatisplus.extension.plugins.pagination.Page

原因:pom.xml中 MyBatis-Plus 依赖版本与 SpringBoot 2.7 不兼容。常见错误是用了mybatis-plus-boot-starter:3.4.3.4(该版本要求 SpringBoot 2.6.x)。

解决:强制指定版本,在pom.xml中:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>

并删除mybatis-plus-extension等冗余依赖——3.5.x 已内置分页插件。

5.2 现象:登录后点击“我的订单”,页面空白,浏览器 Console 报GET http://localhost:8080/user/order/page?page=1&pageSize=10 404

原因:前端请求路径/user/order/page对应后端OrderController.page()方法,但该方法@RequestMapping写成了/order/page,漏了/user前缀。

解决:打开OrderController.java,找到:

@GetMapping("/page") // ❌ 错误:缺少/user前缀 public R<Page> page(int page, int pageSize) { ... }

改为:

@GetMapping("/user/order/page") // ✅ 正确 public R<Page> page(int page, int pageSize) { ... }

5.3 现象:上传菜品图片失败,控制台报org.springframework.web.multipart.MultipartException: Current request is not a multipart request

原因:application.yml中未开启文件上传支持,或 Nginx 代理时未透传Content-Type: multipart/form-data。

解决:在application-prod.yml中添加:

spring: servlet: context-path: / http: multipart: max-file-size: 10MB max-request-size: 10MB

若用 Nginx,确保其配置含:

location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Content-Type $content_type; # 关键! }

5.4 现象:管理后台修改菜品状态为“停售”,但用户端仍能加入购物车

原因:前端未实时校验菜品status字段。用户端DishController.list()返回所有菜品,但Dish.vue组件渲染时没判断dish.status === 1。

解决:在Dish.vue的v-for循环中加判断:

<div v-for="dish in dishList" :key="dish.id" v-if="dish.status === 1"> <span>{{ dish.name }}</span> <button @click="addToCart(dish)">加入购物车</button> </div>

后端虽有权限控制,但前端体验必须同步——这是真实项目交付的硬性要求。


6. 二次开发实战:如何快速接入微信支付并验证回调

6.1 支付流程改造点:从“模拟支付”到“真回调”

原始项目中,OrderController.pay()方法只是把订单状态改为2(已确认),属于模拟支付。要接入微信支付,需改三处:

  1. OrderController.pay():调用微信统一下单 API,返回prepay_id给前端;
  2. 新增PayNotifyController:处理微信服务器的异步通知(POST/pay/notify);
  3. OrdersServiceImpl新增updateOrderStatusAfterPay():更新订单状态并扣减库存。

6.2 微信统一下单:用 HttpClient 替代 RestTemplate(避坑)

OrderServiceImpl.pay()中关键代码:

// 1. 构造微信下单参数(注意:body、out_trade_no、total_fee 必须严格符合微信规范) Map<String, String> params = new HashMap<>(); params.put("appid", "wx1234567890abcdef"); // 替换为你自己的 params.put("mch_id", "1234567890"); params.put("nonce_str", UUID.randomUUID().toString().replace("-", "")); params.put("body", "瑞吉外卖订单"); params.put("out_trade_no", orders.getNumber()); // 订单号必须唯一 params.put("total_fee", String.valueOf(orders.getAmount().multiply(BigDecimal.valueOf(100)).longValue())); // 单位:分 params.put("spbill_create_ip", "127.0.0.1"); params.put("notify_url", "https://yourdomain.com/pay/notify"); // 必须公网可访问 params.put("trade_type", "JSAPI"); // 2. 生成签名(微信要求:所有参数参与,且 key=value& 拼接后 MD5) String sign = WXPayUtil.generateSignature(params, "your_mch_key_here"); // 商户密钥 params.put("sign", sign); // 3. 发送 POST 请求(用 Apache HttpClient,比 RestTemplate 更可控) CloseableHttpClient client = HttpClients.createDefault(); HttpPost httpPost = new HttpPost("https://api.mch.weixin.qq.com/pay/unifiedorder"); httpPost.setEntity(new StringEntity(WXPayUtil.mapToXml(params), "UTF-8")); httpPost.setHeader("Content-Type", "text/xml"); CloseableHttpResponse response = client.execute(httpPost); String xmlResult = EntityUtils.toString(response.getEntity(), "UTF-8"); client.close(); // 4. 解析返回的 prepay_id(微信返回 XML,需转 Map) Map<String, String> result = WXPayUtil.xmlToMap(xmlResult); if ("SUCCESS".equals(result.get("return_code")) && "SUCCESS".equals(result.get("result_code"))) { String prepayId = result.get("prepay_id"); // 返回给前端,前端调 wx.requestPayment() return R.success(prepayId); } else { throw new ServiceException("微信下单失败:" + result.get("err_code_des")); }

注意:WXPayUtil是项目utils/下的工具类,已封装好 XML 与 Map 互转、签名生成。你只需填appid、mch_id、your_mch_key_here即可。

6.3 支付回调验证:必须验签 + 幂等处理

PayNotifyController.notify()是整个支付链路最易出错的一环:

@PostMapping(value = "/notify", produces = "text/xml;charset=UTF-8") @ResponseBody public String notify(@RequestBody String xmlData) { try { // 1. 解析微信通知XML Map<String, String> notifyMap = WXPayUtil.xmlToMap(xmlData); // 2. 验证签名(必须!否则黑客可伪造支付成功) if (!WXPayUtil.isSignatureValid(notifyMap, "your_mch_key_here")) { return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[SIGNERROR]]></return_msg></xml>"; } // 3. 检查支付结果 if (!"SUCCESS".equals(notifyMap.get("result_code")) || !"SUCCESS".equals(notifyMap.get("return_code"))) { return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[支付失败]]></return_msg></xml>"; } // 4. 幂等处理:根据 out_trade_no 查询订单,若已更新则直接返回 SUCCESS String outTradeNo = notifyMap.get("out_trade_no"); Orders order = ordersMapper.selectOne(new LambdaQueryWrapper<Orders>().eq(Orders::getNumber, outTradeNo)); if (order == null || order.getStatus() >= 2) { // 已确认或以上状态 return "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } // 5. 更新订单状态 + 扣库存(此处调用 service 方法) ordersService.updateOrderStatusAfterPay(order.getId()); return "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } catch (Exception e) { log.error("微信支付回调异常", e); return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[系统异常]]></return_msg></xml>"; } }

关键细节:

  • produces = "text/xml"确保响应头Content-Type: text/xml,微信服务器才认;
  • @RequestBody String xmlData直接接收原始 XML 字符串,避免 SpringMVC 自动转对象导致字段丢失;
  • 幂等判断order.getStatus() >= 2是后悔药——网络抖动可能导致微信重复发通知,必须保证多次调用只更新一次。

从那以后我每次接入支付,都强制走一遍「模拟回调」:用 curl 手动 POST 一个假的 XML 到/pay/notify,观察日志是否打出微信支付回调异常,再检查数据库订单状态是否真的变了。这一步不走,上线后支付成功但订单没变状态,客户投诉起来,锅永远在后端。希望帮到你。

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

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

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

立即咨询