news 2026/9/26 2:36:22

Spring Boot校园外卖平台实战:订单状态机与Redis缓存设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot校园外卖平台实战:订单状态机与Redis缓存设计

1. 项目整体设计与技术选型思路

1.1 为什么选Spring Boot来做校园外卖平台

聊到用Spring Boot做校园外卖平台系统,很多人的第一反应是“这不就是个课程作业吗”。其实把场景限定在校园里,这个系统的复杂度比我最初预想的高不少。校园外卖和普通外卖最大的区别在于:用户密度高、配送范围集中、下单时间高度集中在饭点、支付和优惠券逻辑相对简单但订单量波动极大。这些特点决定了技术选型的方向——不需要分布式微服务那一套重武器,但必须在单体应用内把并发、缓存、状态机这些基本功打扎实。

Spring Boot在这个场景里几乎是唯一一个性价比拉满的选择。它自带Tomcat容器,打成一个jar包就能跑,对初学者友好;同时它的自动配置机制让我们不需要花大量时间写各种XML配置,能把精力集中在业务细节上。我见过一些同学拿SSM(Spring+SpringMVC+MyBatis)去写类似的系统,代码量差不多少一半,但踩的坑多一倍——光各种配置文件之间的版本兼容问题就能耗掉好几个晚上。Spring Boot的另一个优势是生态成熟,无论是MyBatis分页插件、Redis缓存还是JWT鉴权,都有大量的现成集成方案,这在时间宝贵的毕业设计或个人项目阶段非常关键。

再说说为什么不做成前后端分离。如果你只是做一个课设级别的系统,模板引擎(Thymeleaf)就够用了;但如果目标是“拿得出手”的个人项目,我强烈建议用Spring Boot + Vue的前后端分离架构。理由很简单:校园外卖的典型使用场景是手机端H5,接口化设计天然适配;而且面试时前后端分离的经历更能展示你的工程化意识。当然这也意味着你要额外处理跨域、Token鉴权、接口文档这些事,后面我会详细讲。

1.2 技术栈选型背后的取舍考量

具体到技术栈,我采用的是目前主流的组合:Spring Boot 2.7.x + MyBatis Plus + Redis + JWT + MySQL + Vue 3。这套组合的兼容性非常成熟,网上资料也多,出了坑很容易找到解决方案。

选择MyBatis Plus而不是原生MyBatis,是因为它内置的分页插件和条件构造器能大幅提升开发效率。做外卖平台必然涉及大量列表查询——比如按分类查菜品、按时间查订单、按模糊名查店铺——用手写SQL映射会很繁琐,而MyBatis Plus的LambdaQueryWrapper一行就能搞定。但有一点需要注意:MP的分页插件需要单独配置拦截器,否则分页不会生效,这个问题几乎每个入坑的人都会遇到一次。

Redis在这个项目里的角色很关键。我做设计了三个核心用途:缓存菜品分类和热门店铺、存储短信/图形验证码、维护购物车数据。前两个是典型的缓存加速场景,第三个其实是把Redis当成临时数据库用——购物车的读写频率高、有效期短,落到MySQL反而浪费。另外,订单号生成我用了Redis的自增ID加日期前缀的组合方式,避免数据库自增ID在并发下产生重复或泄露订单量。

JWT(JSON Web Token)用来做登录鉴权。用户、商家、骑手三类角色共用一个认证机制,但需要在校验时区分角色权限。这个设计看似简单,实际写代码时要处理Token过期续期、不同端(小程序端和后台管理端)的密钥隔离、TOKEN刷新策略等细节,稍不留神就会出一堆安全漏洞。

1.3 系统模块与功能边界划分

校园外卖平台的业务核心可以拆成三个端:用户端(微信H5或普通H5)、商家端(PC管理后台)、管理端(平台运营后台)。用户端负责注册登录、浏览店铺菜品、下单支付、订单跟踪、评价;商家端是商家管理自己的菜品、订单处理、营业状态切换;管理端是平台的最高权限,处理店铺审核、用户管理、骑手调度、数据统计。

