我最近和一个做毕设的学弟通了一个多小时电话,起因是他要用SpringBoot校园外卖平台完成系统设计并落地。他本人以前在学校跑腿群当过大半天群主,多少见过这类需求真实的狼狈样子,所以这个题目不是凭空捏造,是真有场景。聊完之后我只想说,校园外卖和面向全市场的美团、饿了么有很大不同,它有自己非常具体的业务约束,比如校区范围、取餐点、商家出餐能力、骑手身份的临时性。这些约束决定了你不需要把系统设计成一个大而全的外卖中台,做一个聚焦校园闭环的轻量平台反而真正能跑起来。
这篇内容主要面向三类人:正在做SpringBoot课程设计或毕业设计的同学,想入门Java后端完整项目但不想只照着教程敲代码的开发者,以及打算把这类项目作为“能力样板”放进简历里的人。整篇文章不会只贴代码,而是按我实际搞这个项目时的决策顺序,把业务边界、技术选型、数据表设计、接口鉴权、并发扣库存、订单自动取消、Docker部署和最后演示验收讲透。看完你能直接照着复现,也能理解每一步为什么这么定。
1. 上线前的业务拆解:校园外卖闭环里到底需要哪几个模块
做这种项目最怕一上来就开数据库建表。我见过太多把系统设计成“小美团”的例子,优惠券、会员、多商户入驻、客服工单全安排进去,结果下页面还没做完,整个项目已经失控。校园外卖的原始驱动场景其实非常朴素:用户在宿舍点餐,附近商家接单出餐,骑手或者商家自己送到楼下,用户取餐。把这三个环节跑通,平台就有价值了。
1.1 先理清三类角色和三段流程
系统核心角色我建议只保留三类:学生用户、商家、系统管理员。骑手可以做成一种特殊的身份,或者直接挂在商家侧管理,不要一开始就独立设计一套骑手抢单体系,否则你会陷入骑手端App、抢单算法这些无底洞。
业务闭环也相对固定,一共三段:
用户侧流程:用户选择校区内的商家 → 浏览菜品 → 加购物车 → 下单 → 支付 → 查看订单状态 → 确认收货。
商家侧流程:商家维护菜品和库存 → 接收新订单 → 接单 → 出餐 → 标记配送 → 订单完成。
管理侧流程:审核并管理商家 → 处理用户反馈 → 查看基础运营数据。
把这三段流程落到系统模块上你就知道,最核心的后端模块应该是:用户与权限、商家与菜品管理、购物车与订单、支付模拟(或接入)、订单状态流转。至于评价、收藏、会员成长体系,都属于锦上添花的扩展功能,放在二期完全可行。
1.2 第一版功能裁剪时的取舍思路
我帮学弟做第一版时,砍掉了很多看似必要的功能。第一,不做真实支付对接,而是做“模拟支付”加支付流水记录,因为个人开发者接入微信/支付宝支付有较高的资质门槛,而校园平台在课程设计和毕业设计阶段更看重流程完整性,不是支付牌照。第二,不做骑手独立App,配送状态由商家单方面更新,用户只读状态。第三,不做商家之间的平台级竞争流量分配,商家在地理上天然分散,不需要个性化推荐。
但你也要保留几个关键约束,否则演示的时候会显得特别业余:订单必须能体现完整的生命周期,从待支付到已支付、商家接单、配送中、完成、取消;菜品的库存要能被真实扣减,防止超卖;用户不能跨商家一起结算,因为外卖配送覆盖范围决定了一次订单只能来自一个商家。这些都属于“看着不炫但必须对”的业务规则,后面所有数据库设计和接口设计基本都围绕它们在转。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型取舍:SpringBoot 2.7.x + JDK8 的理由,以及哪些“热门组件”不该硬塞
我在定技术栈时做了一个很关键的决策:锁定SpringBoot 2.7.18 + JDK 8,而不是去追SpringBoot 3.x的最新版本。这不是因为我不了解新版本,而是我踩过太多“为了升级而升级”的坑,尤其是做校园项目和毕设,稳定性远大于新特性。
2.1 版本决策:为什么不推荐SpringBoot 3.x起步
SpringBoot 3.0之后强制要求JDK 17及以上,而目前很多学校的机房、个人电脑里装的是JDK 8,服务器配置也普遍有限,JDK 8+SpringBoot 2.7的组合恰恰是最成熟的黄金搭配。还有一个很实际的问题:很多培训机构资料、博客、甚至是AI代码补全默认生成的还是javax.*开头的包,而SpringBoot 3.x全面换成了jakarta.*,你一复制网上的代码就会因为包名不同直接编译失败,初学者根本不知道为什么。如果你刚学完JavaWeb,还在纠结环境问题,那SpringBoot 2.7.x会少给你添很多麻烦。
SpringBoot 2.7.18是2.x分支的最后一个版本,官方维护周期比较长,安全漏洞补丁覆盖也相对完整。这意味着你用它做项目,既不会像2.1那样老旧得有些三方组件不支持,也不会像3.x那样引入学习和迁移成本。SpringBoot版本偏高带来的尴尬我见过不少,有的同学因为版本太高,找遍全网都匹配不上一个老版本的MyBatis-Plus依赖,那段时间极其痛苦。把版本锁定下来,所有组件的版本选型也会顺利很多。
2.2 组件清单以及每一样解决什么问题
这个项目我选型的结果如下:
- JDK 1.8与SpringBoot 2.7.18,是后端基础和运行框架。
- MySQL 8.0,存核心业务数据,主要利用InnoDB事务保证订单和库存的一致性。
- MyBatis-Plus 3.5.x,做单表CRUD和分页查询非常快,能省下大量写Mapper XML的时间,但它只是辅助,不等于不用懂SQL。
- Redis 5.x或6.x,存登录验证码、用户Token以及部分热点菜品缓存。如果不想引入Redis,JWT无状态登录也可以不依赖Redis存Token,后面我会说明取舍。
- Spring Security或者轻量拦截器,做登录鉴权。我在这个中小型项目里选的是拦截器加JWT,不是Spring Security;原因后面有分析。
- Knife4j或Springfox,生成接口文档,方便联调时给前端同学看接口。如果团队里有前端协作,这个一定要配,不然对方会频繁问你“这个接口参数到底是啥”。
MySQL是核心事务型数据库,Redis是缓存和性能辅助,千万不要把订单数据也一股脑塞进Redis然后说“NoSQL快”。订单这种强事务、强状态字段的数据,放MySQL里更靠谱。Redis在这个阶段只承担辅助角色。还有JWT本身的特性就是无状态,Valid登录后下发Token,后续每次请求都带在Header里,鉴权时验签即可,所以不存Redis也能工作;一旦需要强制用户下线,就得把Token加到Redis黑名单。我在实际项目里用的是第二种变体,既能随时踢人,又比Spring Security配一堆过滤器链路简单。
2.3 哪些热门组件不该硬塞进校园外卖项目
最近经常有人在网上问SpringBoot怎么集成Flowable,似乎觉得工作流引擎会让外卖项目管理显得高级。但我劝你先想清楚业务:Flowable是BPMN工作流引擎,最擅长的是复杂审批流、任务调度、流程实例追踪。而外卖订单主流程本质上是一个事务性的状态流转,从支付到接单到配送再到完成,路径短且固定,你为它引入一条BPMN流程图完全是杀鸡用牛刀。Flowable的流程定义部署、流程实例创建、一类表格的数据查询,都会让你的项目维护成本直线上升。类似地,ActiveMQ、Kafka这类消息中间件,如果你的项目定位只是校园外卖,确实没有非用不可的场景。订单超时场景下延迟消息虽然是一种解法,但和定期扫描相比,带来的组件运维成本明显更高,这一点后面单独说。
我不反对引入看起来很炫的组件,但引入之前一定要想清楚它在业务里的明确位置。如果你的系统为了展示技术而硬塞Kafka,最可能的后果是面试官问你“这里的消费组和分区是怎么设计的”,你支支吾吾答不上来,反而暴露了知识盲区。如果一定要做亮点,SpringBoot自动装配原理、MyBatis-Plus插件机制、订单状态机设计,这些能在你不引入额外组件的情况下,成为真正的加分项。
3. 数据库主从拆分与订单状态流转的设计细节
随便找一张外卖订单来看,你会发现同一个订单里既有商品维度数据,又有订单整体维度数据。如果把菜品直接塞到订单表的一个字段里,以后统计、退款、对账都很难做。下面这张表是我项目初始版本的核心数据模型,我特别想讲清楚每张表为什么存在。
3.1 核心表清单和字段含义
| 表名 | 用途 | 需要注意的关键字段 |
|---|---|---|
| user | 学生用户和骑手用户 | role区分角色,默认0表示学生 |
| merchant | 入驻商家 | merchant_name、business_hours、delivery_fee |
| category | 菜品分类 | merchant_id、category_name |
| dish | 菜品 | merchant_id、price、stock、image、status |
| shopping_cart | 购物车 | user_id、dish_id、quantity、merchant_id |
| address | 收货地址 | user_id、contact_name、phone、detail |
| orders | 订单主表 | order_no、user_id、merchant_id、total_amount、status |
| order_item | 订单明细表 | order_id、dish_id、dish_name、dish_image、price、quantity |
| order_status_log | 状态流转日志 | order_id、from_status、to_status、operator_type |
| payment_record | 支付流水 | order_no、pay_amount、pay_type、pay_status |
刚开始做校园项目时,我的第一版只有5张表,后来是随着业务演进才一步步拆出上面这些。user和merchant分开是必要的,因为两者的字段差异很大,混在一张表里会有大量空字段。dish表里的stock字段可以表达“今日可售数量”,等于-1时代表不限量,这对校内商家每天备货有限的情况非常实用。
3.2 订单主表和明细表为什么必须拆开
订单主表orders记录的是一个订单的整体信息,包含订单编号、用户、商家、总金额、订单状态、配送信息。订单明细表order_item则记录这个订单里具体包含哪些菜品,每道菜多少钱、多少份、菜品图片和名称都存了一份快照。最关键的就是快照字段:dish_name、dish_image、price必须从dish表里冗余一份到order_item。
为什么不直接查菜品表?因为商家可能修改菜品名或价格。如果用户下单后商家改了价,订单明细里的价格就跟着变了,那用户付款金额和实际订单金额就对不上。把下单那一刻的菜品名称、图片、价格保存成快照,能保证历史订单永久可追溯。这个道理和电商系统里SPU、SKU的设计思路是一样的。order_item表中不会存总价,总价只由orders表承担,每一条明细通过order_id关联到主表。
另外一个细节是订单号的生成。我强烈建议用类似yyyyMMddHHmmss + 用户ID后四位 + 随机数这样的方式生成订单号order_no,而不要让前端直接看到自增主键。这样既保证全局唯一,又不容易被人通过订单ID猜测出平台的单量。订单号和内部主键id可以共存,所有对用户的展示、支付回调都使用order_no,内部连表时使用自增id。这个设计你以后做任何交易类系统都用得上。
3.3 订单状态的合法流转要如何固化
订单状态在整个项目里几乎牵动所有接口。如果每个地方都允许直接把SQL改成任意状态,最后一定会出现“已取消的订单又变成配送中”这种脏数据。我的做法是定义一个状态枚举,把允许的流转路径写进代码里,并配合数据库更新条件来防止并发问题。
| 状态值 | 含义 | 允许流转到 |
|---|---|---|
| 0 | 待支付 | 已取消、已支付 |
| 1 | 已支付/待商家接单 | 已取消、商家已接单 |
| 2 | 商家已接单/备餐中 | 配送中 |
| 3 | 配送中 | 已完成 |
| 4 | 已完成 | 无 |
| 5 | 已取消 | 无 |
实际写订单状态更新时,不能像很多作业里那样先查出来,判断一下status再update。比如用户取消订单,你要用一个SQL条件更新的写法,只更新待支付的订单,代码如下:
java复制@Transactional
public void cancelOrder(Long userId, String orderNo) {
// 只允许从支付状态更新为已取消
int rows = orderMapper.updateStatusByOrderNoAndStatus(orderNo,
OrderStatus.UNPAID.getStatus(),
OrderStatus.CANCELED.getStatus());
if (rows == 0) {
throw new BizException("当前订单状态不允许取消");
}
// 订单取消后回补库存
orderItemService.restoreStock(orderNo);
orderStatusLogService.record(orderNo, OrderStatus.UNPAID, OrderStatus.CANCELED);
}
对应的Mapper SQL大致是这样:
xml复制<update id="updateStatusByOrderNoAndStatus">
update orders
set status = #{targetStatus}
where order_no = #{orderNo}
and status = #{originStatus}
</update>
这种写法的巧妙之处在于把“检查当前状态”和“修改状态”合并成一个原子操作。即使两个请求同时到达,数据库的行锁也会保证只有一个请求能把状态修改成功,另一个请求要么等待后看到状态已经变了,要么更新行数为0,从而进入异常分支。
order_status_log表我建议从一开始就留着。它记录一张订单每次从哪个状态变成哪个状态,以及操作方是用户、商家还是系统定时任务。上线后排查纠纷时,这张表就是铁证。哪怕你不做定时任务,把日志表建好,比任何口头解释都管用。
4. JWT登录、拦截器与Swagger调试放行的实现笔记
用户登录是整个系统的入口。一开始很多人喜欢在自己写的每个Controller里手动判断session,这种写法的坏处是逻辑分散,容易出现漏判。我推荐用JWT加拦截器做一套轻量级登录鉴权,既不用引入整套Spring Security,又能把权限判断收敛到一处。
4.1 统一返回体与异常处理带来的好处
为了让前端解析接口返回时不用判断各种乱七八糟结构,我定义了一个统一的返回体Result。每次Controller返回数据都包一层code、message、data。业务成功code为200,登录失效code为401,参数错误code为400,系统异常code为500。另外配一个全局异常处理器,把运行时抛出的BizException统一转成JSON返回,这样代码里就可以大胆使用throw new BizException("账号或密码错误"),而不用在每个方法里手写try-catch。
这套看似常规的写法,在后端和前端联调时省了非常多沟通时间。前端只认这几种code,如果后端代码出现未捕获的NullPointerException,也会被全局异常处理器捕获,返回友好的错误文案,而不是一大段堆栈信息。这也是项目看着“像个正规系统”的重要标志。
4.2 登录态鉴权:拦截器 + JWT + ThreadLocal的轻量组合
登录接口本身需要放行,用户提交用户名密码成功后,后端返回一个Token。Token的生成可以使用比较成熟的java-jwt库,用户信息只放userId和role,不要放密码等敏感信息。我实现的JwtUtil核心逻辑大概是这样的:
java复制public class JwtUtil {
private static final String SECRET = "your-secret-key-change-in-prod";
public static String createToken(Long userId, Integer role) {
return JWT.create()
.withClaim("userId", userId)
.withClaim("role", role)
.withExpiresAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
.sign(Algorithm.HMAC256(SECRET));
}
public static DecodedJWT parseToken(String token) {
return JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
}
}
随后写一个WebMvcConfigurer注册拦截器,拦截所有/api/**请求,但放行登录、注册、Swagger文档、静态资源等路径。拦截器里取出请求头Header中的Authorization,如果没有Token或者验签失败,直接返回401。主要代码如下:
java复制@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getMethod().equals("OPTIONS")) {
return true;
}
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
try {
DecodedJWT jwt = JwtUtil.parseToken(token.substring(7));
Long userId = jwt.getClaim("userId").asLong();
Integer role = jwt.getClaim("role").asInt();
UserContext.set(userId, role);
return true;
} catch (Exception e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContext.clear();
}
}
其中UserContext是一个ThreadLocal工具类,在拦截器里把当前登录用户塞进去,然后在业务层直接通过UserContext.getUserId()取当前用户。用完之后在afterCompletion清理,防止线程池复用导致的数据串号。这个细节非常值得写进项目文档,它体现了你对线程安全的基本素养。
我没有在这个项目里引入Spring Security,主要原因是不需要那么复杂的过滤器链。对于课程设计和中小型项目,自定义拦截器大概100行代码就能搞定登录鉴权,且每一行都能看懂。Spring Security的功能虽强,但它的配置复杂度和概念门槛会让很多人在“为什么登录成功后请求还是302”这类问题上卡好久。如果你想学Spring Security,建议单独做项目实践,不要在校园外卖系统里和JWT混在一起找虐。
4.3 Swagger调试放行的边界与坑
现在接口都加了拦截器,前端开发同事最需要的是看接口文档和调试接口。我选用的是knife4j,它对swagger进行了增强,界面比原生好看不少,也支持导出文档。但有一个很常见的坑:SpringBoot 2.6及以上版本默认的路径匹配策略从AntPathMatcher改成了PathPatternParser,而Springfox底层还在用旧策略,启动时会出现类似springfox.documentation.spring.web.WebMvcRequestHandlerProvider的报错。解决办法是在配置文件里显式声明:
yaml复制spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
如果用的是Swagger3或OpenAPI3,可能没有这个问题。但为了兼容老项目,这个配置几乎成了SpringBoot 2.6+搭配Springfox时的标配。除了路径匹配策略,拦截器里的放行路径也要把/doc.html、/webjars/**、/swagger-resources/**、/v3/api-docs/**等加入白名单。如果你是开发环境可以放开Swagger,但生产环境一定要通过配置项关闭,否则别人一访问/doc.html就能看到你所有的接口定义,那等于把系统结构公开了,太不安全。
跨域问题也和鉴权放行有关。前端项目运行在Vite默认的5173端口,后端在8080端口,必然产生跨域。我们在后端统一配置CorsFilter,并把OPTIONS预检请求放行;如果预检请求被拦截器拦住,前端请求连业务代码都到不了,表现就是“明明后端接口单独调没问题,一从前端调就报跨域错误”。这个坑很经典,把OPTIONS放行后基本就解决了。
5. 下单并发:防超卖更新和循环依赖启动失败复盘
下单这个动作是整个外卖系统并发压力最大的环节。一到中午高峰期,可能几百上千个学生同时冲同一家销量好的店下单。很多系统在低并发时一点问题没有,一上压力就崩,最常见的原因不是服务器性能,而是代码里的并发安全没处理好。
5.1 一个把库存扣成负数的小实验
我当时为了演示教学,写了一个最直观的错误版本:用户点击下单后,后端先查dish表当前库存,判断库存是否大于购买数量,若OK则执行update dish set stock = stock - 数量。平时没问题,但用JMeter并发请求压20个线程抢同一道库存为10的菜时,库存直接变成负数。
原因很简单:多个线程同时读到库存为10,都认为库存够,然后各自执行扣减,都成功了。这种情况叫check-then-act,检查与执行之间不是原子操作,线上并发场景下一定会被击穿。外卖虽然不是秒杀系统,但“商家今日菜品限量10份,你抢第11份却不能下单”这个需求是真实存在的。
5.2 用一条SQL的原子更新来兜底
正确的做法是让数据库在更新时完成库存校验。把扣减SQL改成带条件的形式:
sql复制update dish
set stock = stock - #{quantity}
where id = #{dishId}
and stock >= #{quantity}
这条SQL执行后,如果返回的影响行数为1,说明扣减成功;如果返回0,说明库存不足或者菜品已被删除,直接返回“已售罄”。这句话用到了MySQL行锁机制,同一时间只有一条更新语句能获得该行的写锁,其他并发更新必须排队。这比在应用里加synchronized或Redis分布式锁简单得多,也足够解决校园外卖的并发量级。
如果想更“高级”一点,可以引入MyBatis-Plus的乐观锁插件,在dish表加version字段,更新时把version = oldVersion + 1作为条件。核心逻辑是同一原理。对于外卖下单场景,我更倾向于直接用stock >= quantity条件更新,因为少一个字段且语义最清晰。还要注意,一次订单同时包含多个菜品时,整个扣减过程要包裹在同一个数据库事务里:
java复制@Transactional
public OrderVO createOrder(CreateOrderDTO dto) {
// 1. 创建订单主表记录,状态为待支付
// 2. 批量扣减菜品库存,任何一个菜品扣减失败则整体回滚
// 3. 写入订单明细快照
// 4. 返回订单号
}
关于重复下单的问题,有一个比较取巧的兜底方案:在orders表中给user_id和order_no建联合唯一索引,同时结合前端按钮禁用、防抖操作。如果真出现同一用户瞬间提交两次同一份订单,数据库的唯一索引会拦住第二次插入,应用捕获到重复键异常后返回“订单已提交”,让用户去订单列表查看,而不是产生两笔重复订单。
5.3 服务之间的循环依赖是怎么逼我重构的
做项目时我还遇到过一个启动失败的场景,SpringBoot启动时直接报了一长串错误,核心内容是“The dependencies of some of the beans in the application context form a cycle”。循环依赖在SpringBoot 2.6之前可能默认还能容忍,SpringBoot 2.6之后如果检测到循环引用,直接启动失败。
我的问题出在OrderServiceImpl里注入了UserService,而UserServiceImpl又注入了OrderService,两个Bean互相引用,形成了一个环。报错链路很长,但核心原因一眼就能定位。这种情况我很不推荐用@Lazy注解强行解决,因为它只是延迟代理创建,本质上没有消除环,长期来看代码还是坏的。正确做法是把互相依赖的业务方法抽到一个更底层或侧面的服务里。
我当时把订单里需要“查询用户积分”的逻辑,抽到了一个独立的MemberQueryService中,OrderService和UserService都去依赖这个更细粒度的服务,而不是彼此依赖。这相当于把环剪断,两个模块的职责也清晰了。这个案例建议所有做Java后端的人都要经历一次,因为它能帮你建立正确的分层意识:Service层的类不应该成环,否则后续迭代和测试都会非常痛苦。
6. 未支付订单自动取消:定时任务方案与状态机边界
校园外卖和一般电商一样,用户下单后如果不付钱,库存会被白白占用。比如某家店每天限量制作50份黄焖鸡,有人下单后跑到五分钟也没支付,这时候另一个人想买也买不了,商家体验会非常差。所以系统必须设计一个机制,把超时未支付订单自动取消,并把被占的库存释放回菜品表。
6.1 为什么不用延迟消息而用定时任务
方案一,用RabbitMQ的延迟消息或者TTL加死信队列。这个方案技术含量高,但需要额外维护RabbitMQ,而且延迟队列在RabbitMQ中并不是原生特性,需要依赖死信交换机或者延迟插件,对中小型项目来说配置成本过高。方案二,用Redis过期事件监听。这个方案的问题在于Redis过期事件是靠键空间通知实现的,消息不一定实时可靠,Redis如果做集群,键空间通知不是默认支持所有节点,很容易丢消息。
方案三,Spring定时任务定期扫描。我之前也觉得这个方案不够高大上,但算笔账后你会发现它完全够用:校园平台一个高峰期订单量也就在几千到几万这个量级,每30秒执行一次扫描查询,扫出所有“创建时间超过10分钟且状态为待支付”的订单,然后执行批量更新,数据库的压力完全可控。系统复杂度却比前两种低一个数量级。
6.2 定时任务和状态机校验的配合
在使用@Scheduled之前先考虑并发问题:如果服务部署了多个实例,定时任务会在每个实例上各执行一次,导致重复扫描。为了防止这个坑,我建议要么只在单实例部署时开启该定时任务,要么给任务加一个简单的分布式锁。课程设计直接用单实例就够了,通过配置文件里的task.enabled控制是否启用。
定时任务的伪代码如下:
java复制@Component
public class OrderTimeoutTask {
@Scheduled(fixedDelay = 30000)
@Transactional
public void cancelTimeoutOrders() {
LocalDateTime expireTime = LocalDateTime.now().minusMinutes(10);
List<OrderPay> orderList = orderMapper.selectTimeoutUnpaidOrders(expireTime);
for (OrderPay order : orderList) {
int rows = orderMapper.updateStatusByOrderNoAndStatus(
order.getOrderNo(),
OrderStatus.UNPAID.getStatus(),
OrderStatus.CANCELED.getStatus());
if (rows > 0) {
orderItemService.restoreStock(order.getOrderNo());
orderStatusLogService.record(order.getOrderNo(), OrderStatus.UNPAID, OrderStatus.CANCELED);
}
}
}
}
需要特别强调的是,即使是定时任务,也必须使用“状态匹配更新”而不是“查到订单就往取消改”,因为用户可能在任务扫描的瞬间刚好完成了支付。当用户支付请求把订单状态从待支付更新成已支付,之后定时任务再来执行时,受影响行数为0,自然就不会把已支付订单取消。这个小小的条件更新,就是状态机边界的最后一道防线。如果你直接按id去把订单改成“已取消”,大概率会在某个分布式场景下把用户已经支付成功的订单给取消掉,那将是极其严重的事故。
库存回补要放在同一个事务里,而且取消订单的动作和回补库存的动作要保证同时成功或同时失败。否则就会出现订单取消成功但库存没加回来,或者订单还在但你却白白加回了库存,造成超卖。
在定时任务运行起来之后,建议在演示时先把订单超时时长临时改短,比如改成30秒,然后在页面上下了单不支付,等待30秒看到订单被自动取消,同时菜品库存恢复。这个演示效果非常直观,也方便验收者理解订单状态机的价值。
7. 部署到Docker与前后端联调中的几个现实问题
后端代码写完后,很多人的项目死在最后一步:本地运行一切正常,一到服务器上部署就各种诡异问题。这一节整理我实际部署SpringBoot校园外卖项目时遇到的高频问题,含Docker化方案、数据库连接配置、LocalDateTime序列化和资源路径映射。
7.1 JDK8项目打包成Docker镜像的Dockerfile写法
如果我们用SpringBoot 2.7.18和JDK 8,打包镜像时需要注意一个很容易踩的坑:网上大量教程还在推荐使用openjdk:8-jdk-alpine基础镜像,但alpine体系基于musl libc,后来确实有证书、时区、DNS方面的问题,而且openjdk镜像维护也不积极了。我更推荐直接使用eclipse-temurin:8-jre-alpine或eclipse-temurin:8-jre-jammy作为基础镜像,它由Eclipse Adoptium项目维护,更适合现代部署。
Dockerfile如下:
dockerfile复制FROM eclipse-temurin:8-jre-alpine
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY target/campus-food-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "/app/app.jar"]
构建后端镜像时最好把jar包名固定成app.jar,这样Dockerfile里的ENTRYPOINT不用频繁改动。在本地打包时使用Maven命令:
bash复制mvn clean package -DskipTests
然后构建镜像:
bash复制docker build -t campus-food:1.0 .
运行时把宿主机的mysql地址、redis地址通过环境变量或外部配置传入容器,而不是把数据库账号密码直接写死在Docker镜像里。这一点在面试里被问到的概率很高。比较简单的做法是项目里保留application.yml作为公共配置,然后用application-prod.yml存放生产环境特有的数据库地址。
7.2 MySQL连接配置中几个让人抓狂的细节
Java连接MySQL 8时,如果只填一个最简URL,会不断遇到新问题。我最终稳定下来的JDBC连接串如下:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/campus_food?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
useSSL=false是因为本地调试没有配置SSL证书;serverTimezone=Asia/Shanghai是为了避免MySQL驱动把时间默认解析成UTC,导致订单里的创建时间和本地时间差8个小时;allowPublicKeyRetrieval=true是MySQL 8连接时经常遇到Public Key Retrieval is not allowed错误的解决方案。这三个参数看着小,但少了任何一个都会浪费大把时间。
SpringBoot 2.7使用的驱动类是com.mysql.cj.jdbc.Driver,不再是老的com.mysql.jdbc.Driver。如果你用的是MySQL 8,还按网上老教程写com.mysql.jdbc.Driver,启动时会提示驱动类找不到或加载失败。这类问题报错很直接,但来源是网上教程版本太老。
数据库表结构里的时间字段建议直接用datetime,Java实体字段用LocalDateTime。其实就算配置了serverTimezone,SpringBoot默认序列化LocalDateTime时返回的格式也可能是"2025-01-01T12:00:00",T出现在中间让前端经常不知道怎么处理。这个问题我一般通过一个全局Jackson配置解决:
java复制@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
builder.serializers(new LocalDateTimeSerializer(formatter));
builder.deserializers(new LocalDateTimeDeserializer(formatter));
};
}
}
配置完成后,所有接口返回的LocalDateTime字段都会统一成yyyy-MM-dd HH:mm:ss格式,前端不用再自己处理字符串转时间。这个细节在很多项目里都会遇到,能够提前统一处理,联调会顺畅很多。
7.3 菜品图片上传后的静态资源映射
菜品、商家头像都是图片。如果图片上传后存在服务器本地目录,例如/usr/local/campus-food/upload/,但前端访问的URL是http://localhost:8080/upload/dish/xxx.jpg,就会发现404。原因是SpringBoot默认静态资源目录不包括这个自定义磁盘目录。解决办法是在配置里把本地目录映射成URL路径:
yaml复制spring:
web:
resources:
static-locations: classpath:/static/,file:${upload.dir}
其中upload.dir就是本地磁盘目录。这样把图片上传到upload.dir目录,再通过/upload/dish/xxx.jpg访问就能命中静态资源。文件上传时还需要限制单个文件大小,否则一张几GB的图片可能会直接把接口拖崩:
yaml复制spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 20MB
演示时不要拿真实照片地址去跳转,尽量把菜品图片传到自己服务器上,避免外链图片防盗链或跨域导致前端显示不出来,造成“系统烂”的错觉。
8. 演示环境的数据准备和验收演示技巧
到了项目验收或者向导师、面试官演示的阶段,很多人程序本身能跑,却因为演示数据太少、演示顺序混乱,导致整个项目看起来没有亮点。校园外卖这类业务系统,演示效果很大程度上取决于你提前准备了哪些数据。
8.1 提前准备好一套“看起来真实”的数据
核心是至少准备8到10个商家,每个商家下挂3到5个分类,每个分类下3到8个菜品,并给菜品配上图片。不要用“测试菜品1”这种名字,要有真实感,比如二食堂麻辣香锅、三食堂黄焖鸡、大学生活动中心奶茶铺,这个场景代入感很强。每个商家设置不同的起送价和配送费,这样演示时可以说“系统支持不同商家的配送规则”。学生账号也准备三四个,方便演示不同用户下单,避免单独一个账号反复切换带来体验上的混乱。
在运行定时任务之前,把订单超时时间临时调成30秒或1分钟,给演示留出足够时间。如果保持10分钟不支付才取消,演示环节根本等不到自动取消,就会错失一个展示订单状态机的好机会。
8.2 演示顺序建议:从真实用户的视角开始
我建议的演示顺序是:先用管理员账号登录,打开商家审核和运营看板,展示整体界面;然后退出,用一个普通学生账号走完整流程——浏览商家、看菜品、加购物车、提交订单、模拟支付、在订单列表看到状态从待支付变成待接单;接着切换到商家端账号,收到这个订单,点击接单,再点击出餐配送,订单变为配送中;最后切回学生账号,点击确认收货,整个闭环走完。这段流程一气呵成,所有功能都被真实调用了一遍。
如果还想展示技术亮点,自己提前开一个JMeter或者Postman的并发测试集合,在演示现场对某道库存只有10份的菜发起30个并发下单请求,结果必然是只有10个请求成功,剩余20个返回“已售罄”。这个演示比任何口头解释都更有说服力。
8.3 我给学弟反复强调的验收心态
最后分享一个比较实际的体会:不要试图在答辩或简历项目介绍中把系统吹成可以支撑千万级流量的外卖平台。校园外卖系统的价值在于用合适的复杂度解决了一个真实存在的校园业务问题,并且从业务建模、接口设计、状态机控制、并发扣库存、轻量部署到最终演示,都形成了完整的闭环。我见过太多人把卖点放在“用了多少中间件”上,反而被追问底层原理时露馅。SpringBoot本身的生态优势、MVC分层清晰度、MyBatis-Plus对CRUD效率的提升,这些内容如果理解透彻,会比其他花架子更能支撑住提问。
这套项目做完之后,如果你想继续扩展,最合理的路径是加一个基于WebSocket的商家订单实时提醒、一个基于ECharts的运营数据看板,或者一个基于Spring Security的更完整权限体系。从我这个项目经验来看,每加一个真实需求,你对SpringBoot生态边界的理解都会更深一层。而如果你连这套最基础的状态流转和部署链路都没跑通,直接去追Flowable、Kafka这些在大型系统里才用得上的组件,只会让你离“能做出来”越来越远。
