都说宁可项目老一点也别选那种一上来就整高并发分布式的大而全题目。我前后帮人看过不少毕设项目也带过几个新手团队做课程设计Spring Boot校园外卖服务系统这套题属于典型的“看着不起眼、做起来真能学到东西”的题目。它业务链路完整、角色分明既有常规增删改查也有订单状态机、库存并发、权限控制这些稍微有点深度的地方。这篇就围绕这套系统把我实际搭建和调试过程中的设计思路、核心实现、踩坑记录完整写出来给正在选型或者已经开工的同学做个参考。1. 项目整体设计与思路拆解1.1 为什么校园外卖是Spring Boot练手的绝佳场景很多人在毕设选题时会纠结要么选图书管理这种纯增删改查要么选电商秒杀这种动不动就谈分布式、消息队列的。图书管理做完感觉什么都没学到电商秒杀又容易被高并发带偏最后整个项目变成堆积木。校园外卖恰好卡在中间——业务规模不大但流程完整度非常高且天然具备多角色协作的复杂度。从用户侧看学生需要在系统里浏览商家、加购商品、下单支付、查看订单状态、申请退款商家侧需要管理商品上下架、处理接单、修改配送状态骑手侧要完成抢单、取餐、送达确认。三套角色逻辑互相咬合背后还牵扯订单状态流转和库存扣减这就让系统有了真实业务系统的骨架感而不是单纯的“写接口”。选这套题的另一个现实好处是Spring Boot相关生态知识点能全部覆盖到位Spring MVC的请求处理、Spring Data JPA或MyBatis的持久化操作、Spring Security或Sa-Token做权限控制、Redis做缓存与分布式锁、RabbitMQ做订单超时消息推送还可以挂上WebSocket做订单实时通知。一套项目下来Spring Boot的核心用法基本都能摸到后续面试也能拿出具体的业务场景来聊而不是只背八股文。1.2 技术选型的关键考量与避坑思路在技术栈选择上我的建议是“主流为主、适当延伸”。这既是出于稳定性的考虑也因为一旦中途卡壳社区能搜到的资料更多。以下是我实际验证过的一套组合JDK版本与Spring Boot版本如果你不需要折腾JDK 17的新特性老老实实用JDK 8 Spring Boot 2.7.x组合。Spring Boot 2.7还是Spring官方维护的最后一个大版本分支兼容性最好。如果非要用Spring Boot 3.xJDK至少17起步但MyBatis-Plus、某些第三方starter可能还在适配期踩坑成本偏高。持久层框架推荐MyBatis-Plus。校园外卖这类系统里查询条件复杂联表查询也不少MyBatis-Plus的分页插件、条件构造器能省掉大量重复的XML代码也方便做逻辑删除。数据库MySQL 8.0即可。注意字符集统一用utf8mb4排序规则建议utf8mb4_general_ci避免后面中文排序出幺蛾子。缓存与分布式锁Redis在校园外卖系统里的价值很大。首页商家列表的缓存、购物车临时数据、订单超时标记都能用Redis承载并发抢单场景里也能用Redisson或Lettuce实现分布式锁。权限框架如果不想在用户和商家角色切换时浪费太多时间可以优先考虑Sa-Token。它的登录认证、权限校验、踢人下线、多端登录隔离做得非常顺手比Spring Security上手门槛低很多特别适合做多角色系统。实时通知校内订单时效性要求高商家端和骑手端需要实时感知新订单。用WebSocket STOMP协议就能实现简单的消息推送不需要上Netty那套重方案。这里要单独提醒一句版本不要盲目追新。我在2020年左右接手过一个早期Gradle构建的Spring Boot项目那套配置文件写法跟现在Maven项目的约定差别极大迁移时浪费了不少时间。你选型时优先考虑“资料多不多、自己是否熟悉、能不能跑通”而不是“版本是不是最新”。2. 领域模型设计与数据库规划2.1 四类角色权限模型如何设计校园外卖系统至少要支撑四类角色普通用户学生、商家、骑手、平台管理员。权限设计上不建议搞太复杂的RBAC表结构但也不能不做权限控制否则后续会非常被动。最实用的做法是基于角色的权限标识解析。用户表里存一个role_type字段1-用户2-商家3-骑手4-管理员登录成功后把角色信息写进Token和Redis会话里。在接口层面自定义一个RequireRole注解配合HandlerInterceptor拦截请求时读取当前登录人的角色字段做匹配不匹配直接返回403。这样做的好处是代码直观也足够应对毕设或中小型校园系统的权限需求。如果你还想增加“管理后台操作日志”这类功能可以单独建一张操作日志表由拦截器在请求结束后异步记录请求路径、参数、操作用户ID。但要注意切面记录操作日志时不要把密码、Token等敏感信息打印出来这点我在第4章还会细说。2.2 核心业务表结构设计与状态字段说明数据库设计是这类系统里最见基本功的地方。下面这几张表是必须的字段设计也是我反复调整后验证过的版本表名核心字段设计要点userid, username, password, phone, avatar, role_type, status, create_time密码字段用BCrypt加密存储不要明文保存shopid, user_id, shop_name, shop_logo, notice, business_status, delivery_fee, starting_price通过user_id与商家用户关联营业状态单独维护productid, shop_id, product_name, image, price, stock, status, sort必须包含库存字段stock后面并发扣减要用ordersid, order_no, user_id, shop_id, rider_id, total_price, pay_status, order_status, address, create_timeorder_status用整数定义状态流转注释写清楚每个值的含义order_detailid, order_id, product_id, product_name, product_image, price, quantity冗余商品名称和图片是为了防止商品修改后历史订单展示错乱riderid, user_id, real_name, phone, online_status, current_order_count骑手在线状态和当前接单量是抢单分配的重要依据cart_itemid, user_id, shop_id, product_id, quantity, selected, create_time购物车可以不入库用Redis存更舒服但入库也有好处后面展开聊订单状态字段是整个系统里最容易出错的设计点。我建议直接定义一份状态枚举常量比如0-待支付、1-待接单、2-待取餐、3-配送中、4-已完成、5-已取消、6-退款中、7-已退款。所有状态转换集中在Service层做校验不允许调用方随意修改订单状态。最典型的状态流转路径是下单 → 待支付 → 支付成功 → 待接单 → 商家接单 → 待取餐 → 骑手取餐 → 配送中 → 送达完成。每个流转节点都应该校验前置状态比如“待取餐”只能从“待接单”流转过来跳过校验会导致数据问题。这里强烈建议在订单表增加一个status_history字段或独立流水表把每次状态变更记录下来后面排查问题会非常方便。2.3 购物车入库还是Redis缓存这个决策不少新手会纠结。我的实操建议是以Redis为主、MySQL做兜底。购物车是高频读写数据用Redis的Hash结构存用户ID对应的商品与数量读取时一次取出性能非常稳。但Redis数据是纯内存态服务重启会丢所以可以在用户下单成功、购物车结算清空时同步更新MySQL表或者做一个定时任务每小时把Redis里的购物车数据落库备份。如果你不想引入Redis那么购物车表入库也完全可行只是接口响应会慢一些而且购物车商品数量变更频繁会让数据库产生较多垃圾写操作。考虑到校园外卖的业务体量不大纯入库方案其实也能跑但既然用了Spring BootRedis迟早要学顺手用上反而是加分项。3. 核心功能链路与关键实现3.1 登录鉴权与多角色会话管理登录逻辑看起来只是“查表比对密码”但里面有一个关键点密码加密和Token续期策略。密码存储推荐使用BCryptPasswordEncoderSpring Security里自带Sa-Token里也有对应的集成工具。BCrypt每次加密结果不同但校验时会自动解析盐值安全性远好于MD5加盐拼接的老方案。Token这一层我的做法是JWT生成短时Token Redis维护刷新Token用户登录成功后签发一个有效期2小时的Access Token里面包含用户ID、角色标识、用户名。同时在Redis里写入一个refresh_token:{userId}的Key有效期7天。当Access Token过期时客户端拿着Refresh Token调刷新接口重新签发Access Token。用户退出登录时删除Redis中的Refresh Token并加入一个黑名单Key标记JWT失效。很多毕设项目里JWT过期后就直接让用户重新登录这在移动端体验上很寸。校园外卖用户下单时如果刚好Token过期会话一断订单就丢失了体验很差。做刷新机制并没有多复杂但能给答辩和项目评分加分不少。3.2 下单链路与订单状态机设计下单是整个系统最核心的业务链路也是并发问题的高发区。先看流程用户提交购物车商品和收货地址。系统校验商家营业状态、商品库存、价格是否变化。生成订单号和订单明细状态置为待支付。扣减库存这一步关键词是“并发安全”后面详细说。调用支付模拟接口完成支付回调后将订单状态置为待接单。通过WebSocket通知对应商家有新订单。订单号的生成不能简单用自增ID建议用时间戳随机数用户ID尾部拼接比如20250614102033001 用户ID后四位 4位随机数这样既能保证全局唯一也方便用户查单。这里还要重点处理“超时未支付自动关闭订单”。方案有两种一种是定时任务扫描Spring Boot里用Scheduled注解每分钟扫一次把创建超过15分钟还处于待支付的订单取消掉同时回补库存另一种是RabbitMQ延迟队列下单时发送一条延迟消息到期消费时再判断订单状态。校园外卖系统这种体量定时任务扫描就够用实现简单而且不容易出幺蛾子。用MQ延迟队列虽然技术听起来更高级但消息丢失、重复消费这些处理起来比较耗精力。3.3 商家接单与骑手抢单的并发控制商家端和骑手端的核心操作都存在“同一条数据多人操作”的问题。商家接单本质是“一个订单只能被一个商家操作”这个通过数据库的行锁状态更新就能解决。比如执行UPDATE orders SET order_status 2, accept_time NOW() WHERE id ? AND order_status 1这条SQL只有影响行数等于1时才算成功天然防止了重复接单。骑手抢单的并发度更高因为同一时间可能多个骑手在刷同一个新订单。我用的处理方式是Redis分布式锁加乐观锁双保险新订单推送到“抢单池”骑手端拉取订单列表时先从Redis里用SET NX EX尝试锁住订单ID。抢锁成功后再执行上面那种带状态条件的更新SQL确保只有一人能把状态改成“配送中”。锁设置过期时间10秒防止骑手端突然崩溃造成死锁。实际测试下来这套方案在单机部署下并发完全没问题即便后续扩展成Nginx负载均衡的多实例部署Redis锁也能继续支撑。不要一上来就依赖数据库悲观锁SELECT ... FOR UPDATE锁粒度太大接口响应时间会很感人。3.4 异常统一处理与全局过滤器Spring Boot项目里异常处理建议做两层业务异常和系统异常分开处理。业务异常对应参数校验失败、库存不足、订单状态不允许操作这类可控错误统一返回code400之类的业务码系统异常统一返回code500但要注意不要把异常堆栈信息直接返回给前端只记录在日志服务端。我习惯用RestControllerAdvice配合自定义异常类实现这个方案比在每个Controller里try-catch优雅得多。还有个细节文件上传接口如果绑定了MultipartFile参数前端传文件过大时会在进入Controller之前就抛异常这时候也要在全局异常处理里兜住否则前端拿到的是一大段Tomcat默认的错误页面。另外热搜里有一个问题很典型“Spring Boot项目全局过滤器处理上传PDF文件时的XSS攻击”。文件上传场景的XSS风险通常是文件名和文件内容里的恶意脚本比如上传一个包含script标签的PDF或SVG文件。建议在全局过滤器中统一拦截对文件名做白名单校验只允许特定图片扩展名对临时文件做大小限制和类型探测不只依赖扩展名判断。文件存储后不要用用户原来的文件名拼路径而是生成随机UUID作为路径名。4. 常见问题与排查技巧实录4.1 并发扣库存为什么还是超卖了这个问题我在带别人做类似系统时几乎必见。一开始很多人的代码是这样写的Product product productMapper.selectById(productId); if (product.getStock() 0) { product.setStock(product.getStock() - 1); productMapper.updateById(product); }这段代码看起来逻辑没问题但在并发高一点的环境里就会超卖。原因很直接两个请求同时读到了库存1都通过了判断先后执行减1库存变成0但订单却生成了两个。解决办法最推荐的是SQL层原子扣减UPDATE product SET stock stock - 1 WHERE id ? AND stock 0同样的逻辑放在MyBatis-Plus里可以写成update条件构造器影响行数为0时说明库存不足或并发冲突直接抛业务异常。这种方式不需要额外加锁性能也非常好是这一类系统里最稳的扣库存方案。4.2 商品图片能上传但前端访问404开发阶段常见的问题图片上传成功数据库也存了路径但浏览器访问图片地址返回404。原因基本都是静态资源映射没配置。Spring Boot默认只把classpath:/static/作为静态资源目录你上传到本机磁盘的/uploadFiles目录并不在映射范围内。解决方式是在配置类里增加资源映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceHandler(file: uploadDir /); }上传文件存本地磁盘只是简便方案若后续想放到统一存储服务器上可以考虑MinIO。它跟Spring Boot集成很顺只需要引入starter、配置endpoint和accessKey调用客户端API就能上传也天然解决了多实例下文件分发的问题。4.3 WebSocket在线通知经常断连商家端和骑手端需要实时收单WebSocket连接不稳定是很常见的问题。造成断连的原因通常有三个服务器或Nginx有超时时间空闲连接被自动断开。前端没有做心跳检测连接假死状态服务端感知不到。多实例部署时WebSocket连接只落在某一台服务器上请求转发到另一台实例时数据对不上。对应解决方案也清晰前端每隔30秒发送心跳消息服务端收到后响应Nginx配置proxy_read_timeout适当调大后端使用Spring的SimpMessagingTemplate向指定用户推送消息而不是只存SessionMap本地会话。4.4 跨域问题与接口联调的细节坑前端Vue项目在8080端口后端在8081端口联调时跨域问题总会出现。配置上可以在Spring Boot里加一个CorsFilter允许指定来源和请求头。但要注意如果后端启用了登录拦截器OPTIONS预检请求一定要放行否则前端请求会被拦截器挡住。许多同学排查了很长时间才发现问题出在拦截器直接把OPTIONS请求给拦截了。另外一个容易忽略的细节是日期格式。MySQL的datetime字段通过Jackson返回给前端时默认会带时区或时间戳格式前端拿到后日期显示错乱。统一在application.yml里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT84.5 数据库驱动参数与连接池配置热搜里有个问题问“Spring Boot数据库驱动 ?参数”这其实说的是JDBC URL里带参数的情况比如jdbc:mysql://localhost:3306/campus_order?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue其中serverTimezoneAsia/Shanghai解决MySQL 8版本时区报错问题allowPublicKeyRetrievaltrue解决本地MySQL 8缓存SHA2加密插件连接失败的问题。这两个参数是最常踩的坑。连接池方面Spring Boot 2.x默认内置HikariCP配置几个关键参数就可以了spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000maximum-pool-size不要设得太大MySQL默认连接数有限设100反而拖垮数据库。5. 项目管理与部署运维经验5.1 多环境配置应该怎么划分项目开发到中后期配置管理最容易乱。我的习惯是把公共配置写在application.yml然后把不同环境的差异拆到application-dev.yml和application-prod.yml里。启动时通过spring.profiles.activedev或者打包时通过启动参数指定环境。# application-dev.yml server: port: 8081 spring: datasource: url: jdbc:mysql://localhost:3306/campus_order?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456生产环境的数据库密码、Redis密码建议放到环境变量或JVM启动参数里不要硬编码进配置文件避免代码泄露后数据也一起泄露。5.2 打包部署与Docker容器化的步骤项目打包前先执行一次mvn clean package确认跳过测试避免测试用例阻塞打包mvn clean package -DskipTests打包成功后target目录下会生成一个可执行Jar包。单机部署最简单的方式是用DockerFROM openjdk:8-jdk-alpine VOLUME /tmp COPY campus-order-0.0.1-SNAPSHOT.jar app.jar ENV TZAsia/Shanghai ENTRYPOINT [java,-Djava.security.egdfile:/dev/./urandom,-jar,/app.jar]然后通过docker build -t campus-order:latest .构建镜像用docker run -d -p 8081:8081 --name campus-order campus-order:latest启动。注意JVM参数和时区设置时间不对会导致订单超时计算全部错乱。我记得之前部署过一个Spring Boot项目启动后接口访问正常但Redis一直报异常排查了半天才发现是Docker容器里的Redisson默认使用了本机回环地址没走配置好的内网IP。容器化部署时连接外部中间件的IP一律用配置文件显式指定不要依赖默认值。5.3 上线后的持续优化方向系统跑起来之后还有几个地方值得持续优化大对象查询用分页插件限制高频热数据比如首页商家信息加Redis缓存并设置合理的过期时间日志采集分类输出业务日志和系统日志分开文件保存定期备份MySQL数据。这些都是老生常谈但每一条都能在实际项目中看到效果。6. 写在最后的几点个人经验这套校园外卖服务系统做到最后我最大的体会不是某个功能有多难而是Spring Boot把很多基础工作都做完了真正考验人的地方在于梳理业务状态和并发边界。比如说订单状态机你在纸上画清楚之后代码写起来非常顺流水表留好了后面查任何一笔异常订单都能快速定位。库存扣减用原子更新加状态条件整个系统最复杂的并发点也就稳稳扛住了。如果你正在做类似的Spring Boot毕业设计或课程项目我建议不要急着堆功能先把这个核心闭环跑通登录注册 → 商家浏览 → 商品加购 → 生成订单 → 支付回调 → 商家接单 → 骑手配送 → 完成评价。一条链路走通之后其他功能比如优惠券、积分、退款都是在这个骨架上锦上添花。项目做扎实了答辩时就能从容地展示完整业务链路也能答上来状态流转和并发扣减这两类高频问题这比单纯堆一个好看的前端页面有用得多。