简介:一份基于Java技术栈的外卖系统完整源码,适合正在学习Java Web开发、准备课程设计或毕业设计的学生,以及想了解餐饮管理系统业务闭环的开发者。资源共171个文件,包含69个Java源文件、26个JavaScript脚本、15个HTML页面、9个SQL脚本及9个CSS样式等,压缩包整体仅5.61MB,配合yml配置文件和Maven相关文件,基本覆盖后端、前端、数据库与部署配置。目前已有624人学习下载,源码模块划分清晰,覆盖员工管理、菜品管理、分类管理、套餐管理和订单记录等核心功能。通过阅读与实践,可掌握Spring Boot、Spring MVC、MyBatis等框架的整合方式,理解数据库表设计、前后端交互及订单状态流转逻辑,是一份难得的综合实战参考。
1. 这份 Java 外卖系统源码到底能干什么:先搞清楚值不值得解压
如果你搜“Java 外卖系统源码”,多半是课程设计快交、或者面试前需要一套能讲清楚的完整业务。这个 zip 躺在下载列表里,最怕解压完发现只有几个 CSS 文件和一个 mvnw.cmd,跑也跑不起来。我的判断是:它不是一个只能展示首页的静态页面,而是一套具备员工管理、菜品分类、套餐组合、订单流转四个核心闭环的 Spring Boot + MyBatis 后台系统,前端用了 Vue + Element UI,所以才会出现 element-index.css、login.css 这类样式文件。适合三类人:做毕设要交差的学生、转行想找个全栈练手的开发、以及想快速梳理订单类和权限类业务模型的 Java 工程师。先别急着解压,往下看它实际能帮你解决什么问题。
2. 拆开压缩包:从 mvnw.cmd 和一堆 CSS 反推项目结构
拿到 zip 第一件事不是双击解压,而是看一眼根目录都有什么。原始资源列表里有 mvnw.cmd、common.css、element-index.css、index.css、demo.css、style.css、page.css、login.css、iconfont.css。这些名字能透露出不少信息:mvnw.cmd 是 Maven Wrapper 的 Windows 启动脚本,说明后端是一个标准 Maven 工程;element-index.css 是 Element UI 的样式索引,iconfont.css 是阿里图标字体,说明前端不是裸 HTML,而是基于 Vue 生态的后台管理界面。这是一个典型的前后端分离项目,只是压缩时可能把 node_modules 和 target 目录清理掉了,只保留了容易被忽略的静态资源。下面我按最常见的工程布局把这个包的结构补全,你在自己机器上按照这个目录校对。
2.1 先别急着跑,把文件清单读懂
先看核心文件的作用,我整理了一张表:
| 文件 | 作用 |
|---|---|
| mvnw.cmd | Maven Wrapper,Windows 下通过它调用固定版本的 Maven,避免本机 Maven 版本不一致导致构建失败 |
| common.css / index.css / page.css | 公共样式、首页样式、通用页面样式,对应后台管理页面布局 |
| demo.css / style.css | 示例样式和主题样式,通常用于组件预览 |
| login.css | 登录页面专用样式,配合登录组件使用 |
| element-index.css | Element UI 的完整样式文件,说明前端组件库是 Element UI |
| iconfont.css | 字体图标,用于菜单和操作按钮的图标渲染 |
从这张表可以确认,这个资源包的核心资产是「Spring Boot 后端 + Vue 前端静态资源」。后端代码在压缩包里可能被省略了,但基于我拆过类似外卖系统的经验,完整工程应该长这样:
food-takeout/ ├── mvnw.cmd ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/example/food/ │ │ │ ├── controller/ │ │ │ │ ├── EmployeeController.java │ │ │ │ ├── CategoryController.java │ │ │ │ ├── DishController.java │ │ │ │ ├── SetmealController.java │ │ │ │ └── OrderController.java │ │ │ ├── service/ │ │ │ ├── mapper/ │ │ │ ├── config/ │ │ │ └── common/ │ │ └── resources/ │ │ ├── application.yml │ │ ├── mapper/ # MyBatis XML 映射文件 │ │ └── db/ │ │ └── food.sql │ └── webapp/ │ └── frontend/ # 前端 Vue 工程 │ ├── src/ │ │ ├── views/ │ │ │ ├── login.vue │ │ │ ├── dish.vue │ │ │ ├── setmeal.vue │ │ │ └── order.vue │ │ └── styles/ │ │ ├── index.css │ │ ├── login.css │ │ └── common.css这里有个实际拆包习惯:我不太相信压缩包里的目录名,而是先看pom.xml的依赖、再看application.yml的配置,最后用tree命令列一遍 src 目录,确认 controller、service、mapper 三个层都在。你解压后如果发现没有src目录,缺的就是 Java 代码,那就需要去原发布页找配套源码。
2.2 前后端分离的请求链路:从登录页 CSS 到员工管理接口
很多新手被mvnw.cmd和各色 CSS 弄懵,不知道入口在哪。我一般会先讲清楚请求链路:浏览器加载login.css渲染登录页,用户输入账号密码后,Vue 组件里发一个POST /api/system/employee/login请求;后端EmployeeController接收 JSON,调 Service 层校验数据库里的员工表,成功后签发 token。这个链路里,前端只负责展示和搜集参数,后端才是业务核心。
后端入口在application.yml,我通常把端口、数据源、MyBatis 配置固定成下面这样:
server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/food_takeout?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: root1234 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis: mapper-locations: classpath*:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意context-path: /api这一项非常关键。前端所有请求都要以/api开头,和后端 Controller 里的@RequestMapping("/system/employee")拼起来就是完整的/api/system/employee/login。如果改成不配 context-path,前端那边就得改 axios 的 baseURL,很容易出现 404。我在做课程设计演示时,一般保持这个路径不动。
前端 axios 的封装也直接影响联调。Vue 工程里通常有一个request.js,里面会设 baseURL 并统一加 token:
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 5000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) export default requestbaseURL: '/api'和后端的context-path一一对应。开发环境里 Vue 的 devServer 还要配 proxy,把带/api的请求转发到 8080 端口:
devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }如果没配 proxy,浏览器直接访问http://localhost:8081/api/xxx会 404,因为 8081 端口上根本没有这个接口。这是前后端分离最常见的联调事故,先确认 proxy 再查代码。
2.3 数据库与表结构:从六个核心表反推业务模型
外卖系统本质上是「商品 + 订单」模型。员工属于后台管理角色,商品被分类管理,套餐是菜品的组合,订单记录则把商品和顾客串起来。所以数据库至少要有这几张表:employee、category、dish、setmeal、setmeal_dish、orders、order_detail。其中setmeal_dish是套餐与菜品的关联表,用来表达多对多关系——一个套餐包含多个菜品,一个菜品可以出现在多个套餐里。
建表脚本通常长这样:
CREATE TABLE `employee` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密后的密码', `status` INT DEFAULT 1 COMMENT '1启用 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `idx_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表'; CREATE TABLE `setmeal_dish` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `setmeal_id` BIGINT NOT NULL, `dish_id` BIGINT NOT NULL, `copies` INT NOT NULL DEFAULT 1, PRIMARY KEY (`id`), KEY `idx_setmeal` (`setmeal_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐菜品关联表';这里有几个细节值得注意。密码字段我用VARCHAR(100)而不是 32,因为 BCrypt 加密后的密文是 60 位,MD5 才是 32 位;如果你沿用老项目的VARCHAR(32),后面改 BCrypt 就得改表结构,这是典型的历史包袱。所有表都使用utf8mb4,否则 emoji 和生僻字会乱码。setmeal_dish上建idx_setmeal索引,是为了在「根据套餐查菜品」时避免全表扫描,这个查询在订单确认页会被高频触发。
订单表设计也要提前想清楚。orders主表放订单总金额、状态、用户信息,order_detail放某个菜品快照——注意是快照,不是外键关联 dish 表。因为菜品价格和名称随时会改,订单详情必须把下单那一刻的商品信息固定下来,否则历史订单的价格会被菜品编辑拖累。状态字段用status INT,配合代码里的枚举类使用,不要直接存「待支付」这种字符串,否则后续状态流转判断很难写。
CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_number` VARCHAR(32) NOT NULL, `user_id` BIGINT NOT NULL, `amount` DECIMAL(10, 2) NOT NULL, `status` INT NOT NULL DEFAULT 1, `address` VARCHAR(255) NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_number` (`order_number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';整个数据库的表关系,一句话总结:employee 和 category 是基础数据,dish 挂在 category 下,setmeal 通过 setmeal_dish 关联 dish,orders 通过 order_detail 记录下单时的菜品快照。理解了这套关系,你看 Controller 和 Service 代码时就不容易迷路。
3. 把它跑起来:从 JDK 配置到前端页面显示
环境搭建是很多 Java 初学者第一个翻车点,尤其是拿到的源码里只有 mvnw.cmd 没有完整环境时。我复现这套外卖系统用的环境组合是:JDK 1.8(或 11)、Maven 3.6.3、MySQL 5.7(或 8.0)、Vue CLI 4.x。下面步骤按我的操作顺序写,每一步都会告诉你命令含义和可能的失败信号。
3.1 环境检查:先把三行命令跑通
不要一上来就启动项目,先确认基础工具路径正常。Windows 下打开 CMD,依次输入:
java -version mvn -version mysql --version第一行输出java version "1.8.0_xxx"说明 JDK 8 可用;如果提示不是内部或外部命令,就是JAVA_HOME环境变量没配置。第二行会显示 Maven 版本和 Java 版本,重点看是否和 JDK 匹配——Maven 3.6 配 JDK 8 没问题,配 JDK 17 某些旧插件会报兼容错误。第三行确认 MySQL 客户端能连上本地服务。这三行里任何一行失败,都不要继续往下走,否则后面 90% 的错误都会指向环境问题。
我见过最典型的情况是:Java 装的是 17,项目的pom.xml里放的还是spring-boot-starter-parent 2.3.x,结果启动时直接报UnsupportedClassVersionError。所以拿到源码第一眼看pom.xml里的 Spring Boot 版本,如果< 2.5,JDK 用 8 最稳。
如果java -version失败,把 JAVA_HOME 配好。Windows 10 在「系统属性 → 环境变量」里新增:
JAVA_HOME = C:\Program Files\Java\jdk1.8.0_202 Path = %JAVA_HOME%\bin配完后重新开一个 CMD,再执行java -version验证。注意只改 Path 不动 JAVA_HOME,某些 IDE 还是会找不到 JDK,两个都要设。
3.2 导入数据库并修改数据源配置
后端跑起来前必须把数据库建好。假设food.sql在src/main/resources/db/下,导入命令:
mysql -uroot -p123456 -e "CREATE DATABASE IF NOT EXISTS food_takeout DEFAULT CHARSET utf8mb4;" mysql -uroot -p123456 food_takeout < src/main/resources/db/food.sql第一条命令创建库,-uroot -p123456是账号密码,等号后面是你的本机密码。第二条命令把表结构和初始化数据导入。如果food.sql文件里已经有CREATE DATABASE语句,第二步的库名可以省略,但我建议还是显式写上,避免脚本里写死了不存在的库名。
导入后修改application.yml,把数据库地址、账号密码改成你自己的。特别注意serverTimezone=Asia/Shanghai,如果省掉这一句,MySQL 8 会因时区问题抛Server returns invalid timezone,这是我复现时必踩的一条。
3.3 启动后端服务
在项目根目录执行:
mvnw.cmd clean package -DskipTests java -jar target/food-takeout-1.0-SNAPSHOT.jar第一行直接用项目自带的 Maven Wrapper 打包,好处是不依赖本机 Maven 路径,-DskipTests跳过单元测试,加快构建。第二行启动 jar 包。如果用的是 MySQL 8 而 pom 里依赖的是mysql-connector-java 5.1.x,会报Public Key Retrieval is not allowed,需要在 JDBC URL 上加allowPublicKeyRetrieval=true&useSSL=false。看到Started FoodTakeoutApplication和Tomcat started on port 8080就说明后端起来了。
还有更快的启动方式:
mvnw.cmd spring-boot:run两种方式效果一样,但第一种先打包再运行更接近生产习惯,也方便排查 target 里有没有缺资源文件。我建议新手用第二种,因为日志会实时打印在控制台,改完代码不用重复打包。
启动阶段常见的失败信号我整理成了表:
| 日志关键字 | 含义 | 处置 |
|---|---|---|
UnsupportedClassVersionError | JDK 版本过新 | 换 JDK 8 |
Connection refused | 数据库没起或端口不对 | 确认 MySQL 服务已启动 |
Unknown database | 数据库名写错 | 按 3.2 创建库 |
Port 8080 was already in use | 端口被占用 | 看第 5.1 节 |
FileNotFound: mapper/*.xml | Mapper XML 路径错误 | 检查mybatis.mapper-locations |
3.4 启动前端页面
前端如果是一个独立的 Vue 工程,在frontend目录执行:
npm install npm run devnpm install会安装package.json里声明的依赖,网络差时可能要跑几分钟;npm run dev启动开发服务器,默认端口 8081。注意前端端口和后端 8080 不能一样,否则会冲突。如果只有一个静态资源页面,没有package.json,那你就把common.css、element-index.css等文件放到后端的src/main/resources/static目录下,启动后端后直接访问http://localhost:8080/index.html。我拿到精简版源码时经常这么干,能省去装 Node 环境的麻烦。
生产环境或演示环境,我更推荐用 Nginx 托管前端静态文件,再反向代理后端接口。这样前端页面不再依赖 Node 服务:
server { listen 80; server_name localhost; location / { root /data/food-frontend/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; } }try_files $uri $uri/ /index.html是为了兼容 Vue Router 的 history 模式,刷新页面时不会 404。location /api/里的proxy_pass必须以/结尾,否则会把/api前缀吃进去,导致后端收到/system/employee/login而不是/api/system/employee/login。这个细节我调试了一下午,血泪教训。
3.5 第一个接口验证:管理员登录
页面能打开不代表系统通了,真正验证要调接口。我用 curl 测后端登录:
curl -X POST http://localhost:8080/api/system/employee/login \ -H "Content-Type: application/json" \ -d "{\"username\":\"admin\",\"password\":\"123456\"}"返回 JSON 里带着 token 和员工信息,说明数据库、密码校验、序列化链路都正常。这里有个小技巧:很多外卖系统初始化管理员密码统一是123456,但源码里可能用 BCrypt 存了密文。你从 SQL 里SELECT password FROM employee看长度,如果是 60 位就是 BCrypt,如果只有 32 位则是 MD5,这两种加密方式对应不同的校验代码,别混着猜。
curl 验证通过后,再打开浏览器走一遍登录页。如果页面报跨域,看 3.4 的 proxy 配置;如果报 401,看 4.1 的拦截器路径。这些坑后面单独讲。
4. 核心业务模块拆解:员工、分类、套餐、订单
当系统跑起来后,就该看代码是怎么写的。这一章我挑四个最具代表性的模块:员工权限、菜品分类、套餐组合、订单状态流转。每个模块都会给出关键代码片段,以及为什么这样设计。
4.1 员工管理:JWT 拦截器与 BCrypt 密码校验
员工管理表面上是 CRUD,真正值钱的是权限控制。一般做法是:登录成功后签发 JWT,后续请求头带上 token,后端用一个拦截器统一校验,没带 token 直接返回 401。Spring Boot 里的配置常常长这样:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/system/employee/login", "/frontend/**", "/index.html", "/css/**", "/js/**"); } }addPathPatterns("/**")表示所有接口都拦,excludePathPatterns放行登录接口和静态资源。注意登录接口的路径是/system/employee/login,因为这里配置的拦截器路径是相对context-path的,如果写成/api/system/employee/login反而匹配不上,这是很多人配了拦截器发现登录也 401 的原因。
密码校验推荐用BCryptPasswordEncoder:
if (!passwordEncoder.matches(rawPassword, employee.getPassword())) { throw new RuntimeException("用户名或密码错误"); }matches方法会把明文密码和数据库里的 BCrypt 密文做比对,不需要解密,这是安全上更推荐的方式。如果你在代码里看到MD5Util.md5(rawPassword).equals(...),说明老项目用的是 MD5,购买或下载源码时记住:MD5 无盐值容易被彩虹表撞库,以后要迁移到 BCrypt。
4.2 菜品分类管理:树形结构与图片上传的边界
分类管理可以用一棵树来表示:category表里parent_id = 0表示顶级分类,非 0 表示子分类。前端展示时递归渲染成菜单,后端查询时一次性把全部类别返回,由前端 Vue 里做树形转换。后端接口:
@GetMapping("/category/list") public Result<List<Category>> list() { return Result.success(categoryService.getTree()); }getTree()的常见实现是先查全部category,内存中根据parent_id组装成List<Category>的父子结构。数据量在几百条时这么做没问题,超过几千条再考虑在 SQL 里用递归查询或者做懒加载。
前端拿到扁平列表转树时,Element UI 的 el-tree 需要children字段。我常在 Vue 里写这样一个转换方法:
buildTree(list) { const map = {} list.forEach(item => { map[item.id] = { ...item, children: [] } }) const tree = [] list.forEach(item => { if (item.parentId === 0) { tree.push(map[item.id]) } else { map[item.parentId].children.push(map[item.id]) } }) return tree }这里两个 forEach 就完成 O(n) 建树,避免嵌套递归导致深层分类卡顿。如果后端返回的是嵌套结构,前端就不需要这个工具函数,但接口设计上扁平结构更容易复用,我倾向后端直接返回树。
菜品图片上传是另一个坑点。常见的 Controller:
@PostMapping("/dish/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext; String path = uploadDir + "/" + newName; file.transferTo(new File(path)); return Result.success("/upload/" + newName); }这里uploadDir通常来自application.yml自定义配置,比如file.upload-dir=D:/upload/。返回的 URL 要能被前端访问,所以要么把 uploadDir 映射为静态资源路径,要么用 Nginx 对/upload/做 alias。新手最容易在这个地方踩坑:文件确实存到了本地磁盘,但页面拿/upload/xxx.jpg去请求时 404,就是因为 Spring Boot 默认没有把磁盘目录映射到静态资源。
解决方式是在配置类里加资源映射,通常都会跟 5.4 节联动:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:D:/upload/"); }这样图片 URL 才能被访问到。真实项目还会做图片压缩和格式校验,不只是存文件,简易版源码能跑通就行。
4.3 套餐管理:多对多关联与事务回滚
套餐是「一个套餐包含多个菜品,一个菜品可被多个套餐引用」的多对多模型,必须用中间表setmeal_dish存储关系。新增套餐时,要同时插入setmeal主表和setmeal_dish关联表,两步必须要么都成、要么都回滚,用@Transactional是最简洁的写法:
@Transactional(rollbackFor = Exception.class) public Long saveSetmeal(SetmealDTO dto) { Setmeal setmeal = new Setmeal(); BeanUtils.copyProperties(dto, setmeal); setmealMapper.insert(setmeal); List<SetmealDish> dishList = dto.getDishList(); for (SetmealDish item : dishList) { item.setSetmealId(setmeal.getId()); setmealDishMapper.insert(item); } return setmeal.getId(); }rollbackFor = Exception.class很关键:如果不指定,只有 RuntimeException 才会触发回滚,而自定义的业务异常如果继承 Exception,就不会回滚,导致主表有记录、关联表没有,套餐点进去是空的。我习惯在 Service 层再手动检查一遍:
if (dishList == null || dishList.size() == 0) { throw new RuntimeException("套餐至少包含一个菜品"); }放在事务方法开头,避免插入空套餐。事务方法内部也不要捕获异常再吞掉,要让异常抛出才能回滚。
修改套餐和新增不同,需要先删掉中间表里所有旧关联记录,再重新插入。这个操作同样要保证事务:
@Transactional(rollbackFor = Exception.class) public void updateSetmeal(SetmealDTO dto) { setmealMapper.update(dto); setmealDishMapper.deleteBySetmealId(dto.getId()); for (SetmealDish item : dto.getDishList()) { item.setSetmealId(dto.getId()); setmealDishMapper.insert(item); } }先删后插的写法简单,但并发时会有一瞬间套餐是空的。要求高的话可以改成先校验再批量覆盖,课程设计阶段先删后插完全够用。
从数据库层面看,中间表的联合唯一索引也应加上,防止同一套餐重复添加同一菜品。修改建表 SQL:
ALTER TABLE setmeal_dish ADD UNIQUE KEY `uk_setmeal_dish` (`setmeal_id`, `dish_id`);索引还能加速根据 dish_id 反查哪些套餐包含该菜品,这个操作在「菜品已售罄需要批量下架套餐」时特别有用。
4.4 订单流程:状态枚举与并发扣库存
订单是外卖系统的核心,状态不能靠手写数字,一定要用枚举管理。常见状态定义:
public enum OrderStatus { PENDING_PAYMENT(1, "待支付"), PAID(2, "已支付"), DELIVERING(3, "配送中"), COMPLETED(4, "已完成"), CANCELLED(5, "已取消"); private final Integer code; private final String desc; OrderStatus(Integer code, String desc) { this.code = code; this.desc = desc; } }枚举的好处是状态流转的代码里有语义,不会出现if (order.getStatus() == 2)这种魔法数字。后续加状态只改枚举类,引用处自动感知。
订单创建和库存扣减必须放同一个事务里。并发环境下直接先查库存再 update 是危险的,两个线程同时查到库存 1,都去扣就变成 -1。我推荐用条件更新的原子 SQL:
UPDATE dish SET stock = stock - #{num} WHERE id = #{dishId} AND stock >= #{num}这段 SQL 的WHERE stock >= #{num}是并发兜底:如果库存不够,影响行数为 0,Java 里再判断:
int rows = dishMapper.decreaseStock(dishId, num); if (rows == 0) { throw new RuntimeException("库存不足"); }这是比悲观锁更轻的乐观锁思路,很适合外卖这类读多写少的场景。注意stock字段别用BIGINT以外类型,否则扣到负数还查不出来。
支付回调更要注意幂等:支付宝或微信回调可能发送多次,所以订单表的支付状态更新必须带上原状态条件:
UPDATE orders SET status = 2, pay_time = NOW() WHERE id = #{orderId} AND status = 1更新行数为 0 说明订单已被处理过,直接返回成功,不要再下发配送消息。这类幂等判断是线上事故高发地,我在演示源码时经常单独拉出来讲。
订单超时未支付的处理也很典型。简单方案是定时任务每分钟扫一次:
@Scheduled(fixedDelay = 60000) public void closeExpiredOrders() { orderMapper.closeByStatusAndTime(OrderStatus.PENDING_PAYMENT.getCode(), LocalDateTime.now().minusMinutes(15)); }批量把超过 15 分钟还没支付的订单改成已取消,同时把锁定的库存释放。正式项目会用延迟队列或消息队列,但源码讲解阶段,定时器能说明白这个道理就够。
5. 避坑笔记:从数据库乱码到端口占用,六条真实踩坑记录
这一章是我在多个环境里复现这套源码后攒下的经验。每条都按「现象 → 原因 → 解决」整理,你照着这个列表排查,比看十篇报错文章快。
5.1 启动后端立刻提示端口被占用
现象:java -jar启动后马上报Port 8080 was already in use.。 原因:本机已有其他服务占用了 8080,或者上一次启动的后台进程没杀掉。 解决:先查占用:
netstat -ano | findstr 8080拿到 PID 后执行taskkill /F /PID 1234强制结束。如果 8080 上有其他不想杀的服务,那就改application.yml的server.port为 8082,同时同步修改前端 axios 的 baseURL 和 Nginx 的 proxy_pass。注意前端开发服务器默认 8081,前后端两个端口不要撞。
5.2 MySQL 8 报 Public Key Retrieval is not allowed
现象:启动时抛java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed。 原因:MySQL 8 默认使用caching_sha2_password认证,在 SSL 未开启时不允许客户端直接获取公钥,而 pom 里依赖的驱动版本又不够新。 解决:两条路选一条,优先改 JDBC URL:
spring: datasource: url: jdbc:mysql://localhost:3306/food_takeout?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai如果还不行,检查pom.xml里 MySQL 驱动是否是com.mysql:mysql-connector-j8.0 系列,5.1 的旧驱动即使加了参数也经常不认。
5.3 登录后访问其他接口返回 401,登录接口也被拦截
现象:curl 登录接口返回 401,明明已经在拦截器放行了登录路径。 原因:拦截器配置的放行路径少了context-path前缀,或者路径写多了/api。Spring Boot 中excludePathPatterns匹配的是去掉context-path之后的 URL。 解决:如果server.servlet.context-path=/api,拦截器里写/system/employee/login。如果还是 401,把拦截器放行写在addPathPatterns("/**")之前,并加一行日志打印请求 URI,看拦截器拦截到的真实路径长什么样。我一般用request.getRequestURI()输出到控制台,数字段就知道前缀问题了。
5.4 菜品图片上传成功但页面显示裂图
现象:上传接口返回了/upload/xxx.jpg,但浏览器访问该路径 404。 原因:文件写入了本地磁盘 D:/upload,但 Spring Boot 没有把/upload/**映射到那个目录,前端拿到的 URL 无法路由到静态资源处理器。 解决:在配置类里加资源映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:D:/upload/"); } }注意D:/upload/最后必须带斜杠,不带斜杠 Spring 会当成文件而不是目录,引起新的 404。如果是部署在 Linux,路径写成file:/home/user/upload/。
5.5 控制台中文乱码,SQL 查询返回问号
现象:后端日志打印出的中文全是???,数据库里SELECT * FROM dish也是一堆问号。 原因:三处编码不统一,最常见的是 MySQL 连接串没指定characterEncoding=utf-8,或者建库时不是utf8mb4,又或者 Windows 控制台默认 GBK。 解决:先保证 JDBC URL 里有characterEncoding=utf-8,再检查建表语句的DEFAULT CHARSET=utf8mb4,最后在pom.xml里给 Spring Boot 加启动参数-Dfile.encoding=UTF-8。如果是 IDEA 运行,在 VM options 里填-Dfile.encoding=UTF-8;Windows 控制台运行则执行chcp 65001切换 UTF-8 代码页。这三处都改完,乱码基本绝迹。
5.6 Maven 依赖下载超时,构建卡死或 jar 包损坏
现象:mvnw.cmd clean package一直卡在下载依赖,或者报Could not transfer artifact、ZipFile invalid。 原因:Maven 默认中央仓库在国外,网络不稳;断点续传机制不完善,下载一半的.lastUpdated文件被 Maven 当缓存用,导致后面的构建一直在拿坏文件重试。 解决:换国内镜像源。在Maven 安装目录/conf/settings.xml的<mirrors>标签里加:
<mirror> <id>aliyun</id> <url>https://maven.aliyun.com/repository/public</url> <mirrorOf>central</mirrorOf> </mirror>如果已经下载了一半,需要强制更新快照:
mvnw.cmd clean package -U-U参数会强制刷新 SNAPSHOT 依赖。项目里如果使用项目自带的 Maven Wrapper,镜像源配置在~/.m2/settings.xml中,别改错位置。
以上六条覆盖了从启动到使用的绝大多数坑。排查的时候记住一个原则:只改一个变量,重启验证。别同时改端口又改编码又改数据库,到时候报错都不知道是哪个引起的。
6. 进阶:把订单模块改造成 Redis 缓存 + 微服务就靠这三步
这套系统跑通之后,如果想让它在简历上更有分量,别停在「能启动」这一步。我一般会按三步做改造,每一步都能单独拿出来讲。
第一步是给分类接口加 Redis 缓存。菜品分类是典型的读多写少,每次请求都查数据库很浪费。用 Spring Data Redis 很简单:
@GetMapping("/category/list") public Result<List<Category>> list() { String key = "food:category:tree"; String cached = stringRedisTemplate.opsForValue().get(key); if (cached != null) { return Result.success(JSON.parseArray(cached, Category.class)); } List<Category> tree = categoryService.getTree(); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(tree), 30, TimeUnit.MINUTES); return Result.success(tree); }这里设置了 30 分钟过期时间。改造后,分类接口在缓存命中时耗时从几十毫秒降到个位数毫秒。面试提这个点时,要强调缓存穿透和一致性问题:后台修改分类时必须删除缓存,否则前台永远显示旧分类。
第二步是给下单接口加分布式幂等。前面 4.4 的订单状态幂等只针对支付回调,真正的高并发场景下,用户连点两下提交按钮会产生两个订单。更优雅的方案是前端生成幂等 key,后端用 Redis 判断是否处理过:
public void createOrder(String idempotentKey, OrderDTO dto) { Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent("order:idem:" + idempotentKey, "1", 5, TimeUnit.MINUTES); if (!success) { throw new RuntimeException("请勿重复提交"); } // 继续订单创建逻辑 }setIfAbsent是 Redis 的 setnx,同一个 key 只能写入一次,天然防止重复提交。
第三步是把订单模块抽成独立微服务。源码里订单和员工耦合在一起,拆服务时先按包名边界划分,把OrderController、OrderService、OrderMapper迁移到新工程,再通过 Feign 调用用户服务和菜品服务。这一步需要引入 Spring Cloud,但属于锦上添花,课程设计做到第二步已经足够优秀。
最后说点实在的。我每次拿到新源码,都会强制走一遍「看目录 → 配数据库 → 跑登录 → 看日志」四件事,全部记录成笔记,这样即使源码被裁剪过也能快速复原。这个习惯救过我好几次。希望你复现这套外卖系统时少走弯路,希望帮到你。
本文还有配套的精品资源,点击获取