☰
瑞吉外卖SpringBoot实战项目:含完整数据库与部署指南
2026/10/17 3:24:39 网站建设 项目流程

简介:瑞吉外卖项目源码及数据库资源是一套面向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 实战工程

你刚接手一个校园食堂小程序的二期需求,要加「堂食预约时段控制」和「学生证号绑定核销」——但后端接口文档没给全,老同事已离职,测试环境连数据库都连不上。这时候,一份带完整建表语句、含真实业务分支(如“满30减5”“配送超时自动取消”)、且所有 Controller 层都打了@ApiOperation注释的 SpringBoot 工程,比十篇原理文章管用得多。瑞吉外卖项目正是这样一份「开箱即用型教学级生产代码」:它不是玩具 Demo,而是某高校实验室基于真实外卖平台简化出的闭环系统,覆盖用户注册/登录、商家管理、菜品分类、购物车、订单生成与状态机流转、Redis 缓存菜品热度、MyBatis-Plus 动态条件查询等核心链路。技术栈干净利落——SpringBoot 2.7.x + MyBatis-Plus 3.5.x + MySQL 8 + Redis 6 + Vue2 前端(源码包含前后端),Linux 部署脚本齐全,连 Nginx 反向代理配置都写好了注释。如果你正卡在「想练手却找不到有真实业务厚度的 SpringBoot 项目」,或需要快速搭一个可演示的外卖后台原型,这份资源就是那个能让你今晚就提交第一个 PR 的起点。


2. 环境准备与一键启动:从解压到访问 localhost:8080/admin 的完整路径

2.1 本地开发环境最低要求与验证命令

项目对运行环境有明确依赖,不要跳过验证步骤。很多新手卡在第 1 步,不是代码问题,是环境没对齐。以下是我在三台不同配置机器上反复验证过的最小可行组合:

  • JDK:必须为 OpenJDK 11(非 17 或 8)。执行java -version,输出需含11.0.x。若为 JDK 17,启动时会报java.lang.UnsupportedClassVersionError: org/springframework/boot/SpringApplication has been compiled by a more recent version of the Java Runtime—— 这是 SpringBoot 2.7.x 的硬性限制。
  • MySQL:版本 ≥ 8.0.22。执行mysql --version,确认小版本号。低于此版本会导致json_contains函数不可用(项目中用于查询用户角色权限)。
  • Redis:6.2.x 推荐,5.0+ 可运行。执行redis-cli --version,注意项目使用StringRedisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS),低版本 Redis 不支持SETEX命令的毫秒级精度,会导致缓存失效异常。
  • Maven:3.6.3+。执行mvn -v,检查是否为Apache Maven 3.6.3或更高。低于此版本无法解析mybatis-plus-boot-starter的 BOM 依赖。

提示:Linux 下建议用 SDKMAN 管理多 JDK 版本:sdk install java 11.0.22-open,再sdk use java 11.0.22-open。Windows 用户请手动设置JAVA_HOME指向 JDK 11 根目录,并确保PATH中%JAVA_HOME%\bin在C:\Program Files\Java\jdk-17\bin之前。

2.2 数据库初始化:三步导入 SQL,绕过字符集玄学

项目数据库脚本位于sql/ryg.sql,但直接source ryg.sql极易失败——不是语法错,是字符集惹的祸。我踩过两次坑:一次是 MySQL 客户端默认latin1,导入中文字段全变???;另一次是建库时没指定utf8mb4,导致微信昵称里的 emoji 存不进去。正确流程如下:

  1. 新建数据库并显式指定字符集

    CREATE DATABASE ryg CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  2. 修改 MySQL 全局配置(仅首次需做)
    编辑/etc/my.cnf(Linux)或my.ini(Windows),在[mysqld]下追加:

    character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ci

    重启 MySQL:sudo systemctl restart mysqld

  3. 用 mysql 命令行工具导入(非 Navicat / DBeaver GUI)

    mysql -u root -p ryg < sql/ryg.sql

    导入成功后,执行SELECT COUNT(*) FROM employee;应返回2(默认管理员账号admin/123456和测试员test/123456)。

注意:ryg.sql中包含INSERT INTO category (name, type, sort, status, create_time, update_time)等语句,其中type字段为 TINYINT(1),值1表示菜品分类,2表示套餐分类——这个业务含义在CategoryController.java的@ApiParam(value = "分类类型,1为菜品,2为套餐")注释里写得很清楚,别看漏。

2.3 后端服务启动:跳过前端编译,直连后端 API

