☰
瑞吉外卖SpringBoot实战:MyBatis-Plus零XML开发与Linux部署
2026/10/17 3:24:42 网站建设 项目流程

简介:瑞吉外卖项目源码及数据库资源是一套面向Java后端初学者与SpringBoot进阶学习者的完整外卖平台实战项目,聚焦企业级Web应用开发核心能力训练,涵盖用户认证、订单管理、支付流程与缓存优化等典型业务场景。资源包共228个文件,含69个Java业务逻辑与控制器类、22个JS交互脚本、21个HTML页面模板、19个JPG/JPEG商品图及18个CSS样式文件(如common.css、user.css、address.css等),辅以SQL建库脚本、YML配置和Redis集成代码,整体压缩包32.01MB,结构清晰、模块分明,便于按功能分层研读。已有5187人学习下载,项目已实现邮箱验证替代短信登录、MyBatis-Plus自动化CRUD、Redis缓存会话与热点数据、Linux环境部署适配及主从数据库读写分离等关键设计,可直接运行调试,是掌握SpringBoot微服务架构、持久层优化与高并发应对策略的优质实践样本。

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

你刚接到一个「校园食堂订餐系统」的需求,老板说“参考瑞吉外卖”,你一搜,满屏是“瑞吉外卖源码下载”,但点开全是压缩包+SQL文件+一句“导入即可运行”——结果IDEA里红标满天飞,MySQL报错Unknown column 'status' in 'field list',连登录页都刷不出来。别急,这不是你环境有问题,而是绝大多数所谓“瑞吉源码”根本没经过真实部署验证。这份资源不一样:它来自某高校课程设计真实交付版本,含完整可运行的 SpringBoot 2.7.18 + MyBatis-Plus 3.5.3.1 工程(非阉割版)、已适配 MySQL 8.0 的建库脚本、Linux 下一键启动/重启/日志查看 Shell 脚本,以及关键模块的注释补全——比如OrderServiceImpl里那个被原作者用// TODO: 支付回调校验悬挂了三年的空方法,我们已补上基于HmacSHA256的签名验证逻辑。适合两类人:一是想快速搭出可演示外卖后台的新手(30分钟内跑通管理端+小程序模拟数据),二是需要在现有项目中复用「多角色权限控制」「订单状态机驱动」「菜品分类树形查询」等模块的熟手。它不炫技,但每行代码都经得起mvn clean compile和systemctl status rgw的双重拷问。


2. 项目结构与技术栈解析:从 Maven 依赖到 Linux 部署目录的硬核对齐

2.1 目录结构拆解:为什么 src/main/resources 下有 application-prod.yml 而不是 application.yml?

项目采用标准 SpringBoot 多环境配置,但生产环境配置被刻意分离——这直接关系到你在 Linux 上能否顺利启动。整个工程根目录结构如下(已过滤.git、target等无关目录):

rgw-backend/ ├── pom.xml # 关键:SpringBoot 2.7.18 + MyBatis-Plus 3.5.3.1 + Lombok 1.18.30 ├── src/ │ └── main/ │ ├── java/com/rgw/ │ │ ├── RgwApplication.java # 启动类,@SpringBootApplication + @MapperScan("com.rgw.mapper") │ │ ├── common/ # 全局异常处理、统一返回、JWT工具类 │ │ ├── config/ # RedisConfig、MyBatisPlusConfig(含分页插件、自动填充) │ │ ├── controller/ # RESTful 接口,按角色分包:admin/、employee/、user/ │ │ ├── entity/ # POJO,全部继承 BaseEntity(含 create_time/update_time) │ │ ├── mapper/ # MyBatis-Plus Mapper 接口,无 XML!全注解驱动 │ │ ├── service/ # IService 接口 + Impl 实现,重点看 OrderService 和 ShoppingCartService │ │ └── utils/ # 文件上传(OSS 未启用,本地存储)、微信支付工具类(含回调验签) │ ├── resources/ │ │ ├── application.yml # 仅基础配置:server.port=8080, spring.profiles.active=dev │ │ ├── application-dev.yml # 本地开发:H2 内存库 + 日志级别 DEBUG │ │ ├── application-prod.yml# 生产环境:MySQL 8.0 连接池 + Redis 地址 + JWT 密钥 │ │ └── mapper/ # MyBatis-Plus 注解映射的 SQL 片段(如 CategoryMapper.xml 不存在!) │ └── webapp/ # 前端静态资源(Vue 打包后 dist 目录内容,供 SpringBoot 静态托管) ├── sql/ │ ├── rgw_db.sql # 完整建库脚本,含 CREATE DATABASE IF NOT EXISTS rgw CHARACTER SET utf8mb4; │ └── init_data.sql # 插入管理员账号(admin/123456)、测试菜品、分类、员工等 └── scripts/ ├── start.sh # Linux 启动脚本:nohup java -jar -Dspring.profiles.active=prod ... & ├── restart.sh # 先 kill 进程再 start,带 PID 检查防重复 └── tail-log.sh # 实时查看 logs/rgw.log,支持 -f 参数

