ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot外卖点餐系统实战:角色权限、订单状态机与部署排错全解析

Spring Boot外卖点餐系统实战:角色权限、订单状态机与部署排错全解析 简介这是一份面向毕业设计场景的Spring Boot外卖点餐系统完整项目适合计算机相关专业学生用于课程设计、毕业设计或项目答辩参考。后台采用Spring Boot框架前端页面使用Vue数据库为MySQL运行环境JDK1.8并配套微信小程序端开发工具支持Eclipse、MyEclipse、STS、IDEA等常用IDE。项目围绕管理员、商家、用户、骑手四类角色设计涵盖首页、个人中心、用户管理、商家管理、菜品分类管理、骑手管理、系统管理、菜品管理、订单管理、配送单管理、商品评价管理、我的收藏管理等核心功能模块层次划分清晰便于二次开发。压缩包大小约63.65MB收录源码、数据库脚本、论文文档、答辩PPT、环境工具包及同框架项目的安装教程从环境配置到部署运行均有详细说明可帮助快速搭建演示环境。目前已有62人学习下载适合需要完整参考实现、毕业设计文档及答辩材料的同学直接使用。1. Spring Boot外卖点餐系统的角色权限与模块边界接手这份毕业设计源码时第一眼看上去是十几个Controller和几十张表但真正决定你能不能在答辩现场把项目讲圆的是角色权限边界是否清晰。这个项目有管理员、商家、用户、骑手四类身份对应前端Vue管理后台和微信小程序两个终端数据全部落在MySQL里。相比网上那些只有增删改查的管理系统这套源码真正值得拆的地方在于订单、配送单、评价这三条线是跨角色联动的而不是各写各的CRUD。很多人拿到源码第一反应是启动、点页面结果被Spring Security拦截器挡在登录页外面就开始怀疑代码有问题。其实问题出在没理解这套系统的访问模型——不同角色访问同一张订单表看到的字段和操作按钮是完全不同的。这篇文章会从数据库设计、订单状态机、前端接口对接、部署排错四个维度拆开讲最后附一份答辩时高频问题的验证清单。无论你是准备直接跑起来演示还是打算二次开发做功能扩展都能找到对应落点。2. 环境选型与数据库初始化JDK 1.8、MySQL 脚本与IDEA导入2.1 为什么这套项目锁死 JDK 1.8项目描述里点名要求 jdk1.8这不是随便写的。Spring Boot 2.x 系列默认基于 JDK 8 编译如果你用 JDK 17 或 21 去跑常见的坑是java.lang.IllegalAccessError或者 Lombok 版本不兼容导致 getter/setter 找不到。老项目用 Lombok 的话IDEA 里必须装对应版本的 Lombok 插件否则编译期就报符号找不到。我一般拿到源码先做三件事确认pom.xml里的 Spring Boot 版本这套源码大概率是 2.3.x 或 2.4.x确认本机JAVA_HOME指向 JDK 8然后改 IDEA 的 Project Structure——把 Project SDK 和 Modules 的 Language Level 全部切成 8。开发工具用 Ecplise、MyEcplise、STS、IDEA 都可以原理一样但 IDEA 对 Maven 多模块支持更稳推荐直接用它。环境变量配置好之后用命令行验证java -version mvn -v输出里必须看到java version 1.8.0_xxx和Apache Maven 3.6.x这样的字样。Maven 版本太新比如 3.9有时会跟旧版 Spring Boot 插件冲突报Unable to find main class这时候把 Maven 降到 3.6.3 基本能解决。注意不要把 JDK 切到 11 或更高Spring Boot 2.x 在 JDK 11 下虽然能跑但 CGLIB 代理和部分反射调用在跨版本时会出现诡异行为没必要给自己加负担。2.2 导入源码与数据库脚本执行顺序先把源码包解压数据库脚本通常在sql/或db/目录下文件名类似takeout.sql。用 Navicat 或命令行执行前先手动创建数据库字符集选utf8mb4排序规则选utf8mb4_general_ci。原因很简单菜品名称、评价内容里如果有表情符号utf8存不进去utf8mb4才是完整的 UTF-8。CREATE DATABASE IF NOT EXISTS takeout DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE takeout; SOURCE /你的路径/takeout.sql;执行完看表数量。这套系统按角色拆模块核心表包括表名核心字段归属角色sys_userusername, password, role_type管理员merchantmerchant_name, status, user_id商家food_infofood_name, price, category_id, merchant_id商家food_categorycategory_name, merchant_id商家ordersorder_no, user_id, merchant_id, rider_id, amount, status, create_time用户/商家/骑手delivery_orderorder_id, rider_id, status, pickup_time骑手food_commentorder_id, user_id, content, rating用户food_collectuser_id, food_id, create_time用户注意orders表不能用order做表名order是 MySQL 的保留关键字直接建会报语法错误。执行脚本时如果报错优先看是不是这种保留字问题。另外password字段如果存的是明文说明这是教学简化版本答辩时你最好自己补一段 BCrypt 加密逻辑。2.3 application.yml 配置的四个关键参数导入 IDEA 后打开src/main/resources/application.yml这个文件决定了项目能不能连上数据库并跑起来。常见配置长这样server: port: 8080 servlet: context-path: / spring: datasource: url: jdbc:mysql://localhost:3306/takeout?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none show-sql: true mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true第一serverTimezoneAsia/Shanghai不能省MySQL 8.x 默认时区跟中国差 8 小时不加会出现数据库时间比系统时间早 8 小时的问题。第二useSSLfalse是避免 MySQL 8 的 SSL 握手警告。第三如果项目用的是 JPAddl-auto必须设成none否则启动时会按实体类自动改表结构把你自己导入的测试数据清掉。第四map-underscore-to-camel-case开启后数据库字段create_time才能自动映射到实体的createTime不开的话查询结果全是 null。如果你的数据库密码不是123456改掉即可。跑TakeOutApplication.java的 main 方法看到Started TakeOutApplication in x.xxx seconds且没有红色异常说明后端已经起来了。这时候浏览器访问http://localhost:8080如果配置了静态首页会看到登录页否则是 404——这是正常的因为后台页面在 Vue 项目里需要单独启动。3. 订单与配送单状态机核心业务逻辑的实现与并发陷阱3.1 订单状态的流转设计外卖点餐系统最核心的业务不是菜品 CRUD而是订单状态如何在不同角色之间流转。这套源码里订单状态一般定义为整型字段status我用过的状态枚举大致如下状态值含义可执行操作目标状态0待支付用户支付11待接单商家接单22配送中骑手取餐33已完成用户评价44已评价无--1已取消无-这里有个容易被忽略的设计点为什么「已完成」和「已评价」要分成两个状态因为评价是异步行为用户可能隔天再评如果合并成一个状态就无法区分「订单完成但没评价」和「评价完」两种情况。答辩时如果你能主动讲出这一层比背概念拿分多。3.2 状态流转的代码实现状态变更不能写散在 Controller 里而是要收敛到一个 Service 方法。常见做法是这样Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Override Transactional(rollbackFor Exception.class) public boolean updateOrderStatus(Long orderId, Integer fromStatus, Integer toStatus, Long operatorId) { Order order orderMapper.selectById(orderId); if (order null) { throw new BusinessException(订单不存在); } if (!fromStatus.equals(order.getStatus())) { throw new BusinessException(订单状态已变更请刷新后重试); } order.setStatus(toStatus); order.setUpdateTime(new Date()); int rows orderMapper.updateById(order); return rows 0; } }调用时比如商家接单操作传参是updateOrderStatus(orderId, 1, 2, merchantId)。这个方法有两个关键设计。第一fromStatus和toStatus都传参避免每个接口里自己写 if-else 判断——把状态机规则放在调用方Service 只负责一致性校验。第二Transactional保证状态变更和后续的配送单生成操作在同一个事务里要么都成功要么都回滚。要特别强调的是updateById这一步的并发问题。在秒杀或高峰订单场景下两个请求同时读到status1同时执行updateById后写的会把先写的覆盖掉。解决思路是加乐观锁版本号SQL 变成UPDATE orders SET status #{toStatus}, version version 1 WHERE id #{orderId} AND status #{fromStatus} AND version #{version}MyBatis-Plus 里给实体加Version注解拦截器配置OptimisticLockerInnerInterceptor就能自动处理这个逻辑。源码里如果没写建议你自己补上面试时这就是加分项。3.3 配送单如何与订单联动仔细看项目功能的描述配送单管理横跨了管理员、商家、用户、骑手四个角色。这个设计说明配送单并不是独立创建的而是在商家接单后自动生成。常见做法是在订单状态从待接单变成配送中时同时插入一条配送记录Transactional(rollbackFor Exception.class) public void assignDelivery(Order order, Long riderId) { DeliveryOrder delivery new DeliveryOrder(); delivery.setOrderId(order.getId()); delivery.setRiderId(riderId); delivery.setStatus(0); // 待取餐 delivery.setPickupTime(new Date()); deliveryOrderMapper.insert(delivery); order.setStatus(3); orderMapper.updateById(order); }配送单的状态通常分三段0 待取餐、1 配送中、2 已送达。骑手端小程序的职责就是扫描或者点击确认把这个状态往后推。注意assignDelivery方法上有Transactional这里订单状态和配送单状态的更新必须原子化否则会出现配送单已创建但订单状态没变的数据不一致。3.4 评价与订单的约束商品评价管理里最容易忽略的业务规则是一条订单只能评价一次。源码里如果没有唯一索引你就得自己在代码层加校验。最稳妥的方式是在数据库层面加唯一约束而不是靠代码判断——并发时代码判断会同时通过唯一索引会直接拒绝第二条ALTER TABLE food_comment ADD UNIQUE KEY uk_order_food (order_id, food_id);如果评价是整单评价而不是按商品评价那就只对order_id建唯一索引。加了索引之后评价接口返回Duplicate entry异常时代码里捕获并转成「您已评价过该订单」的提示就是一次完整的异常处理实践写进答辩 PPT 里会比介绍某个 CRUD 接口有说服力得多。4. Vue 管理后台与微信小程序的接口对接模式4.1 后台前端的登录态管理这套系统的后台页面是 Vue前端工程一般在源码包的vue/或web/目录下需要单独npm install然后npm run dev启动。它跟后端交互的核心问题是每次请求怎么让后端认出你是谁。常见做法是用 Token登录成功后后端返回一串随机字符串前端存到localStorage后续请求都带上。用 Axios 封装时拦截器统一处理// http.js import axios from axios const service axios.create({ baseURL: http://localhost:8080/api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }, error { return Promise.reject(error) }) service.interceptors.response.use(response { const res response.data if (res.code 401) { localStorage.removeItem(token) window.location.href /login } return res }, error { return Promise.reject(error) }) export default servicebaseURL里的/api前缀要求后端所有接口统一以/api开头。如果后端没有加这个前缀前端请求会 404这时要么改后端的server.servlet.context-path/api要么改前端的baseURL去掉/api。参数说明就两个点timeout设 10 秒是防止接口卡死影响体验code 401是统一处理登录过期避免每个页面都写一遍跳转逻辑。4.2 后端 Token 拦截器的对应实现前端带Authorization头后端就要有拦截器解析。Spring Boot 里用 HandlerInterceptor 实现public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); // 从 Redis 或 Token 表中校验 Long userId tokenService.getUserId(token); if (userId ! null) { request.setAttribute(userId, userId); return true; } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }注册拦截器时注意排除登录接口和静态资源Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register, /api/food/list); }这套源码如果默认开着拦截器而你直接访问接口拿不到数据多半是没配excludePathPatterns把登录和菜品浏览也拦截了。另外如果你的测试方式是直接在浏览器地址栏敲接口地址浏览器不会自动带Authorization头返回 401 是预期行为要用 Postman 或 Apifox 加 Header 测试。4.3 小程序端的请求封装差异微信小程序不能用axios它原生提供wx.request。封装思路类似但要注意两个差异点第一小程序的storageAPI 是同步的跟网页的localStorage用法不同第二小程序里没有window对象跳转登录页要用wx.navigateTo。常见的封装形式// utils/request.js const BASE_URL http://localhost:8080/api function request(url, method, data) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL url, method: method || GET, data: data || {}, header: { Content-Type: application/json, Authorization: Bearer wx.getStorageSync(token) }, success: (res) { if (res.data.code 200) { resolve(res.data.data) } else if (res.data.code 401) { wx.navigateTo({ url: /pages/login/index }) } else { reject(res.data) } }, fail: (err) reject(err) }) }) } module.exports { request }重点是wx.getStorageSync(token)每次请求都从本地取——如果登录后没有setStorageSync这里永远是空字符串后端就会一直返回 401。调试小程序时打开微信开发者工具的「Network」面板看 Authorization 头是否真的带上了比单纯看 console 里的报错更快定位问题。4.4 跨域问题的解决Vue 后台跑在http://localhost:9528后端跑在http://localhost:8080端口不同必然产生跨域。这套源码如果配了跨域一般是写了一个CorsConfig类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:9528) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }allowCredentials(true)表示允许携带 CookiemaxAge(3600)是一小时内浏览器不用再发预检请求。如果把allowedOrigins配成*同时allowCredentials(true)浏览器会直接拒绝这是 Spring MVC 的硬性约束。写配置时注意这两点就不会踩坑。另外如果你把前端页面直接放进了后端src/main/resources/static目录那就不存在跨域问题了因为前后端同源。5. 打包部署排错与答辩验证清单5.1 从 IDEA 直接到生产环境的启动方式开发环境用 IDEA 跑没问题但答辩现场如果电脑配置一般开 IDEA 反而卡顿。更稳妥的方式是打包成 jar 直接跑这样能少一层 IDE 的资源占用mvn clean package -DskipTests java -jar target/takeout-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod这一步推荐用 Maven 插件里的spring-boot-maven-plugin它会生成可执行 jar。注意-DskipTests是跳过测试不是跳过编译——如果你的代码里有测试类且 JUnit 版本不匹配不跳过就会在打包时直接报错。第一次打包如果报Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin把skipTests加上基本就好了。启动之后用curl验证接口连通性curl -X POST http://localhost:8080/api/user/login \ -H Content-Type: application/json \ -d {username:admin,password:admin123}返回 JSON 里带 token 字段说明启动成功。这一步要在答辩前一晚做不要现场才试。如果 curl 返回 404先看context-path是不是配置了/api路径匹配不上是最高频的问题。5.2 启动失败的三个高频错误第一个是Access denied for user rootlocalhost这是数据库用户名或密码错误。注意 MySQL 8 默认用caching_sha2_password认证老版本 JDBC 驱动不认识会报Public Key Retrieval is not allowed解决方法是驱动换成mysql-connector-java8.0.x或者url里加allowPublicKeyRetrievaltrue。第二个是Table takeout.xxx doesnt exist这说明拒绝访问的数据库名对不上application.yml里配置的url和实际数据库名不一致。还有一个隐形坑你自己手动创建数据库时用了大写字母Linux 下 MySQL 表名区分大小写就会一直报找不到表。解决方式是建库时统一用小写。第三个是端口被占用Port 8080 was already in use。这种情况先查是谁占用了端口netstat -ano | findstr :8080拿到 PID 之后在任务管理器里结束掉或者改server.port用 8081 这种不常用端口。答辩时最好不要现场处理这类问题——提前录一段启动成功的视频放在 PPT 的最后一页真出状况了播视频也比现场盯着控制台强。5.3 答辩前要会讲的六张表这套源码配套的论文里一定有数据库设计章节但论文写的是「最终状态」答辩老师更关心的是「为什么这样设计」。我建议把这几张表的数据流捋顺每个字段都能说清楚来源表名答辩要讲清楚的点orders状态字段为什么要用 int 而不是 varchardelivery_order跟 orders 是一对一还是一对多为什么food_comment唯一约束的业务意义food_collect收藏是冗余表还是逻辑删除sys_user不同角色是同一张表还是分表food_info菜品下架是 status 置 0 还是物理删除举例来说如果被问到「菜品下架你怎么设计」你要能答出来不是 delete而是status字段从 1 改成 0这样历史订单里的菜品信息还能关联查询到。如果直接删掉菜品记录orders表关联food_id会变成空指针这就是物理删除的隐患。另一个容易被追问的知识点订单超时未支付怎么处理。源码里如果没实现你可以用 Spring 的Scheduled做一个定时扫描Scheduled(fixedDelay 60000) public void cancelExpiredOrders() { Date expireTime new Date(System.currentTimeMillis() - 15 * 60 * 1000); ListOrder expiredOrders orderMapper.selectExpiredOrders(expireTime); for (Order order : expiredOrders) { order.setStatus(-1); orderMapper.updateById(order); } }fixedDelay 60000是每分钟扫一次expireTime算的是 15 分钟前——也就是说订单超过 15 分钟没支付就自动取消。定时扫描的方案虽然简单但存在延迟最多延迟 1 分钟答辩时你可以说这是资源受限场景下的折中方案生产环境更优解是延迟队列或 RocketMQ 定时消息。主动抛出这个演进方向比被动等老师提问要好得多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进