模块拆分要遵循一个原则:高内聚,低耦合。比如“订单服务”只负责订单的创建、状态流转、取消,不关心支付具体怎么调用第三放接口;“支付服务”只对接支付回调,回调成功后通过MQ或定时任务通知订单服务更新状态。这样拆,哪怕最后没有用消息队列,直接同步调用,代码也清晰得多。

我见过不少人把商家、用户、骑手的所有功能全部塞进一个Controller里,一个类几百行,改一个功能要牵扯一片。正确做法是至少按角色拆Controller,或者更细一点按业务域拆分:AuthController(登录注册)、ShopController(店铺)、DishController(菜品)、CartController(购物车)、OrderController(订单)、AddressController(地址管理)等等。每个Controller只做参数校验、调用Service、返回结果这三件事。

2. 数据库设计与核心表结构

2.1 六张核心表的角色分配

数据库设计是整个系统中最不能糊弄的部分。很多初学者一上来就建表,想到什么加什么字段,结果后期要么缺字段反复改表结构,要么一个表存了太多无关信息。我的做法是遵循“第一范式起步,按业务域拆分”的思路,最终落地为六张核心业务表加若干辅助表。

用户表(user)存储用户基本信息,包括昵称、手机号、头像、密码(BCrypt加密存储)、学号(校园场景的独特字段)。这里有个容易忽略的点——手机号是用户登录的凭证之一,必须加唯一索引;但有些学校的用户可能没有绑定手机号、只支持学号登录,所以登录字段设计上要留出扩展的可能。

店铺表(shop)和菜品表(dish)是商家端的核心。店铺要记录店铺名称、公告、起送价、配送费、营业状态(1营业、0休息)、评分。菜品表要关联店铺ID,同时要有菜品分类字段,比如“招牌热菜”“饮品小吃”、库存量、月销量、图片URL和上下架状态。关于库存我多说一句:校园外卖场景下,菜品库存不像电商那么复杂,不需要精确到SKU级别,但至少要有“沽清”概念,也就是售完自动下架,这个字段设计出来不难,难的是并发扣减库存时不超卖。

订单表(orders)是系统中字段最多的表,包含订单编号、用户ID、店铺ID、订单金额、配送地址、订单状态、支付状态、收货人信息(姓名、电话、地址)、骑手ID、备注信息、创建时间和支付时间。这里有一个重要设计,就是订单金额必须保存为快照,不能去查询商品表现算——因为菜品可能改价或下架,金额快照才能保证历史订单的数据准确。地址信息同理,直接冗余在订单表里,不要关联用户地址表,因为用户搬家后历史订单还要能看收到哪里。

订单明细表(order_detail)记录每笔订单包含了哪些菜品、单价、数量、小计金额。它和订单表是父子表关系。再接下来是购物车表(cart)、地址表(address)、骑手表(rider)和系统管理表(sys_user)。完整建表脚本我就不贴了(太长),但在设计上有一个关键心得:所有表都必须有create_time、update_time这两个字段,并且用ON UPDATE CURRENT_TIMESTAMP自动维护,否则后期排查问题会非常痛苦。

2.2 订单状态字段的设计陷阱

订单状态是整个外卖平台中最容易翻车的部分。很多人的第一版设计是直接用一个状态字段存字符串,比如“待支付”“已支付”“配送中”“已完成”,这样看起来直观,但后续做状态统计、异常订单处理时就会很麻烦。我的建议是使用整数状态码,通过常量或枚举类统一管理。

我的状态机设计是这样的:0待支付、1待接单、2已接单(备餐中)、3配送中、4已完成、5已取消、6退款中、7已退款。注意,取消操作在什么状态下允许?支付前用户主动取消没问题;商家接单后只能申请退款不能简单取消;配送中状态下用户取消需要走退款申请流程。这些状态之间的流转规则如果不提前定义清楚,代码里就全是散落的if判断,时间一长逻辑就乱成一团。

