
简介这是一套基于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 里就是一组相互关联的表。常见的表集合如下具体源码的表名可能带前缀字段也可能有差异但关系不变。表名常见命名关键字段作用userid, username, password, role用户、商家、管理员共用一张表用 role 区分shopid, user_id, name, status商家店铺一个商家对应一个店铺categoryid, shop_id, name, sort菜品分类dishid, shop_id, category_id, name, price, stock, status菜品与库存金额必须用 DECIMALcartid, user_id, dish_id, count购物车可用 Redis 替代addressid, user_id, phone, detail收货地址order_masterid, order_no, user_id, shop_id, total_amount, status订单主表一个订单一条order_detailid, 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;这段 SQL 里有几个容易被忽略的参数金额用 DECIMAL(10,2) 而不是 DOUBLE避免浮点误差order_no 用业务号并加唯一索引不要把数据库自增 ID 直接展示给用户status 用 TINYINT 加注释比字符串值更省空间也更好做索引。拿到源码里的 .sql 文件后命令行导入是最稳的方式mysql -uroot -p --default-character-setutf8mb4 waimai 外卖点餐系统.sql--default-character-setutf8mb4保证中文注释不乱码。导入前如果报 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 或 51已支付/待接单2 或 52商家已接单3 或 53配送中44已完成无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.ymljwt: 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, ListCartItem 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 iddeductStock 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 表完全没问题。想给项目加亮点的可以在购物车模块用 RedisAutowired 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:用户IDhash 的 field 用 dishIdvalue 用数量。读取购物车时一次性拿到所有菜品 ID再去数据库批量查菜品信息。注意 Redis 的 value 都是字符串数字类型要手动转换。对应的连接配置如下spring: data: redis: host: localhost port: 6379 password: database: 0这里有个版本坑Spring Boot 2.x 的配置前缀是spring.redisSpring 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 或 11Spring 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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue 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会提示加载失败serverTimezoneAsia/Shanghai解决数据库时间差 8 小时的问题allowPublicKeyRetrievaltrue是 MySQL 8 的 caching_sha2_password 认证插件经常报的 Public Key Retrieval 问题StdOutImpl把 SQL 打印到控制台排查问题比关掉日志方便很多演示时再换成org.apache.ibatis.logging.slf4j.Slf4jImpl。启动命令建议限制一下堆内存java -Xms256m -Xmx512m -jar 外卖点餐系统.jar --spring.profiles.activedev-Xms256m -Xmx512m给演示环境 512M 上限避免本地内存被撑爆--spring.profiles.activedev指定加载 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)允许带 CookiemaxAge(3600)让预检请求结果缓存一小时。浏览器在发 POST、PUT 这类非简单请求前会先发一个 OPTIONS 预检如果后端没有正确响应 CORS 头主请求根本不会发出。这个配置在 Spring Boot 3.x 里同样生效不需要额外依赖。4.3 启动与登录报错对照表下面这些报错出现的频率最高按表格核对能省下大量排查时间现象常见原因处理建议Invalid bound statement (not found)mapper.xml 没被扫描到检查 mapper-locations 是否为 classpath*:mapper/**/*.xmlAccess denied for user rootlocalhost数据库密码不符核对 yml 中密码注意特殊字符需要单引号包裹Public Key Retrieval is not allowedMySQL 8 认证插件问题URL 加 allowPublicKeyRetrievaltrueUnknown database waimai数据库没创建先执行 CREATE DATABASE 再导数据Table xxx doesnt exist表名大小写不一致检查 lower_case_table_names 配置Port 8080 was already in use端口被占用用java -jar xx.jar --server.port8081换端口时间差 8 小时时区参数缺失URL 加 serverTimezoneAsia/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 并发下单本地事务完全够用。引入消息队列意味着要处理消费者、持久化、重试三个新问题属于典型过度设计。在答辩时把边界讲清楚说明当前量级下的事务方案足够后续订单量上来再引入独立消息中间件并保留拆分扩展点这句话比堆一堆中间件名字更有说服力。最后把压测订单数、剩余库存、失败原因日志放在同一张截图里贴进课程设计说明文档比任何文字描述都直观。本文还有配套的精品资源点击获取