简介:这是一套基于Spring Boot的外卖点餐系统毕业设计项目源码,适合Java方向应届毕业生或需要完整实战项目的开发者。项目实现用户登录、菜品浏览、购物车、订单提交与后台管理等核心功能,采用前后端分离架构,覆盖前后端交互、权限控制等常见业务场景。压缩包共281个文件,包括60个Java后端源码、51个Vue前端页面、92个PNG图片素材,以及SQL数据库脚本、XML/yml配置、Maven构建工具等,整体大小仅14.16MB,目录清晰,部署方便。项目已获导师指导并通过,认可度较高,目前已有509人学习。借助这套资料,读者可以快速掌握Spring Boot与Vue的整合流程,获得完整可运行的源码、数据库设计及前端页面,也能参考其订单服务、菜品管理等关键模块的代码组织方式,为毕业设计或二次开发提供直接帮助,是Java Web项目实践与毕业设计答辩的良好参考。
1. 点餐系统的复杂度不在增删改查,而在订单状态与库存边界
一套 springboot 外卖点餐系统源码解压之后,通常是一个后端工程加一个 .sql 数据库文件。很多第一次接触这种 java 毕业设计的人,跑起来之前最慌的是三件事:数据库怎么导、账号密码怎么改、为什么连续下单后库存对不上。这个项目表面是 CRUD,真正的复杂度集中在两个字段上:订单状态和菜品库存。只要弄清了订单主表和明细表的关系、扣库存的更新语句怎么写,剩下的登录、购物车、菜品管理都是同一套套路。下面按「数据库 → 后端实现 → 运行参数 → 答辩验证」的顺序,把这个标题背后的外卖点餐系统从源码到数据库完整拆开,适合拿到源码后想快速改顺手的人,也适合想拿一个完整业务场景去准备 springboot 面试题的工程师。
2. 业务链路与 MySQL 数据库表设计:先想清楚订单从哪来到哪去
2.1 从点餐链路推导八张核心表
一个最小可用的外卖点餐系统,业务链路是:用户浏览店铺和菜品 → 加入购物车 → 提交订单 → 付款(毕设通常是模拟支付)→ 商家接单 → 配送完成。这条链路落到 MySQL 里,就是一组相互关联的表。常见的表集合如下,具体源码的表名可能带前缀,字段也可能有差异,但关系不变。
| 表名(常见命名) | 关键字段 | 作用 |
|---|---|---|
| user | id, username, password, role | 用户、商家、管理员共用一张表,用 role 区分 |
| shop | id, user_id, name, status | 商家店铺,一个商家对应一个店铺 |
| category | id, shop_id, name, sort | 菜品分类 |
| dish | id, shop_id, category_id, name, price, stock, status | 菜品与库存,金额必须用 DECIMAL |
| cart | id, user_id, dish_id, count | 购物车,可用 Redis 替代 |
| address | id, user_id, phone, detail | 收货地址 |
| order_master | id, order_no, user_id, shop_id, total_amount, status | 订单主表,一个订单一条 |
| order_detail | id, order_master_id, dish_id, dish_name, price, count | 订单明细,按菜品快照存储 |
核心关系是 order_master 与 order_detail 的一对多关系。为什么订单要拆成两张表?因为一个订单包含多个菜品,明细表里的 dish_name 和 price 不是实时去查菜品表,而是下单那一刻的冗余快照。菜品改名、改价都不影响历史订单的统计,这也是数据库课程设计里很值得写进文档的一点:交易数据要快照,主数据要冗余。
这套表设计里我一般不会建物理外键。物理外键在演示现场很容易被约束挡住,比如导师要当场删一个分类,关联数据没清干净就删不掉。用逻辑外键,也就是普通索引加业务约束,演示更顺畅,性能也更好。如果你收到的源码里建了完整外键,导入后建议先跑一段基础流程再决定要不要保留,不要上来就删结构。
2.2 建表脚本、命令行导入与字符集参数
下面是一份可用的订单核心表 SQL,和多数毕设源码的字段风格一致。
CREATE TABLE `order_master` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,展示给用户', `user_id` BIGINT NOT NULL COMMENT '下单用户', `shop_id` BIGINT NOT NULL COMMENT '店铺', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2商家接单 3配送中 4已完成 5已取消', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_shop_id` (`shop_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE `order_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_master_id` BIGINT NOT NULL COMMENT '关联订单主表', `dish_id` BIGINT NOT NULL COMMENT '菜品ID', `dish_name` VARCHAR(100) NOT NULL COMMENT '冗余菜名,防止菜品改名', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `count` INT NOT NULL COMMENT '数量', PRIMARY KEY (`id`), KEY `idx_order_master_id` (`order_master_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';这段 SQL 里有几个容易被忽略的参数:金额用 DECIMAL(10,2) 而不是 DOUBLE,避免浮点误差;order_no 用业务号并加唯一索引,不要把数据库自增 ID 直接展示给用户;status 用 TINYINT 加注释,比字符串值更省空间也更好做索引。
拿到源码里的 .sql 文件后,命令行导入是最稳的方式:
mysql -uroot -p --default-character-set=utf8mb4 waimai < 外卖点餐系统.sql--default-character-set=utf8mb4保证中文注释不乱码。导入前如果报 Unknown database,先建库:
CREATE DATABASE waimai DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;用 Navicat 的同学走「右键数据库 → 运行 SQL 文件」,编码选 65001(UTF-8)。导入完成后不要急着启动项目,先执行SHOW TABLE STATUS;确认表数量和字符集,再用SELECT COUNT(*) FROM dish;看有没有种子数据。很多源码自带的账号、店铺、菜品都是插在 SQL 文件里的,确认有数据,登录时才不会遇到「账号不存在」。
顺带说一句 springboot 配置相关的误区:MyBatis-Plus 或 JPA 的自动建表功能只适合本地实验。毕业设计评审时直接用 SQL 文件初始化,表结构、注释、种子数据都能在文档里展示,比「让框架自动建表」更有说服力。
2.3 订单状态字段:int 枚举加状态机,而不是随便填字符串
订单状态是整个外卖点餐系统里最容易写乱的部分。常见的做法是用一个 int 字段,配合枚举类统一管理。状态流转方向如下:
| 状态值 | 状态名 | 可流转方向 |
|---|---|---|
| 0 | 待支付 | 1 或 5 |
| 1 | 已支付/待接单 | 2 或 5 |
| 2 | 商家已接单 | 3 或 5 |
| 3 | 配送中 | 4 |
| 4 | 已完成 | 无 |
| 5 | 已取消 | 无 |
对应到 Java 代码里,常见做法是定义一个枚举:
public enum OrderStatusEnum { WAIT_PAY(0, "待支付"), PAID(1, "已支付"), ACCEPTED(2, "商家已接单"), DELIVERING(3, "配送中"), FINISHED(4, "已完成"), CANCELED(5, "已取消"); private final int value; private final String desc; OrderStatusEnum(int value, String desc) { this.value = value; this.desc = desc; } public int getValue() { return value; } public String getDesc() { return desc; } public boolean canChangeTo(int target) { if (this == WAIT_PAY) { return target == PAID.getValue() || target == CANCELED.getValue(); } if (this == PAID) { return target == ACCEPTED.getValue() || target == CANCELED.getValue(); } if (this == ACCEPTED) { return target == DELIVERING.getValue() || target == CANCELED.getValue(); } return this == DELIVERING && target == FINISHED.getValue(); } }这个枚举把魔法数字集中到了一处,controller 和 service 里不会到处出现if (status == 3)这种难以维护的硬编码。真正写业务时,建议在商家接单、骑手取餐的接口里都先调用canChangeTo做一次校验,避免出现「已完成订单被改回已支付」这种离谱数据。如果源码里是一个纯 int 字段也没关系,用一个常量类包住所有状态值即可,改动量不大。
3. Spring Boot 后端实现:登录态、下单事务与缓存选型
3.1 JWT 登录与三种角色共用一套鉴权
外卖点餐系统一般有三端:用户端小程序或 H5、商家端后台、管理端后台。三张端各自写登录逻辑是重复代码,常见做法是共用一张 user 表,登录接口返回 JWT,后面所有请求都带Authorization: Bearer <token>走同一个拦截器。选 JWT 而不是 Session,主要因为演示环境前后端端口不同,JWT 不依赖 Cookie,跨域时更省事。
拦截器核心逻辑如下:
@Component public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public JwtInterceptor(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 登录和注册接口放行 String uri = request.getRequestURI(); if ("/api/login".equals(uri) || "/api/register".equals(uri)) { return true; } String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { response.setStatus(401); return false; } Long userId = jwtUtil.parseUserId(header.substring(7)); if (userId == null) { response.setStatus(401); return false; } // 后续 Controller 用 @RequestAttribute("userId") 取值,避免到处解析 token request.setAttribute("userId", userId); return true; } }代码逻辑不复杂:先放行登录注册,再取请求头里的 token,解析失败直接回 401,成功则把 userId 放进 request attribute。Controller 里只要写@RequestAttribute("userId") Long userId就能拿到当前用户,不需要在每个方法里重复解析。
配套的密钥配置建议放 application.yml:
jwt: secret: waimai-demo-secret-key-change-in-production expire-hours: 72密钥长度至少 32 字节,过期时间按演示场景设 72 小时足够。生产环境不应该把密钥提交到仓库,用环境变量读取。如果还要区分商家和管理员,把 role 一起写进 token 的 claim 里,然后在商家管理接口加一个角色校验注解,和用户端接口隔离开。
3.2 下单事务与条件扣库存:避免负库存
下单接口是整个 springboot 项目里含金量最高的部分,也是一些同学库存对不上的根源。核心流程是:校验用户和店铺、计算订单金额、插入订单主表、插入订单明细、扣减库存、清空购物车。这一串操作必须放在同一个事务里,任何一个环节失败都要全部回滚。
@Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, Long shopId, List<CartItem> items) { // 1. 计算总金额并核对菜品是否上架 BigDecimal total = BigDecimal.ZERO; for (CartItem item : items) { Dish dish = dishMapper.selectById(item.getDishId()); if (dish == null || dish.getStatus() != 1) { throw new BusinessException("菜品已下架: " + item.getDishId()); } total = total.add(dish.getPrice().multiply(new BigDecimal(item.getCount()))); } // 2. 插入订单主表,状态 0 待支付 OrderMaster order = new OrderMaster(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setShopId(shopId); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 3. 插入订单明细 for (CartItem item : items) { OrderDetail detail = new OrderDetail(); detail.setOrderMasterId(order.getId()); detail.setDishId(item.getDishId()); detail.setCount(item.getCount()); orderDetailMapper.insert(detail); } // 4. 扣库存:条件更新比先查再减更安全 for (CartItem item : items) { int rows = dishMapper.deductStock(item.getDishId(), item.getCount()); if (rows == 0) { throw new BusinessException("库存不足: " + item.getDishId()); } } // 5. 清空购物车 cartMapper.deleteByUserId(userId); return order.getId(); }对应的 Mapper XML 里,扣库存的 SQL 是这样:
<update id="deductStock"> UPDATE dish SET stock = stock - #{count} WHERE id = #{dishId} AND stock >= #{count} </update>这个方法返回受影响行数,要么是 1,要么是 0。并发场景下两个用户同时买同一个菜,数据库会对这一行加锁,第二个事务执行时发现 stock 不够,更新 0 行,代码抛出「库存不足」并回滚整个订单,不会出现库存变成负数。如果先SELECT stock再在 Java 里判断,两个请求同时读到库存 5、同时扣 5,库存就变成 0 了但实际可能超卖两次,这就是负库存的典型来源。
事务这块还有两个 springboot 面试里容易被问的点。第一个是@Transactional默认只对 RuntimeException 回滚,自定义 BusinessException 如果继承的是 Exception,要写上rollbackFor = Exception.class才会回滚。第二个是同类内部调用会失效,比如在同一个类里用this.createOrder(...),注解不经过 Spring 包装,事务不会生效,常见做法是把下单逻辑拆到另一个 Service 里。另外,事务里不要调第三方支付接口或者发短信,这类远程调用耗时不可控,会让数据库连接被长时间占用。
3.3 购物车用 MySQL 表就够了,Redis 是加分项不是必须
很多毕业设计源码的卖点写着「Redis 缓存购物车」,但从评审角度,MySQL 的 cart 表和 Redis 各有取舍:
| 对比点 | MySQL cart 表 | Redis Hash |
|---|---|---|
| 读写延迟 | 每次走一次 SQL | 内存操作,毫秒级 |
| 数据持久化 | 天然落库 | 需要持久化配置或允许丢失 |
| 演示复杂度 | 不需要额外服务 | 本机必须装 Redis |
| 面试提问价值 | 普通 CRUD | 能讲缓存一致性和过期策略 |
想控制演示成本的,用 cart 表完全没问题。想给项目加亮点的,可以在购物车模块用 Redis:
@Autowired private StringRedisTemplate redisTemplate; public void addToCart(Long userId, Long dishId, Integer count) { String key = "cart:" + userId; redisTemplate.opsForHash().put(key, String.valueOf(dishId), String.valueOf(count)); }这里 key 用cart:用户ID,hash 的 field 用 dishId,value 用数量。读取购物车时一次性拿到所有菜品 ID,再去数据库批量查菜品信息。注意 Redis 的 value 都是字符串,数字类型要手动转换。
对应的连接配置如下:
spring: data: redis: host: localhost port: 6379 password: database: 0这里有个版本坑:Spring Boot 2.x 的配置前缀是spring.redis,Spring Boot 3.x 改成了spring.data.redis。网上大量教程还在用 2.x 的写法,你装了 3.x 照着配,日志里完全看不到 Redis 相关报错,但所有连接都走默认 localhost,这就属于典型的 springboot 配置不生效问题。源码包是 2.x 时代的写法而你本机装 3.x,经常还会遇到 javax 命名空间被替换成 jakarta 导致编译不过,处理方式是先看源码里 pom 的 Spring Boot 父版本,而不是盲目升级。
Redis 密码不建议明文写死在 yml 里,常见做法是用环境变量${REDIS_PASSWORD:}注入,或者配合 Jasypt 做配置密文,答辦时提到「密码不走配置文件明文」是一个不错的加分点。
3.4 菜品图片上传与订单号生成
菜品图片是毕设里最常见的翻车点。源码里图片地址经常写成相对路径,打包成 jar 后路径失效。常见做法是配置一个绝对路径,并注册静态资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${upload.path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }配置里指定:
upload: path: D:/waimai-upload/如果源码里图片存的是 base64 字符串直接塞数据库,演示时没问题,但数据库会膨胀得很快,不推荐。
订单号生成也有讲究。不要用数据库自增 ID 直接展示给用户,会被猜到订单量。并发不高时用时间戳加随机数足够:
private String generateOrderNo(Long userId) { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")) + String.format("%04d", userId % 10000) + String.format("%02d", ThreadLocalRandom.current().nextInt(100)); }这个方案生成 20 位左右订单号,同一用户在同一毫秒内下两单才可能重复,配合 order_master 表里的唯一索引,冲突时会直接报错,可以在 service 里捕获后重新生成。UUID 可以当作内部标识,但不要把 32 位带横线的字符串给用户看。
4. Spring Boot 运行参数与常见启动报错排查
4.1 数据源、MyBatis-Plus 与 JDK 环境变量一次配好
拿到源码后先别急着点启动,把环境变量和数据源核对一遍。JDK 方面确认JAVA_HOME指向 JDK 安装目录,java -version能看到版本。Spring Boot 2.x 用 JDK 8 或 11,Spring Boot 3.x 至少 JDK 17。资料里经常出现「springboot 版本太高」的问题,本质是源码基于 2.x 写的,你本机装了 3.x 对应的最新依赖,javax 和 jakarta 命名空间冲突导致编译失败。
一个干净的 MySQL 数据源配置长这样:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/waimai?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: "123456" mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true几个参数说明:MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,老教程里的com.mysql.jdbc.Driver会提示加载失败;serverTimezone=Asia/Shanghai解决数据库时间差 8 小时的问题;allowPublicKeyRetrieval=true是 MySQL 8 的 caching_sha2_password 认证插件经常报的 Public Key Retrieval 问题;StdOutImpl把 SQL 打印到控制台,排查问题比关掉日志方便很多,演示时再换成org.apache.ibatis.logging.slf4j.Slf4jImpl。
启动命令建议限制一下堆内存:
java -Xms256m -Xmx512m -jar 外卖点餐系统.jar --spring.profiles.active=dev-Xms256m -Xmx512m给演示环境 512M 上限,避免本地内存被撑爆;--spring.profiles.active=dev指定加载 application-dev.yml,如果源码里只有单文件配置,去掉这个参数即可。看到Started Application in xx seconds才代表启动成功,别只看端口没冲突就以为没事。
4.2 前后端分离的 CORS 与预检请求
现在的毕设前端基本都用 Vue,跑起来后最常见的现象是后端启动正常、数据库有数据、但页面上所有接口都报 CORS。解决方式是后端加一个全局跨域配置:
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); }allowedOriginPatterns("http://localhost:*")兼容不同前端端口,allowCredentials(true)允许带 Cookie,maxAge(3600)让预检请求结果缓存一小时。浏览器在发 POST、PUT 这类非简单请求前,会先发一个 OPTIONS 预检,如果后端没有正确响应 CORS 头,主请求根本不会发出。这个配置在 Spring Boot 3.x 里同样生效,不需要额外依赖。
4.3 启动与登录报错对照表
下面这些报错出现的频率最高,按表格核对能省下大量排查时间:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| Invalid bound statement (not found) | mapper.xml 没被扫描到 | 检查 mapper-locations 是否为 classpath*:mapper/**/*.xml |
| Access denied for user 'root'@'localhost' | 数据库密码不符 | 核对 yml 中密码,注意特殊字符需要单引号包裹 |
| Public Key Retrieval is not allowed | MySQL 8 认证插件问题 | URL 加 allowPublicKeyRetrieval=true |
| Unknown database 'waimai' | 数据库没创建 | 先执行 CREATE DATABASE 再导数据 |
| Table 'xxx' doesn't exist | 表名大小写不一致 | 检查 lower_case_table_names 配置 |
| Port 8080 was already in use | 端口被占用 | 用java -jar xx.jar --server.port=8081换端口 |
| 时间差 8 小时 | 时区参数缺失 | URL 加 serverTimezone=Asia/Shanghai |
登录环节还有一个隐蔽问题:源码里密码可能存的是 MD5 或 BCrypt。先查数据库里 user 表密码字段,如果是$2a$开头的 BCrypt,前端登录传到后端时必须经过同样加密再比对;如果源码里根本没加密,直接用明文比对即可。答辩前把这条链路自己走一遍,比现场翻代码更稳妥。
5. 答辩前的并发验证与缓存边界说明
5.1 用 JMeter 给下单做 50 并发冒烟
不用搭完整的压测平台,JMeter 一个命令行就够了。先在 GUI 里录制或手写一个下单接口的测试计划,保存为 order-test.jmx,然后无界面运行:
jmeter -n -t order-test.jmx -l result.jtl -e -o report-n无界面模式,-t指定测试计划,-l输出结果日志,-e -o生成 HTML 报告。报告打开后重点看三件事:错误率是否为 0、事务平均响应时间、订单总数量与数据库中订单数量是否一致。
更直观的验证方法是选一个库存 100 的菜品,用 50 个并发线程同时下单,每单买 1 份。跑完之后分别查订单表和库存表,订单数加库存数必须等于 100。如果出现订单总数少于 50 而库存也少了,说明有事务没回滚干净;如果库存变成负数,说明扣库存的 SQL 没有加stock >= #{count}条件。把压测前后的数字放进课程设计文档,整个事务正确性就不需要用文字解释了。
并发测试如果冒出 Deadlock 错误,常见原因不是扣库存语句本身,而是两个订单里包含多个菜品,明细插入顺序不一致导致死锁。解法是在 service 里先把 items 按 dishId 排序再处理,顺序一致后死锁概率会明显下降,这也是面试官很爱追问的细节。
5.2 菜品缓存读多写少的边界
菜品列表是典型的读多写少场景,适合加一层本地 Redis 缓存:
public Dish getDishById(Long dishId) { String key = "dish:" + dishId; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, Dish.class); } Dish dish = dishMapper.selectById(dishId); if (dish != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(dish), 30, TimeUnit.MINUTES); } return dish; }键设计成dish:菜品ID,命中缓存直接返回,没命中则查库并写回,过期时间 30 分钟。菜品价格或库存更新时,删除对应 key 让缓存重建。需要说清楚的是边界:这类缓存只解决热菜品的重复查询压力,不解决库存一致性,库存还是以数据库为准。这样回答有一个好处,面试官追问「缓存和数据库不一致怎么办」时,可以直接说「本项目里下单扣库存绕过缓存,脏缓存最多影响菜品展示,30 秒内过期后自动修正」。
针对「为什么不用消息队列」这类问题,也是同样的思路:单实例 50 并发下单,本地事务完全够用。引入消息队列意味着要处理消费者、持久化、重试三个新问题,属于典型过度设计。在答辩时把边界讲清楚,说明当前量级下的事务方案足够,后续订单量上来再引入独立消息中间件并保留拆分扩展点,这句话比堆一堆中间件名字更有说服力。最后把压测订单数、剩余库存、失败原因日志放在同一张截图里,贴进课程设计说明文档,比任何文字描述都直观。
本文还有配套的精品资源,点击获取