项目前端(Vue2)放在front-end/目录,但首次启动无需编译前端。你只需让后端跑起来,用 Postman 或 curl 测试接口即可。步骤如下:

  1. 进入backend/目录(注意不是根目录)

  2. 修改application.yml中的数据库连接:

    spring: datasource: url: jdbc:mysql://localhost:3306/ryg?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_mysql_root_password

    关键参数说明:serverTimezone=Asia/Shanghai解决时区错位(否则create_time全是 1970);allowPublicKeyRetrieval=true是 MySQL 8+ 必加参数,否则报Public Key Retrieval is not allowed。

  3. 执行启动命令:

    mvn clean package -Dmaven.test.skip=true java -jar target/ryg-backend-1.0.jar

    启动日志末尾出现Started RygBackendApplication in X.XXX seconds即成功。

  4. 访问http://localhost:8080/doc.html(Swagger UI),输入默认账号admin/123456,即可看到所有接口文档。重点测试/employee/login登录接口,返回{"code":1,"msg":"登录成功","data":{"id":1,"username":"admin",...}}即通。


3. 核心模块拆解:从 EmployeeController 到 OrderDetailServiceImpl 的真实调用链

3.1 登录认证模块:JWT Token 生成与拦截器校验逻辑

登录功能看似简单,但瑞吉项目把 JWT 的完整生命周期都实现了——生成、存储、校验、刷新。关键不在EmployeeController.login(),而在JwtTokenAdminInterceptor这个自定义拦截器。它不是用 Spring Security,而是手写拦截逻辑,更适合教学理解。

EmployeeController.java中的登录方法:

@PostMapping("/login") public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) { // 1. 调用 service 层查用户 Employee emp = employeeService.getByUsername(employee.getUsername()); if (emp == null || !passwordEncoder.matches(employee.getPassword(), emp.getPassword())) { return R.error("用户名或密码错误"); } // 2. 生成 JWT Token(关键:载荷里塞了 employee.getId() 和 employee.getUsername()) String token = JwtUtil.generateToken(emp.getId(), emp.getUsername()); // 3. 将 token 存入 Redis,有效期 2 小时 redisTemplate.opsForValue().set("token:" + emp.getId(), token, 2, TimeUnit.HOURS); return R.success(token); }

参数说明:JwtUtil.generateToken()内部使用HMACSHA256算法,密钥硬编码在JwtUtil.java第 12 行private static final String SECRET = "ryg-secret-key-2023";—— 实际项目必须抽到配置中心,此处仅为演示。

真正起作用的是JwtTokenAdminInterceptor.preHandle():

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); // 前端需在请求头带 token if (token == null || token.length() == 0) { response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN"))); return false; } try { // 1. 解析 token 获取 empId Long empId = JwtUtil.parseToken(token).getLong("id"); // 2. 查 Redis 确认 token 是否有效(防登出后 token 继续可用) String redisToken = (String) redisTemplate.opsForValue().get("token:" + empId); if (!token.equals(redisToken)) { response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN"))); return false; } } catch (Exception e) { response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN"))); return false; } return true; }