具体实现上我推荐用策略模式或者状态机框架来管理流转。比如Spring StateMachine,但对这个项目体量来说有点重;用自定义的OrderStatusEnum加一个OrderStateValidator工具类就够了,核心是维护一张状态流转的规则表,每个状态下能执行哪些操作、操作需要什么权限、操作成功后跳到哪个状态。这样写的好处是,当你想加一个新功能比如“模拟超时自动取消”,只需要在这个规则表里加一条记录就行。

2.3 购物车与库存的细节设计

购物车是用户端体验的直接影响因素。我用Redis的Hash结构来存储,key是cart:userId,field是dishId,value是数量。用户加购一个菜品,直接HINCRBY cart:1001 10101 1,只有一行代码,性能碾压MySQL。但这里有个体验问题:同一店铺的菜品才能同时下单,跨店时需要提示用户当前购物车中存在其他店铺的商品。这个校验放在后端,添加菜品到购物车时就把shopId一起存进Redis,判断是否和已有菜品来自同一店铺。

库存扣减是另一个高频问题。校园外卖的菜品备货量一般不大,我不建议引入复杂的分布式锁,最简单可靠的方式是数据库乐观锁。UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0,通过受影响行数判断是否扣减成功,失败则提示用户“菜品已售罄”。这个方案在单机应用下完全够用。如果以后订单量上来,再考虑引入Redis预扣库存+MQ异步落库的方案也不迟。

3. 关键功能实现与原理解读

3.1 JWT登录鉴权的完整流程

JWT在这里承担的是“无事发生,但一旦需要检查,立刻能拿出身份凭证”的通行证职责。实现思路并不复杂:用户登录成功后,服务端用密钥生成一个包含用户ID、角色、过期时间的Token返回给前端;前端在后续请求的Header中带上Authorization: Bearer token;后端通过拦截器或过滤器统一解析并校验Token。

让我细说下实现细节。生成Token时我用了io.jsonwebtoken这个库,核心配置分三段:Header(算法和令牌类型)、Payload(用户信息和过期时间)、Signature(用HMAC-SHA256算法对前两段进行签名)。签名密钥必须在application.yml里配置,不要硬编码在Java类里,方便环境切换。过期时间我会设置成24小时,前端拿到Token后存到localStorage,并在请求拦截器里统一添加Header。

