简介:瑞吉外卖项目源码及数据库资源是一套面向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,关键表关系如下:
| 表名 | 主键 | 关键外键 | 业务约束 |
|---|---|---|---|
employee | id | — | username唯一,status0=禁用/1=启用 |
setmeal | id | category_id →category.id | status0=停售/1=起售,影响前端展示 |
orders | id | user_id →user.id, address_book_id →address_book.id | status5=已完成,一旦=5,管理后台禁止修改 |
order_detail | id | order_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就行。必须分三步:
先创建数据库并指定编码:
CREATE DATABASE ruiji CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;再执行 SQL 文件(注意路径):
mysql -u root -p ruiji < /path/to/ruiji_db.sql手动修正两个字段(原始 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(已确认),属于模拟支付。要接入微信支付,需改三处:
OrderController.pay():调用微信统一下单 API,返回prepay_id给前端;- 新增
PayNotifyController:处理微信服务器的异步通知(POST/pay/notify); 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,观察日志是否打出微信支付回调异常,再检查数据库订单状态是否真的变了。这一步不走,上线后支付成功但订单没变状态,客户投诉起来,锅永远在后端。希望帮到你。
本文还有配套的精品资源,点击获取