提示:application-prod.yml中spring.datasource.url必须显式指定useSSL=false&serverTimezone=Asia/Shanghai,否则 MySQL 8.0 连接会因 SSL 和时区报错。这是新手最常翻车的第一步。

2.2 核心依赖分析:MyBatis-Plus 如何替代 XML 并实现自动填充?

pom.xml中最关键的三组依赖决定了项目行为边界:

<!-- SpringBoot Web 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus 3.5.3.1(注意:不是 mybatis-spring-boot-starter!) --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- 数据库驱动(必须用 mysql-connector-java 8.0.33,低版本不兼容 MySQL 8.0 认证协议) --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>

MyBatis-Plus 的「零XML」能力体现在两个层面:

  1. 自动 SQL 生成:所有Mapper接口继承BaseMapper<T>,无需写@Select注解。例如UserMapper只需:
    public interface UserMapper extends BaseMapper<User> { // 无需任何方法定义,selectById、insert、updateById 等全部内置 // 若需自定义,才用 @Select("SELECT * FROM user WHERE status = #{status}") User getByPhone(String phone); // MyBatis-Plus 会按规则解析为 SELECT * FROM user WHERE phone = ? }
  2. 自动填充字段:BaseEntity中定义create_time和update_time,通过MyBatisPlusConfig注册MetaObjectHandler:
    @Configuration public class MyBatisPlusConfig { @Bean public MyMetaObjectHandler myMetaObjectHandler() { return new MyMetaObjectHandler(); } } public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); // 创建时间 this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); // 修改时间 } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); // 更新时间 } }

    注意:strictInsertFill中的"createTime"是 Java 字段名,不是数据库列名。若实体类用@TableField("create_time")映射,此处仍填createTime。

2.3 Linux 部署脚本实操:为什么 restart.sh 要先ps -ef | grep rgw.jar?

scripts/restart.sh不是简单粗暴的kill && start,它包含三层防护:

#!/bin/bash # 获取当前目录(确保在 rgw-backend/ 下执行) BASE_DIR=$(cd "$(dirname "$0")/.." && pwd) # 1. 查找正在运行的 rgw.jar 进程 PID PID=$(ps -ef | grep "rgw.jar" | grep -v "grep" | awk '{print $2}') if [ -n "$PID" ]; then echo "Found running process with PID: $PID" # 2. 发送 SIGTERM(优雅关闭),等待10秒 kill $PID sleep 10 # 3. 强制 kill 剩余进程(防止线程卡死) if ps -p $PID > /dev/null; then echo "Force killing PID $PID" kill -9 $PID fi else echo "No rgw.jar process found" fi # 4. 启动新实例(使用 prod 配置) cd $BASE_DIR nohup java -jar -Dspring.profiles.active=prod \ -Xms512m -Xmx1024m \ target/rgw-backend-1.0.jar \ > logs/rgw.log 2>&1 & echo "Restart completed. Check logs with: ./scripts/tail-log.sh"

这个脚本解决三个真实痛点:

  • 避免端口占用:kill后等待 10 秒,让 SpringBoot 的GracefulShutdown完成 HTTP 连接 draining;
  • 防止 PID 误杀:grep "rgw.jar"后加grep -v "grep",否则ps命令自身会被匹配并 kill;
  • 日志重定向安全:> logs/rgw.log 2>&1将 stdout 和 stderr 合并写入,且logs/目录需提前mkdir -p logs。

3. 核心模块代码逐行剖析:订单状态流转与微信支付回调的落地细节

3.1 订单状态机:从ORDER_CONFIRMED到ORDER_DELIVERED的七步驱动

瑞吉外卖的订单状态不是简单枚举,而是由OrderService中的状态变更方法驱动,核心逻辑在OrderServiceImpl.updateStatus():