校验环节,我用Spring MVC的HandlerInterceptor,配置拦截规则为/api/**并排除/api/login、/api/register、/api/shop/list、/api/dish/list这些公开接口。用户端、商家端、管理端三个角色用不同的访问前缀区分,比如/api/user/**、/api/merchant/**、/api/admin/**,拦截器里获取到Token中的角色字段后判断是否有权限访问对应前缀,这样一套逻辑就能覆盖三个端的鉴权需求。

有一个关键细节容易被忽视:JWT一旦签发,在过期之前是无法撤销的。如果用户退出登录,前端直接删除Token即可,但服务端这边没有感知。所以我在Redis里存了一份“黑名单”,key是jwt:blacklist:{jti},用户退出时将jti(JWT的唯一ID)塞进去并设置剩余有效期,后续请求校验时先查一下黑名单,命中就拒绝访问。这样才算一个完整的退出登录闭环。

3.2 Redis在购物车和验证码场景的使用

Redis在项目里不止做缓存,它还承担了几个关键的“状态存储”任务。第一个是短信验证码的存储和校验。注册和登录页面输入手机号后获取验证码,服务端生成6位随机数,以sms:code:{phone}为key、验证码为value存入Redis并设置5分钟过期,校验时取出来和用户输入比对。这里我踩过一个坑:同一手机号60秒内只能发送一次,防止短信轰炸,这个限制必须在后端做,不能只靠前端按钮倒计时。

第二个是图形验证码。校园外卖后台管理端登录时会用到Kaptcha生成图形验证码,同样存入Redis,Key设计成captcha:{uuid},用户提交登录时带上uuid和验证码值,成功后立即删除,保证一次性有效。这里有个细节,图形验证码的过期时间通常是2分钟,但我在实际测试中发现用户扫码或输入可能要多次尝试,过期时间设置为3分钟更合理,同时每次校验失败就删除,避免暴力破解。

第三个是购物车存储,前面提过用Hash结构。提一个实际的Redis使用经验:一定要给所有业务key设置过期时间,特别是验证码类,可以防止脏数据累积导致Redis内存膨胀。另外,Redis的连接池大小要合理配置,默认lettuce的配置可能不够支持高并发场景,我在spring.redis.lettuce.pool里设置max-active=16、max-wait=3000ms,在实际压测中表现还可以。

3.3 订单状态流转的并发控制

外卖平台在饭点高峰期会遇到“同一时间大量用户同时下单”的情况。订单创建的核心逻辑分三步:扣减库存、生成订单记录、清空购物车。这三步必须在一个事务里完成,任何一个环节失败都要回滚。

我的下单接口Service层加@Transactional注解,但这只是基础。更关键的是,扣减库存用乐观锁SQL,同时订单表必须设置店铺ID索引和用户ID索引,否则订单量上来后查询会非常慢。

还有一类并发问题:用户在下单的同时取消订单。如果取消操作发生在支付回调之后,会导致“订单已支付但被取消了”的状态错乱。我在支付回调处理时加了状态校验,只允许从“0待支付”流转到“1待接单”,否则返回失败并记录日志。这就是前面状态机设计的价值——状态流转的定义清晰了,并发控制就有了依据。

另外说一个订单号生成的方案。不使用数据库自增主键做订单号,我采用Redis的INCR命令生成自增序列,拼接日期格式的字符串,形如20250606120000123456,长度18位。这样既能保证唯一性,又能在日志里一眼看出订单的创建时间,排查问题时非常方便。

3.4 MyBatis分页插件的用法

校园外卖系统的后台管理端必然需要分页列表。MyBatis Plus的分页插件配置很简单,注册一个MybatisPlusInterceptorBean,并添加PaginationInnerInterceptor即可。只有配置了拦截器,Page参数才有效;否则底层SQL不拼接LIMIT,会把全表数据查出来内存分页,数据量一大直接内存溢出。

实际使用时的最佳实践是:Service层的方法接收一个Page对象和查询条件DTO,返回IPage<T>类型。以订单管理页面来看,代码是page(new Page<>(pageNum, pageSize), new LambdaQueryWrapper<Order>().eq(Order::getShopId, shopId).orderByDesc(Order::getCreateTime))。这里有两个性能优化细节:第一,分页查询时不要select *,尽量只查需要的字段,如果实体类字段过多就用select指定;第二,订单表的明细字段如果太大,建议单独建一个轻量VO,不要把所有字段全返回前端。

还有一个小坑要提醒:分页插件和自定义SQL的复杂JOIN查询搭配时,如果COUNT查询出错,需要手动指定optimizeCountSql参数或者检查SQL是否有问题。实际开发中我一直保留一个原则——尽量通过单表查询配合二次过滤来完成列表展示,少用复杂的多表关联,不仅性能更好,代码也更简单。

4. 实操过程:从零搭建核心模块

4.1 项目初始化与目录结构

要想复制一个能跑起来的项目,初始化这一步必须做对。用IntelliJ IDEA创建Spring Boot项目时,我建议直接通过Spring Initializr生成,选择Java 8、Spring Boot 2.7.18版本,依赖勾选Spring Web、MyBatis Framework、MySQL Driver、Spring Data Redis、Lombok、Validation。为什么选2.7.18而不是3.x?因为3.x基于Jakarta命名空间改动较大,很多网上资料和第三方库还没完全适配,对于学习型项目没必要给自己增加难度。

目录结构上我采用标准的Controller-Service-Mapper三层结构,同时增加config、common、vo、dto几个包。common里放统一返回结果类Result、异常处理类BusinessException和全局异常处理器GlobalExceptionHandler;dto放接收前端参数的类;vo放返回给前端的视图对象。这样分层之后,每层职责非常清晰,写代码时基本不会迷路。

有一个非常关键的配置:跨域处理。前后端分离后,前端运行在localhost:8080,后端在localhost:8081,如果不配置CORS(跨域资源共享),前端所有请求都会被浏览器拦截。我写了一个CorsConfig类,实现WebMvcConfigurer的addCorsMappings方法,允许所有来源、所有请求头,允许GET/POST/PUT/DELETE/OPTIONS方法。当然这里存在安全隐患,但放在开发环境没问题,上线时可以收紧为具体域名。

4.2 实现一个下单接口的完整流程

我以“用户下单”这个核心接口为例,串一遍完整的代码逻辑。前端请求参数为{shopId, addressId, remark, cartItems: [{dishId, quantity}], totalAmount}。

Controller层非常简单:

@PostMapping("/order") public Result<String> createOrder(@RequestBody @Valid CreateOrderDTO dto) { return Result.success(orderService.createOrder(dto)); }

Service层的createOrder方法,核心逻辑用伪代码描述如下:

第一步,根据userId从Redis中读取购物车数据,校验菜品都属于同一店铺且都在上架状态。第二步,遍历购物车,逐条执行乐观锁扣减库存。注意如果某条扣减失败,抛出业务异常触发整体回滚。第三步,计算实际订单金额(后端根据菜品单价和数量重新计算,不信任前端传的totalAmount),插入订单主表和订单明细表。第四步,异步清空Redis购物车。第五步,发送MQ消息通知商家端有新订单(如果没有MQ,就用Spring的事件机制或直接同步更新商家端的待处理订单列表)。

中间有很多细节值得展开。比如金额计算,我用BigDecimal而不是double,避免精度丢失;比如地址信息从地址表读取后快照到订单表;比如下单后要记录日志,方便后续追踪。

4.3 Docker部署Java应用的实践

项目完成后,部署环节我用Docker搞定,避免每台服务器都要装一套JDK和MySQL。编写一个最简单可行的Dockerfile:

FROM openjdk:8-jre-alpine MAINTAINER yourname COPY target/order-system.jar /app/order-system.jar WORKDIR /app EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/order-system.jar"]

这个Dockerfile的要点是使用多阶段构建或者配合Maven构建出可执行的jar包后,直接COPY进镜像。我在实际部署时还写了一个docker-compose.yml,同时编排MySQL、Redis和应用容器,一套命令docker-compose up -d就能启动全部依赖,这对本地联调和演示都非常方便。

部署方面有两个核心经验:一是容器时区问题,默认的Alpine镜像时区是UTC,会导致打印的日志时间比北京时间慢8小时,必须在Dockerfile里加上RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime;二是Java进程内存配置,Docker容器内没有默认的JVM调优,建议在启动参数中设置-Xms256m -Xmx512m,避免容器内存被吃掉太多导致OOM重启。

5. 常见问题与排查技巧实录

5.1 经典报错及解决方案

开发过程中我整理了一张问题排查表,这些都是学生项目里极高概率踩的坑。

问题现象根本原因解决方案
分页不生效,查出全表数据缺少分页插件拦截器配置新增MybatisPlusInterceptorBean并注册PaginationInnerInterceptor
前端调用接口报跨域错误后端未配置CORS写CorsConfig类实现跨域配置
POST请求报415 Unsupported Media Type前端未设置Content-Type: application/json前端请求头统一设置JSON格式
中文乱码数据库连接串缺少编码参数JDBC URL加?useUnicode=true&characterEncoding=utf8
登录后请求接口401JWT Token过期或未携带检查本地存储Token及请求拦截器
Tomcat报端口被占用默认8080端口被其他程序占用改用--server.port=8081或者配置随机端口
插入订单数据超时数据库连接池没配好检查sprint.datasource配置,合理设置连接池大小

5.2 前端联调中的体验优化

项目做完后我花了很多时间在联调和交互体验上。后端接口返回格式统一为{code, message, data},其中code=200代表成功,前端拦截器只认这个约定。有一段时间我自己接口写得很随性,有的返回data有的返回result,联调时前端同学气得够呛。后来我也总结了一个心得:接口契约文件必须在编码前定义,哪怕是简单的表格或Swagger注解,也比后面对着一堆乱糟糟的接口文档改代码舒服。

关于图片上传,菜品和店铺都涉及图片。学生的本地环境可以用Base64上送,太大了,建议用对象存储方案或直接服务器存储并返回静态资源URL。这里要注意的一个细节是:Spring Boot上传文件的默认大小限制是1MB,菜品图片稍微大点就上传失败,需要在application.yml配置:

spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB

联调时顺带把登录态的细节完善了一下:用户刷新页面时,前端会先调用/api/user/info,根据返回的code判断Token是否过期来决定是否跳转登录页,这个前置校验比等接口报错再跳转的体验好很多。

5.3 压力测试与性能观察的小心得

项目完成后建议做一次简单的压力测试。我测试时用JMeter模拟100个并发用户同时下单,观察数据库连接数占用和接口响应时间。测试过程中发现两个有意思的现象:一是乐观锁扣库存确实能防止超卖,但并发过高时会有大量失败请求,这时候用户体验会很差;考虑引入Redis预扣减库存就会好很多,不过对于课设项目,把失败信息返回成“手速太快啦,请重试”也就够了。二是Redis连接池默认配置下,验证码接口的并发能力会先于数据库成为瓶颈,所以我把lettuce.pool的max-active从默认值调大了一些。

最后说说项目结构的长期维护。我做这个项目时就坚持写简洁的Git提交记录,每完成一个模块就提交一次,而不是一口气提交所有代码。后来项目进了面试注意点清单,别人问起来我能清楚地讲出每个模块的迭代过程,这种记录习惯本身也是专业素养的一部分。

一个人从零做起一个校园外卖平台,确实会遇到不少麻烦,但把每一步踩过的坑都记录下来,这本身就会让你比只调用接口的“熟练工”强得多。项目做到后期,我更强烈地体会到Spring Boot框架是工具,真正决定项目上限的是你对业务的理解、对状态流转的定义和对细节的把控。这套系统按上面的方式开发完,如果还有精力,建议把Redis缓存热数据和MQ异步解耦再打磨一遍,整个项目的含金量会再上一个台阶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 2:36:06

群晖NAS第三方硬盘兼容解锁:3步把硬盘写进兼容数据库

群晖NAS第三方硬盘兼容解锁&#xff1a;3步把硬盘写进兼容数据库 【免费下载链接】Synology_HDD_db Add your HDD, SSD and NVMe drives to your Synologys compatible drive database and a lot more 项目地址: https://gitcode.com/GitHub_Trending/sy/Synology_HDD_db …

作者头像 李华
网站建设 2026/9/26 2:35:42

RK3588部署YOLOv5s实战:环境搭建与模型转换全流程指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 2:34:50

5G网络仿真安全指南:OAI威胁模型与加密配置实操

这个系列写到第15期&#xff0c;前前后后聊了不少关于5G网络仿真的组网、协议栈、参数调优和实测分析。按计划这期该说安全了&#xff0c;但我得提前说一句&#xff1a;这块在仿真圈子里&#xff0c;确实是长期被忽视的角落。很多人搭好一套基于OAI、ns-3或OMNeT的5G仿真环境&a…

作者头像 李华
网站建设 2026/9/26 2:30:45

Hermes 接入 DeepSeek 快速指南:一条命令、两分钟、零代码

Hermes 接入 DeepSeek 快速指南:一条命令、两分钟、零代码 【免费下载链接】awesome-deepseek-agent 项目地址: https://gitcode.com/GitHub_Trending/aw/awesome-deepseek-agent 给会自我进化的 Hermes 换一颗又快又便宜的大脑,两分钟、零代码就够:按 awesome-deepsee…

作者头像 李华
网站建设 2026/9/26 2:28:55

VMware Workstation 安装 Windows 7 虚拟机全流程与 Tools 报错排查实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华