逻辑说明:该拦截器注册在WebMvcConfiguration.java的addInterceptors()方法中,对/admin/**路径生效。它不依赖任何框架自动注入,所有依赖(JwtUtil,RedisTemplate)都通过构造函数传入,符合 Spring 最佳实践。

3.2 订单状态机:从下单到派单的七种状态流转与事务边界

瑞吉项目的订单模块是典型的状态驱动设计,OrderServiceImpl.submitWithTransaction()方法用@Transactional包裹整个下单流程,但事务只管 DB,不管 Redis 和消息队列——这点常被忽略。我们来拆解一次完整下单:

  1. 前置校验:检查购物车非空、地址存在、菜品库存充足(dishFlavorService.checkStock()调用Redis读取stock:dish:1001)
  2. DB 写入:
    • 插入orders主表(status=1,表示「待支付」)
    • 批量插入order_detail子表(每道菜一行)
    • 更新dish表的sales字段(UPDATE dish SET sales = sales + 1 WHERE id = ?)
  3. Redis 更新:扣减菜品库存redisTemplate.opsForValue().decrement("stock:dish:" + dishId, 1)
  4. 状态变更触发:当orders.status从 1→2(已支付),监听器OrderStatusChangeListener会发 MQ 消息通知骑手端(代码在mq/目录,但默认未启用 ActiveMQ,需自行配置)

关键事务陷阱:如果第 3 步 Redis 扣减失败(如网络抖动),DB 已提交,就会出现「订单已创建但库存没扣」。项目没做最终一致性补偿,这是留给二次开发的典型改造点。

3.3 菜品分类管理:MyBatis-Plus 条件构造器的实战写法

CategoryController.java的分页查询接口page()展示了 MyBatis-Plus 最常用的动态查询模式:

@GetMapping("/page") public R<Page> page(int page, int pageSize, String name) { Page<Category> pageInfo = new Page<>(page, pageSize); LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.like(name != null && name.length() > 0, Category::getName, name); // 动态 like queryWrapper.eq(Category::getType, 1); // 固定查菜品分类(type=1) queryWrapper.orderByDesc(Category::getSort); // 按 sort 字段倒序 categoryService.page(pageInfo, queryWrapper); return R.success(pageInfo); }

参数说明:LambdaQueryWrapper比QueryWrapper更安全,避免字符串写错字段名;like(..., Category::getName, name)中Category::getName是方法引用,IDE 可跳转、可重构;orderByDesc()保证前端列表按权重排序,而非插入顺序。

对应的 XML 映射文件CategoryMapper.xml几乎为空——因为categoryService.page()直接走 MP 自动生成的 SQL,无需手写<select>。这正是 MyBatis-Plus 的价值:90% 的 CRUD 不用碰 XML。


4. 避坑指南:Linux 部署与开发调试中 5 个血泪经验

4.1 现象:Linux 启动后访问 8080 返回 404,但日志显示Started RygBackendApplication

原因:SpringBoot 默认绑定localhost:8080,Linux 服务器无图形界面,localhost解析失败;或防火墙拦截(CentOS 7 默认开启 firewalld)。
解决:修改application.yml,显式绑定0.0.0.0:

server: port: 8080 address: 0.0.0.0 # 关键!不能省略

再执行sudo firewall-cmd --permanent --add-port=8080/tcp和sudo firewall-cmd --reload。

4.2 现象:MySQL 导入ryg.sql报错ERROR 1067 (42000): Invalid default value for 'create_time'

原因:MySQL 5.7+ 严格模式下,DATETIME字段不能设默认值CURRENT_TIMESTAMP。而ryg.sql中create_time DATETIME DEFAULT CURRENT_TIMESTAMP违反此规则。
解决:临时关闭严格模式,在my.cnf的[mysqld]下加:

sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION

去掉NO_ZERO_DATE和STRICT_TRANS_TABLES,重启 MySQL。

4.3 现象:Redis 缓存菜品热度时,redisTemplate.opsForValue().increment("hot:dish:1001", 1)返回 null

原因:increment()对不存在的 key 会创建并设为 0,但若 Redis 服务未启动或连接池耗尽,返回 null。项目未做空值判断,导致后续if (count > 100)报 NPE。
解决:在DishServiceImpl.updateHotCount()中加判空:

Long count = redisTemplate.opsForValue().increment("hot:dish:" + dishId, 1); if (count == null) { log.error("Redis increment failed for dish: {}", dishId); return; }

4.4 现象:Swagger UI 中点击接口「Try it out」后,返回{"code":1,"msg":"操作成功","data":null},但数据库无变化

原因:Swagger 默认发送Content-Type: application/json,但EmployeeController.login()的@RequestBody Employee要求 JSON 格式,而你填的username=admin&password=123456是application/x-www-form-urlencoded。
解决:在 Swagger 的「Request Body」区域粘贴标准 JSON:

{"username":"admin","password":"123456"}

或改用 Postman,手动设Content-Type: application/json。

4.5 现象:前端npm run serve启动后,调用/backend/employee/login报 CORS 错误

原因:前端端口8080,后端端口8080,同源策略认为是跨域(实际是端口相同但协议不同?不,是前端 dev server 代理未配)。
解决:修改front-end/vue.config.js的devServer.proxy:

devServer: { proxy: { '/backend': { target: 'http://localhost:8080', // 后端地址 changeOrigin: true, pathRewrite: { '^/backend': '' } // 把 /backend 去掉再转发 } } }

这样前端请求/backend/employee/login,会被代理到http://localhost:8080/employee/login。


5. 二次开发实战:如何在 30 分钟内接入微信扫码支付并记录支付流水

5.1 支付模块定位与扩展点分析

瑞吉项目当前未实现支付功能,OrderServiceImpl.submitWithTransaction()中支付状态直接设为2(已支付),属于模拟逻辑。真实接入需在OrderServiceImpl的submit()方法末尾插入支付网关调用,并将支付结果持久化。关键扩展点有三个:

模块文件路径扩展方式说明
支付发起service/impl/OrderServiceImpl.java修改submit()方法在orders.setStatus(2)前,调用wechatPayService.unifiedOrder()
支付回调controller/WechatPayNotifyController.java(需新建)新增@PostMapping("/notify")接口处理微信异步通知,更新订单状态并记录pay_log表
支付流水entity/PayLog.java+mapper/PayLogMapper.java新增实体与 Mapper记录交易号、订单号、支付金额、状态等

提示:项目已有common/utils/HttpClientUtil.java,封装了 HTTP 请求,可直接复用调用微信统一下单接口https://api.mch.weixin.qq.com/pay/unifiedorder。

5.2 微信统一下单:四步生成预支付交易会话标识

微信支付要求先调用统一下单接口获取prepay_id,再由前端调起支付。我们在WechatPayService.java中实现:

@Service public class WechatPayService { @Value("${wechat.appid}") private String appId; @Value("${wechat.mchid}") private String mchId; @Value("${wechat.apikey}") private String apiKey; public Map<String, String> unifiedOrder(Long orderId, BigDecimal amount) throws Exception { // 1. 构造请求参数(微信要求 XML 格式) Map<String, String> params = new HashMap<>(); params.put("appid", appId); params.put("mch_id", mchId); params.put("nonce_str", UUID.randomUUID().toString().replace("-", "")); params.put("body", "瑞吉外卖订单"); params.put("out_trade_no", "RYG" + orderId); // 商户订单号,需全局唯一 params.put("total_fee", amount.multiply(new BigDecimal(100)).intValue() + ""); // 单位:分 params.put("spbill_create_ip", "127.0.0.1"); params.put("notify_url", "http://your-domain.com/backend/wechat/notify"); params.put("trade_type", "JSAPI"); // 2. 生成签名(微信 SHA256withRSA) String sign = WXPayUtil.generateSignature(params, apiKey); params.put("sign", sign); // 3. 发送 XML 请求 String xml = WXPayUtil.mapToXml(params); String resultXml = HttpClientUtil.doPostXml("https://api.mch.weixin.qq.com/pay/unifiedorder", xml); // 4. 解析返回 XML,提取 prepay_id Map<String, String> resultMap = WXPayUtil.xmlToMap(resultXml); if ("SUCCESS".equals(resultMap.get("return_code")) && "SUCCESS".equals(resultMap.get("result_code"))) { Map<String, String> payParams = new HashMap<>(); payParams.put("appId", appId); payParams.put("timeStamp", String.valueOf(System.currentTimeMillis() / 1000)); payParams.put("nonceStr", UUID.randomUUID().toString().replace("-", "")); payParams.put("package", "prepay_id=" + resultMap.get("prepay_id")); payParams.put("signType", "MD5"); payParams.put("paySign", WXPayUtil.generateSignature(payParams, apiKey)); return payParams; } throw new RuntimeException("微信下单失败:" + resultMap.get("return_msg")); } }

关键参数说明:total_fee必须为整数分,所以amount.multiply(new BigDecimal(100)).intValue();notify_url必须为公网可访问地址,本地调试可用ngrok映射;paySign是 JSAPI 支付专用签名,与统一下单签名算法不同(此处用 MD5,下单用 HMAC-SHA256)。

5.3 支付回调处理:幂等性校验与状态更新

微信会多次推送同一笔支付结果,必须做幂等控制。WechatPayNotifyController.java的核心逻辑:

@RestController @RequestMapping("/wechat") public class WechatPayNotifyController { @Autowired private OrderService orderService; @Autowired private PayLogService payLogService; @Value("${wechat.apikey}") private String apiKey; @PostMapping("/notify") public String notify(@RequestBody String xml) { try { Map<String, String> notifyMap = WXPayUtil.xmlToMap(xml); // 1. 签名校验(必须!防伪造通知) if (!WXPayUtil.isSignatureValid(notifyMap, apiKey)) { return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[签名失败]]></return_msg></xml>"; } // 2. 查询订单是否存在且未支付 String outTradeNo = notifyMap.get("out_trade_no"); Long orderId = Long.parseLong(outTradeNo.substring(3)); // 提取 RYG 后数字 Orders order = orderService.getById(orderId); if (order == null || order.getStatus() != 1) { // 非待支付状态,直接返回 success return "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } // 3. 更新订单状态 + 记录支付日志 order.setStatus(2); // 已支付 order.setPayTime(LocalDateTime.now()); orderService.updateById(order); PayLog payLog = new PayLog(); payLog.setOrderId(orderId); payLog.setTradeNo(notifyMap.get("transaction_id")); payLog.setAmount(new BigDecimal(notifyMap.get("total_fee")).divide(new BigDecimal(100))); payLog.setPayTime(LocalDateTime.now()); payLog.setStatus(1); payLogService.save(payLog); 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>"; } } }

幂等性保障:通过out_trade_no查询订单,若订单状态已非1(待支付),直接返回 SUCCESS,避免重复更新;PayLog表主键为自增 ID,不依赖业务单号,天然支持并发插入。

从那以后我每次接入第三方支付,都强制走一遍「本地 ngrok 模拟回调 → 抓包看微信原始 XML → 手动构造一次 notify 请求」的闭环。不跑通这三步,绝不提测。希望帮到你。

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

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

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

立即咨询