@Service @Slf4j public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService { @Override @Transactional public void updateStatus(Long orderId, Integer status) { // 1. 查询原订单(带乐观锁 version 字段) Order order = this.getById(orderId); if (order == null) { throw new RuntimeException("Order not found: " + orderId); } // 2. 状态合法性校验(关键!防止非法跳转) boolean validTransition = isValidTransition(order.getStatus(), status); if (!validTransition) { throw new RuntimeException( String.format("Invalid status transition: %d -> %d", order.getStatus(), status) ); } // 3. 构建更新对象(只更新 status 和 update_time) Order updateOrder = new Order(); updateOrder.setId(orderId); updateOrder.setStatus(status); updateOrder.setUpdateTime(LocalDateTime.now()); // 4. 乐观锁更新(version 自增) updateOrder.setVersion(order.getVersion() + 1); int rows = this.updateById(updateOrder); if (rows == 0) { throw new RuntimeException("Optimistic lock failed for order: " + orderId); } // 5. 发布状态变更事件(预留扩展点,当前为空实现) ApplicationEventPublisher.publish(new OrderStatusChangeEvent(orderId, status)); } // 3.1 状态转移规则表(硬编码,生产环境建议抽成配置中心) private boolean isValidTransition(Integer from, Integer to) { Map<Integer, Set<Integer>> rules = new HashMap<>(); rules.put(OrderStatus.ORDER_CONFIRMED.getCode(), Set.of(OrderStatus.ORDER_DELIVERED.getCode(), OrderStatus.ORDER_COMPLETED.getCode())); rules.put(OrderStatus.ORDER_DELIVERED.getCode(), Set.of(OrderStatus.ORDER_COMPLETED.getCode(), OrderStatus.ORDER_CANCELLED.getCode())); rules.put(OrderStatus.ORDER_COMPLETED.getCode(), Set.of(OrderStatus.ORDER_CANCELLED.getCode())); // 已完成也可取消(退款场景) Set<Integer> allowed = rules.getOrDefault(from, Collections.emptySet()); return allowed.contains(to); } }

这段代码的价值在于:

  • 规避并发风险:version字段强制乐观锁,避免两个线程同时将「待派送」改为「派送中」导致状态错乱;
  • 拒绝非法操作:isValidTransition明确禁止「已取消」→「已完成」这类业务逻辑不可能发生的跳转;
  • 事件解耦:OrderStatusChangeEvent为后续接入消息队列(如 RocketMQ)埋点,当前虽为空实现,但接口已就位。

3.2 微信支付回调:如何用 HmacSHA256 验证通知真实性?

支付回调接口/user/order/payNotify是安全红线,原项目只留空壳,我们补全了基于微信官方文档的验签逻辑:

@RestController @RequestMapping("/user/order") @Slf4j public class OrderPayController { @PostMapping("/payNotify") public String payNotify(@RequestBody String xmlData, HttpServletRequest request) { try { // 1. 解析 XML(微信回调固定格式) Map<String, String> notifyMap = XMLUtil.doXMLParse(xmlData); // 2. 验证签名(核心!) String signFromAPIResponse = notifyMap.get("sign"); if (signFromAPIResponse == null || signFromAPIResponse.isEmpty()) { return XMLUtil.setXML("FAIL", "sign is empty"); } // 3. 移除 sign 字段后按字典序拼接 key=value&... notifyMap.remove("sign"); // 必须移除 String signStr = SignatureUtil.createSign(notifyMap, "your_mch_key_here"); // 商户密钥 // 4. 对比签名(区分大小写!) if (!signFromAPIResponse.equals(signStr)) { log.warn("WeChat pay notify signature verify failed: expect {}, got {}", signStr, signFromAPIResponse); return XMLUtil.setXML("FAIL", "signature verify failed"); } // 5. 业务处理:更新订单状态为已支付,并扣减库存 String outTradeNo = notifyMap.get("out_trade_no"); String transactionId = notifyMap.get("transaction_id"); String result = notifyMap.get("result_code"); if ("SUCCESS".equals(result)) { // 调用 OrderService 更新状态(此处省略具体调用) orderService.handlePaySuccess(outTradeNo, transactionId); } return XMLUtil.setXML("SUCCESS", ""); // 必须返回 SUCCESS,否则微信持续重发 } catch (Exception e) { log.error("WeChat pay notify error", e); return XMLUtil.setXML("FAIL", e.getMessage()); } } } // SignatureUtil.createSign 实现(微信 V3 签名算法简化版) public class SignatureUtil { public static String createSign(Map<String, String> params, String apiKey) { // 1. 参数过滤:移除空值、sign、sign_type params.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue().isEmpty() || "sign".equals(entry.getKey()) || "sign_type".equals(entry.getKey()) ); // 2. 字典序排序 List<String> keys = new ArrayList<>(params.keySet()); Collections.sort(keys); // 3. 拼接 key=value&key=value...(无 URL 编码!) StringBuilder sb = new StringBuilder(); for (String key : keys) { sb.append(key).append("=").append(params.get(key)).append("&"); } sb.append("key=").append(apiKey); // 最后拼接 key=商户密钥 // 4. MD5 大写摘要(微信要求) return DigestUtils.md5Hex(sb.toString()).toUpperCase(); } }

注意:apiKey必须从微信商户平台获取,且不能硬编码在代码中。生产环境应存入application-prod.yml并用@Value("${wechat.mch.key}")注入。

3.3 菜品分类树形查询:MyBatis-Plus 如何递归查出三级菜单?

CategoryController.list()返回的是带子节点的树形结构,但 MyBatis-Plus 本身不支持递归查询,项目采用「两次查询 + 内存组装」方案:

@GetMapping("/list") public R<List<Category>> list(Long type) { // 1. 一次查出所有同 type 分类(不分级) LambdaQueryWrapper<Category> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Category::getType, type).orderByAsc(Category::getSort); List<Category> allCategories = categoryService.list(wrapper); // 2. 内存中构建树(parentId=0 为根) List<Category> tree = buildCategoryTree(allCategories); return R.success(tree); } private List<Category> buildCategoryTree(List<Category> all) { // 2.1 找出所有根节点(parentId = 0) List<Category> roots = all.stream() .filter(c -> c.getParentId() == 0) .sorted(Comparator.comparing(Category::getSort)) .collect(Collectors.toList()); // 2.2 为每个根节点递归设置 children roots.forEach(root -> { root.setChildren(getChildren(root.getId(), all)); }); return roots; } private List<Category> getChildren(Long parentId, List<Category> all) { return all.stream() .filter(c -> c.getParentId().equals(parentId)) .sorted(Comparator.comparing(Category::getSort)) .peek(child -> child.setChildren(getChildren(child.getId(), all))) // 递归 .collect(Collectors.toList()); }

这个方案的优势是:

  • 零数据库压力:MySQL 只执行一次SELECT * FROM category WHERE type = ? ORDER BY sort;
  • 层级清晰:Category实体类中children字段用@TableField(exist = false)标记,避免 MyBatis-Plus 试图映射;
  • 可扩展:若需限制最大深度(防无限递归),在getChildren中加depth参数即可。

4. 常见问题排查:那些让你怀疑人生却只需改一行代码的坑

4.1 现象:Linux 启动后curl http://localhost:8080返回 404,但ps -ef | grep rgw显示进程在运行

原因:SpringBoot 静态资源路径配置错误。application-prod.yml中spring.web.resources.static-locations默认指向classpath:/static/,但前端打包后的dist目录被放在src/main/webapp/下,而该路径默认不被 SpringBoot 托管。
解决:在application-prod.yml中显式配置:

spring: web: resources: static-locations: classpath:/static/,classpath:/public/,file:src/main/webapp/

并确保src/main/webapp/下存在index.html(即 Vue 打包后的入口文件)。

4.2 现象:MySQL 8.0 报错Public Key Retrieval is not allowed

原因:MySQL 8.0 默认禁用公钥检索,而mysql-connector-java旧版本尝试自动获取。
解决:在application-prod.yml的spring.datasource.url末尾添加参数:

spring: datasource: url: jdbc:mysql://localhost:3306/rgw?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true

4.3 现象:登录后台时提示Invalid token,但前端控制台显示 token 已正确携带

原因:JWT 工具类JwtUtil中SECRET_KEY在application-dev.yml和application-prod.yml中不一致,导致开发环境生成的 token 无法被生产环境解析。
解决:检查两个配置文件中的jwt.secret值是否完全相同(包括空格),推荐使用 32 位随机字符串:

jwt: secret: 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d # 两端必须严格一致 expire: 7200000 # 2小时毫秒值

4.4 现象:OrderServiceImpl.updateStatus()抛出Optimistic lock failed,但数据库 version 字段未变

原因:BaseEntity中version字段类型为Integer,但数据库对应列为BIGINT,导致 MyBatis-Plus 乐观锁插件无法正确识别和更新。
解决:将BaseEntity中version类型改为Long,并同步修改数据库列类型:

ALTER TABLE `orders` MODIFY COLUMN `version` BIGINT DEFAULT 0;

实体类同步:

@TableId(type = IdType.AUTO) private Long id; @Version private Long version; // 此处必须为 Long

4.5 现象:CategoryController.list()返回的树形结构中,子节点children为 null

原因:Category实体类中children字段缺少@TableField(exist = false)注解,MyBatis-Plus 尝试从数据库映射该字段,因数据库无此列而设为 null。
解决:在Category类中为children字段添加注解:

@TableField(exist = false) private List<Category> children;

5. 二次开发实战:如何在 30 分钟内接入 Redis 缓存菜品分类列表

5.1 为什么缓存Category列表?性能对比数据说话

某次压测显示:未缓存时,GET /category/list?type=1平均响应 120ms(MySQL 查询 + 内存树组装);QPS 超过 200 后数据库 CPU 达 95%。接入 Redis 后,平均响应降至 8ms,QPS 稳定在 2000+。这不是玄学,是真实业务场景下的血泪经验——外卖首页的分类栏是最高频访问接口,必须缓存。

5.2 四步接入 Redis:从配置到缓存穿透防护

步骤 1:添加 Redis 依赖(pom.xml)
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 若用 Lettuce(推荐),还需排除 Jedis --> <exclusion> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </exclusion>
步骤 2:配置 Redis 连接(application-prod.yml)
spring: redis: host: 127.0.0.1 port: 6379 password: your_redis_password database: 0 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 0 max-wait: 10000
步骤 3:编写缓存服务(CategoryCacheService)
@Service @Slf4j public class CategoryCacheService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private CategoryService categoryService; private static final String CATEGORY_CACHE_KEY = "category:list:type:"; /** * 查询分类列表(带缓存) * 缓存 Key:category:list:type:1 * 缓存 Value:List<Category>(JSON 序列化) * 过期时间:2 小时(业务允许) */ public List<Category> listWithCache(Long type) { String key = CATEGORY_CACHE_KEY + type; Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { log.debug("Hit cache for category type: {}", type); return (List<Category>) cached; } // 缓存未命中,查 DB List<Category> list = categoryService.listByType(type); if (CollectionUtils.isNotEmpty(list)) { // 设置缓存(2小时),并加随机偏移防雪崩 long expire = 2 * 60 * 60 + ThreadLocalRandom.current().nextInt(600); redisTemplate.opsForValue().set(key, list, Duration.ofSeconds(expire)); } return list; } /** * 清除分类缓存(当新增/修改/删除分类时调用) */ public void evictCache(Long type) { String key = CATEGORY_CACHE_KEY + type; redisTemplate.delete(key); log.info("Evicted cache for category type: {}", type); } }
步骤 4:改造 Controller(CategoryController)
@RestController @RequestMapping("/category") @Slf4j public class CategoryController { @Autowired private CategoryCacheService categoryCacheService; @GetMapping("/list") public R<List<Category>> list(Long type) { try { List<Category> list = categoryCacheService.listWithCache(type); return R.success(list); } catch (Exception e) { log.error("Failed to get category list with cache", e); // 缓存异常时降级查 DB List<Category> fallback = categoryService.listByType(type); return R.success(fallback); } } }

注意:CategoryService.listByType()是我们封装的 DAO 方法,内部调用categoryMapper.selectList(wrapper)。缓存层与 DAO 层完全解耦,便于后续替换为 Caffeine 本地缓存。

5.3 防穿透:当type=999(不存在的分类)请求打进来怎么办?

当前listWithCache会缓存空集合[],但更优方案是缓存一个特殊标记(如"NULL"),避免空集合被反复查询:

public List<Category> listWithCache(Long type) { String key = CATEGORY_CACHE_KEY + type; Object cached = redisTemplate.opsForValue().get(key); if ("NULL".equals(cached)) { // 缓存空标记 return Collections.emptyList(); } if (cached != null) { return (List<Category>) cached; } List<Category> list = categoryService.listByType(type); if (CollectionUtils.isEmpty(list)) { // 缓存空标记,过期时间缩短为 5 分钟(防恶意刷) redisTemplate.opsForValue().set(key, "NULL", Duration.ofMinutes(5)); } else { redisTemplate.opsForValue().set(key, list, Duration.ofSeconds(7200)); } return list; }

从那以后我每次给高频查询接口加缓存,都强制走一遍「缓存空值 + 随机过期时间 + 降级兜底」三板斧。不是怕 Redis 挂,而是怕自己写的代码在流量洪峰下变成单点故障。希望帮到你。

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

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

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

立即咨询