欢迎光临
我们一直在努力

苍穹外卖单体项目实战总结

苍穹外卖单体项目实战总结

    • 一、项目整体介绍
      • 1\\.1 项目定位
      • 1\\.2 项目架构模式
      • 1\\.3 项目运行环境
    • 二、核心技术栈梳理
      • 2\\.1 核心框架
      • 2\\.2 辅助技术
    • 三、核心业务模块功能详解
      • 3\\.1 商家后台管理端(PC端)
      • 3\\.2 用户小程序端(移动端)
    • 四、项目核心难点与解决方案
      • 4\\.1 无状态登录认证(JWT\\+拦截器)
      • 4\\.2 数据库事务控制
      • 4\\.3 缓存优化(Redis)
      • 4\\.4 微信支付对接与回调处理
      • 4\\.5 全局异常处理与统一结果返回
      • 4\\.6 文件云端上传
      • 4\\.7 AOP切面自动填充公共字段
        • 1\\. AOP核心原理(面向切面编程)
        • 2\\. 反射核心知识(本项目核心精髓)
        • 3\\. 整体执行流程(完整闭环)
      • 4\\.8 WebSocket实时订单消息推送
      • 4\\.9 SpringTask定时任务处理超时未支付订单
    • 五、开发中踩过的坑与解决经验
    • 六、总结

近期完成了
苍穹外卖单体版项目的完整开发与调试,这是一套对标企业真实业务的Java后端实战项目,覆盖了外卖平台核心的用户端、商家端全业务流程。项目基于Spring Boot单体架构开发,完整落地了后端接口开发、业务逻辑实现、权限控制、文件上传、支付对接、异常处理等企业级核心功能,非常适合作为Java后端入门、巩固SSM+Spring Boot技术栈、积累实战项目经验的标杆项目。

本文将从项目简介、技术栈、核心业务模块、重难点解析、踩坑总结、个人收获、优化方向全方位复盘,适合博客记录、面试复盘、技术沉淀。

一、项目整体介绍

1.1 项目定位

苍穹外卖是一款面向餐饮行业的B2C(Business To Consumer,商对客模式,即商家直接面向普通消费者提供服务的电商模式)外卖平台后端项目,分为用户端小程序和商家端后台管理系统,单体架构设计,代码分层清晰、业务贴合真实互联网外卖场景,规避了入门项目功能单一、逻辑简单的问题,高度还原企业开发规范。

项目核心目标:实现用户在线点餐、购物车管理、订单支付、地址管理,以及商家对菜品、分类、订单、员工、数据统计的全流程管理。

1.2 项目架构模式

采用Spring Boot 单体架构,经典三层架构+DDD简易分层思想(领域驱动设计,以业务领域为核心拆分代码,聚焦业务边界,贴合真实业务场景,本项目简化运用该思想,拆分独立业务逻辑,避免代码臃肿耦合),严格遵循企业开发分层规范:

  • Controller(控制层):接收前端请求、参数接收、请求转发、统一响应

  • Service(业务层):核心业务逻辑处理、事务控制、业务校验

  • Mapper(数据持久层):数据库CRUD操作,基于MyBatis实现

  • Entity/POJO:数据库实体类、DTO数据传输对象、VO返回视图对象

  • Common公共模块:统一结果封装、全局异常处理、工具类、常量、拦截器配置

1.3 项目运行环境

  • 开发工具:IDEA、MySQL、DataGrip、Apifox、微信开发者工具

  • 运行环境:JDK1.8、Maven3.6+、MySQL8.0

  • 部署环境:本地Tomcat、Nginx反向代理、支持云服务器线上部署

二、核心技术栈梳理

本项目技术栈以Spring Boot全家桶为核心,整合主流企业级开发技术,是Java后端入门必学技术组合,所有技术均为实际工作高频使用:

2.1 核心框架

  • Spring Boot:简化项目搭建、自动配置、快速开发单体应用

  • Spring MVC:负责请求分发、接口开发、参数绑定、视图解析

  • MyBatis:持久层框架,简化数据库操作,灵活编写SQL语句

2.2 辅助技术

  • MySQL:关系型数据库,存储项目所有业务数据,设计多表关联关系

  • Redis:缓存技术,用于菜品数据缓存、用户登录态缓存、热点数据优化

  • JWT:令牌生成与校验,实现无状态用户登录认证、权限校验

  • 阿里云OSS:文件上传,实现菜品图片、用户头像的云端存储

  • 微信支付API:对接真实支付流程,实现订单支付、退款、回调通知

  • Apifox:一站式接口协作工具,用于项目接口调试、参数校验、自动生成接口文档、模拟请求,替代传统Swagger工具,高效完成前后端接口联调工作 在这里插入图片描述

  • ThreadLocal:线程本地存储,实现登录用户信息全局共享

  • 全局异常处理:统一捕获系统异常,规范前端返回信息

  • Nginx:同时具备反向代理与负载均衡两大核心能力,是项目线上部署的关键组件。反向代理:隐藏后端真实服务端口、统一请求入口、实现动静分离,避免后端服务直接暴露公网,提升项目安全性;负载均衡:线上多服务器集群部署时,自动分发用户请求、分摊服务器压力,解决单节点性能瓶颈,提升项目并发能力、稳定性与可用性。

核心 Nginx 配置(反向代理 + 负载均衡)

# 后端服务集群
upstream sky_server {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
}

server {
listen 80;
server_name localhost;

# 反向代理、负载均衡分发请求
location / {
proxy_pass http://sky_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

三、核心业务模块功能详解

项目整体分为商家管理端和用户小程序端两大模块,涵盖外卖平台全核心业务,所有接口均完成开发、调试、联调。

3.1 商家后台管理端(PC端)

主要供商家管理员使用,实现店铺运营全流程管理:

  • 员工管理:员工新增、编辑、启用/禁用、分页查询、登录认证,基于JWT实现员工权限控制

  • 菜品分类管理:菜品分类新增、修改、删除、启停,分类关联菜品,实现分类统一管理

  • 菜品管理:菜品新增、上下架、修改、删除、批量操作,关联分类、上传菜品图片、配置菜品规格

  • 套餐管理:套餐创建、搭配菜品、套餐上下架、套餐编辑删除,适配商家组合售卖场景

  • 订单管理:实时查看用户订单、接单、拒单、派送、完成订单、订单详情查询、订单状态流转

  • 数据统计:营业额统计、订单量统计、用户数量统计,可视化数据展示

在这里插入图片描述

3.2 用户小程序端(移动端)

面向普通消费者,实现用户点餐全流程:

  • 微信登录:对接微信授权,一键登录,自动注册新用户,基于JWT维护登录状态

  • 地址管理:用户收货地址新增、编辑、删除、设为默认地址,下单自动关联地址

  • 菜品浏览:按分类查看菜品、查看菜品详情、套餐浏览,缓存热点菜品提升访问速度

  • 购物车管理:添加菜品、修改数量、删除菜品、清空购物车,实时计算价格

  • 订单操作:提交订单、微信支付、查看订单列表、取消订单、确认收货、订单退款

在这里插入图片描述

四、项目核心难点与解决方案

开发过程中解决了多个企业级开发高频难点,也是面试重点考察知识点,复盘如下:

4.1 无状态登录认证(JWT+拦截器)

难点:传统Session登录不适合前后端分离项目,存在跨域、服务器集群共享问题。

解决方案:基于JWT实现无状态登录

  • 用户登录成功后,后端生成携带用户信息的JWT令牌,返回给前端

  • 前端每次请求携带Token,自定义拦截器拦截所有请求

  • 拦截器校验Token有效性、过期时间,校验通过后解析用户ID存入ThreadLocal

  • 全局通过ThreadLocal获取当前登录用户信息,无需重复传参

核心代码实现

1. 登录生成JWT令牌

// 登录成功后生成JWT
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
// 生成有效期12小时的令牌
String token = JwtUtil.generateToken(claims, 12 * 60 * 60);
return Result.success(token);

2. 自定义Token拦截器校验、存入登录用户信息

@Component
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 获取请求头token
String token = request.getHeader("token");
// 2. 校验token合法性、过期时间
JwtUtil.validateToken(token);
// 3. 解析token获取登录用户ID
Long empId = Long.valueOf(JwtUtil.getClaimsByToken(token).get(JwtClaimsConstant.EMP_ID).toString());
// 4. 存入ThreadLocal全局共享
UserContext.setUserId(empId);
return true;
}

// 请求结束清空ThreadLocal,防止内存泄漏
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContext.removeUserId();
}
}

4.2 数据库事务控制

难点:提交订单、删除套餐、修改菜品等场景,存在多表操作,单步失败会导致数据不一致。

解决方案:使用Spring声明式事务 @Transactional

  • 订单提交场景:同时新增订单数据、订单明细、扣减购物车,添加事务保证原子性

  • 删除套餐场景:同时删除套餐关联菜品数据和套餐数据,失败自动回滚

  • 配置事务传播机制、异常回滚规则,规避事务失效问题

核心代码实现

订单提交事务控制(核心业务方法)

@Service
public class OrderServiceImpl implements OrderService {

// 开启事务,所有异常均回滚
@Override
@Transactional(rollbackFor = Exception.class)
public void submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
// 1. 获取当前登录用户、收货地址
Long userId = UserContext.getUserId();
// 2. 新增订单主表数据
Orders orders = new Orders();
// 字段赋值…
orderMapper.insert(orders);

// 3. 新增订单明细数据
List<OrderDetail> orderDetailList = ...;
orderDetailMapper.insertBatch(orderDetailList);

// 4. 清空当前用户购物车数据
shoppingCartMapper.deleteByUserId(userId);

// 任意步骤报错,事务自动回滚,保证数据一致
}
}

4.3 缓存优化(Redis)

难点:首页菜品、分类数据访问频次极高,频繁查询数据库导致性能低下。

解决方案:Redis缓存热点数据

  • 查询菜品分类、菜品信息时,先查缓存,命中直接返回,未命中查询数据库并回写缓存

  • 菜品/分类更新、上下架时,主动清空对应缓存,保证数据一致性

  • 设置缓存过期时间,避免缓存数据长期失效

核心代码实现

1. 菜品分类查询缓存逻辑

@Override
public Result<List<Category>> list(Integer type) {
// 1. 先查询Redis缓存
String key = "category_" + type;
List<Category> categoryList = (List<Category>) redisTemplate.opsForValue().get(key);
// 2. 缓存命中直接返回
if (categoryList != null && !categoryList.isEmpty()) {
return Result.success(categoryList);
}
// 3. 缓存未命中,查询数据库
LambdaQueryChainWrapper<Category> query = new LambdaQueryChainWrapper<>(categoryMapper);
if (type != null) {
query.eq(Category::getType, type);
}
categoryList = query.list();
// 4. 数据回写缓存,设置过期时间
redisTemplate.opsForValue().set(key, categoryList, 30, TimeUnit.MINUTES);
return Result.success(categoryList);
}

2. 更新分类清空缓存(保证一致性)

@Override
public void update(Category category) {
categoryMapper.updateById(category);
// 更新数据后主动清空对应缓存
redisTemplate.delete("category_" + category.getType());
}

4.4 微信支付对接与回调处理

难点:微信支付流程复杂,涉及统一下单、支付回调、订单状态同步、退款处理,容易出现订单状态不一致问题。

解决方案:

  • 调用微信支付API生成预支付订单,返回支付参数给前端唤起支付

  • 接收微信异步回调通知,校验签名合法性,防止恶意请求

  • 回调成功后修改订单状态为已支付,记录支付流水号

  • 处理支付超时、支付失败、退款场景,保证订单状态闭环

核心代码实现

支付回调处理核心逻辑

@PostMapping("/payNotify")
public String payNotify(@RequestBody String xmlData) {
// 1. 解析微信回调参数、校验签名
boolean verifyResult = WxPayUtil.verifySign(xmlData);
if (!verifyResult) {
// 签名失败,返回错误
return WxPayUtil.getErrorXml("签名校验失败");
}

// 2. 解析订单号、支付状态
Map<String, String> resultMap = WxPayUtil.xmlToMap(xmlData);
String outTradeNo = resultMap.get("out_trade_no");
String tradeNo = resultMap.get("transaction_id");

// 3. 更新订单状态为已支付
orderService.paySuccess(outTradeNo, tradeNo);

// 4. 返回微信成功响应,停止重复回调
return WxPayUtil.getSuccessXml();
}

4.5 全局异常处理与统一结果返回

难点:项目原生异常返回格式混乱,前端难以统一处理报错信息。

解决方案:

  • 自定义统一结果类Result,封装成功/失败状态码、提示信息、返回数据

  • 自定义业务异常类,区分系统异常和自定义业务异常

  • 通过@RestControllerAdvice实现全局异常拦截,统一捕获所有异常并格式化返回

核心代码实现

1. 统一返回结果类 Result

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code; // 响应码:1成功 0失败
private String msg; // 提示信息
private T data; // 响应数据

// 成功响应
public static <T> Result<T> success(T data) {
return new Result<>(1, "操作成功", data);
}

// 失败响应
public static <T> Result<T> error(String msg) {
return new Result<>(0, msg, null);
}
}

2. 全局异常处理器

@RestControllerAdvice
public class GlobalExceptionHandler {

// 捕获自定义业务异常
@ExceptionHandler(BusinessException.class)
public Result<?> businessExceptionHandler(BusinessException e) {
return Result.error(e.getMessage());
}

// 捕获全局未知异常
@ExceptionHandler(Exception.class)
public Result<?> exceptionHandler(Exception e) {
log.error("系统异常:", e);
return Result.error("系统繁忙,请稍后重试");
}
}

4.6 文件云端上传

难点:本地存储图片无法线上访问,且占用服务器磁盘空间。

解决方案:整合阿里云OSS对象存储

  • 接收前端图片文件,通过OSS工具类上传至云端

  • 返回云端图片URL存入数据库,前端直接访问线上图片

核心代码实现

@RestController
@RequestMapping("/admin/common")
public class CommonController {

@Autowired
private OssUtil ossUtil;

// 阿里云OSS文件上传接口
@PostMapping("/upload")
public Result<String> upload(MultipartFile file) {
try {
// 上传文件至OSS,返回云端URL
String url = ossUtil.upload(file.getOriginalFilename(), file.getInputStream());
return Result.success(url);
} catch (Exception e) {
return Result.error("文件上传失败");
}
}
}

4.7 AOP切面自动填充公共字段

难点:项目所有实体类都统一包含四个公共字段:创建时间、修改时间、创建人、修改人。如果在每一个新增/修改的业务方法中手动set赋值,会产生大量重复代码,极度冗余、开发效率低,且极易出现漏填、错填、格式不统一的问题,后期维护成本极高。

核心解决方案:Spring AOP + 自定义注解 + 反射机制

这是本项目最核心、最具工程化思想的技术点之一,彻底实现了公共代码解耦,符合AOP 横向抽取重复代码的设计思想。下面拓展核心知识点:

1. AOP核心原理(面向切面编程)

AOP 是 Spring 核心特性之一,区别于 OOP 面向对象编程。OOP 是纵向的类继承、方法重写,而 AOP 是横向拦截,可以在不修改原有业务代码的前提下,对方法进行增强,实现日志记录、权限校验、字段填充、事务控制等通用功能。

本项目AOP核心要素:

  • 切入点(Pointcut):拦截 com.sky.mapper 包下所有带 @AutoFill 注解的方法,精准拦截数据库新增、修改操作

  • 通知(Before):在目标方法执行 之前 完成公共字段填充,保证入库数据完整

  • 自定义注解:标记方法操作类型(新增/修改),作为切面的识别标识,实现精准区分填充规则

2. 反射核心知识(本项目核心精髓)

AOP只能拦截方法、获取方法参数,但无法直接给实体类的字段赋值,所以必须配合Java反射机制实现动态赋值。

反射定义:程序在运行状态中,可以动态获取类的全部信息、动态调用对象的方法,打破封装,实现代码通用化。

为什么这里必须用反射?

  • 项目中有菜品、分类、套餐、订单等上百个实体类,如果写死每一个实体类的set方法,代码臃肿且无法复用;

  • 通过反射,我们可以不关注具体实体类类型,统一获取实体的set方法,动态为公共字段赋值;

  • 配合常量类统一管理方法名,避免硬编码字符串写错,提升代码健壮性。

3. 整体执行流程(完整闭环)
  • 开发者在 Mapper 新增/修改方法上添加 @AutoFill(OperationType.INSERT/UPDATE) 注解;

  • 程序调用 Mapper 方法时,Spring AOP 拦截目标方法;

  • 切面通过方法签名获取注解,判断是新增还是修改操作;

  • 通过反射获取实体类对应的set方法;

  • 结合 BaseContext(ThreadLocal)获取当前登录用户ID + 当前系统时间;

  • 反射动态调用set方法完成字段填充,目标方法执行入库。

  • 最终效果:业务代码完全无需关注公共字段赋值,一行注解即可自动填充,代码极简、统一、零冗余。

    解决方案:基于Spring AOP + 自定义注解实现公共字段自动填充

    • 自定义自动填充注解,区分新增、修改两种操作场景

    • 通过AOP切面拦截Mapper新增/修改方法,统一拦截数据库操作

    • 结合ThreadLocal获取当前登录用户ID,自动填充创建人、修改人

    • 自动填充系统当前时间作为创建时间、修改时间,彻底解放手动赋值

    核心代码实现

    1. 自定义填充注解

    /**
    * 公共字段自动填充注解
    */

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface AutoFill {
    // 数据库操作类型:INSERT、UPDATE
    OperationType value();
    }

    2. AOP切面处理类

    @Aspect
    @Slf4j
    @Component //交给Spring容器管理
    public class AutoFillAspect {
    //切入点 @annotation: 只拦截 加了 @AutoFill 这个注解的方法
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint){
    log.info("开始进行公共字段的填充…");

    // 获取到当前被拦截的方法上的数据库操作类型
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
    AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象
    OperationType operationType = autoFill.value();//获得数据库操作类型

    // 获取到当前被拦截的方法的参数–实体对象
    Object[] args = joinPoint.getArgs();//编译看左边,运行看右边
    if(args == null || args.length == 0){
    return;
    }
    Object entity = args[0];//编译看左边,运行看右边,在编译时entity为Object类型,在运行时为具体的实体类

    // 准备赋值的数据
    LocalDateTime now = LocalDateTime.now();
    Long currentId = BaseContext.getCurrentId();

    // 根据当前不同的操作类型,为对应的属性通过反射来赋值
    if(operationType == OperationType.INSERT){
    // 新增操作:为4个公共字段赋值
    try {
    // 调用常量类防止硬编码出错
    Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
    Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
    Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
    Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

    // 通过反射为对象属性赋值
    setCreateTime.invoke(entity, now);
    setCreateUser.invoke(entity, currentId);
    setUpdateTime.invoke(entity, now);
    setUpdateUser.invoke(entity, currentId);
    } catch (Exception e) {
    e.printStackTrace();
    }
    } else if(operationType == OperationType.UPDATE){
    // 修改操作:只更新更新时间、更新人
    try {
    Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
    Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

    // 通过反射为对象属性赋值
    setUpdateTime.invoke(entity, now);
    setUpdateUser.invoke(entity, currentId);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }
    }

    package com.sky.aspect;

    import com.sky.annotation.AutoFill;
    import com.sky.constant.AutoFillConstant;
    import com.sky.context.BaseContext;
    import com.sky.enumeration.OperationType;
    import lombok.extern.slf4j.Slf4j;
    import org.aspectj.lang.JoinPoint;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Before;
    import org.aspectj.lang.annotation.Pointcut;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.springframework.stereotype.Component;

    import java.lang.reflect.Method;
    import java.time.LocalDateTime;

    @Aspect
    @Slf4j
    @Component //交给Spring容器管理
    public class AutoFillAspect {
    //切入点 @annotation: 只拦截 加了 @AutoFill 这个注解的方法
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint){
    log.info("开始进行公共字段的填充…");

    // 获取到当前被拦截的方法上的数据库操作类型
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
    AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象
    OperationType operationType = autoFill.value();//获得数据库操作类型

    // 获取到当前被拦截的方法的参数–实体对象
    Object[] args = joinPoint.getArgs();//编译看左边,运行看右边
    if(args == null || args.length == 0){
    return;
    }
    Object entity = args[0];//编译看左边,运行看右边,在编译时entity为Object类型,在运行时为具体的实体类

    // 准备赋值的数据
    LocalDateTime now = LocalDateTime.now();
    Long currentId = BaseContext.getCurrentId();

    // 根据当前不同的操作类型,为对应的属性通过反射来赋值
    if(operationType == OperationType.INSERT){
    // 新增操作:为4个公共字段赋值
    try {
    // 调用常量类防止硬编码出错
    Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
    Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
    Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
    Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

    // 通过反射为对象属性赋值
    setCreateTime.invoke(entity, now);
    setCreateUser.invoke(entity, currentId);
    setUpdateTime.invoke(entity, now);
    setUpdateUser.invoke(entity, currentId);
    } catch (Exception e) {
    e.printStackTrace();
    }
    } else if(operationType == OperationType.UPDATE){
    // 修改操作:只更新更新时间、更新人
    try {
    Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
    Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

    // 通过反射为对象属性赋值
    setUpdateTime.invoke(entity, now);
    setUpdateUser.invoke(entity, currentId);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }
    }

    @Mapper
    public interface CategoryMapper {

    // 新增自动填充创建时间、创建人
    @Insert("insert into category(type, name, sort, status, create_time, create_user, update_time, update_user) values (#{type},#{name},#{sort},#{status},#{createTime},#{createUser},#{updateTime},#{updateUser})")
    @AutoFill(OperationType.INSERT)
    void insert(Category category);

    // 修改自动填充更新时间、更新人
    @AutoFill(OperationType.UPDATE)
    void updateById(Category category);
    }

    4.8 WebSocket实时订单消息推送

    难点:传统HTTP请求是单向、短连接,只能由前端主动向后端发送请求,后端无法主动推送消息。商家端需要实时接收用户新订单提醒、订单状态变更通知,轮询查询数据库会造成极大的性能浪费,实时性极差。

    在这里插入图片描述

    解决方案:整合 WebSocket 实现服务端主动推送

    WebSocket 是一种长连接双向通信协议,客户端与服务端建立一次连接后,可永久保持通信,支持后端主动向前端推送数据,完美适配订单实时提醒场景。

    核心应用场景:用户下单成功后,后端主动推送新订单消息至商家后台,无需前端轮询,实时提醒商家接单。

    执行流程:

    • 商家登录后台后,前端与后端建立 WebSocket 长连接;

    • 用户小程序下单成功,后端生成新订单;

    • 服务端通过 WebSocket 主动向商家客户端推送订单消息;

    • 商家后台实时弹窗提醒,完成接单操作。

    核心代码实现

    @Component
    @ServerEndpoint("/ws")
    public class WebSocketServer {

    // 存储在线客户端连接
    private static ConcurrentHashMap<String, Session> sessionMap = new ConcurrentHashMap<>();

    // 建立连接成功
    @OnOpen
    public void onOpen(Session session) {
    sessionMap.put(session.getId(), session);
    }

    // 服务端推送消息
    public static void sendMessage(String message) {
    for (Session session : sessionMap.values()) {
    try {
    // 主动向前端推送消息
    session.getBasicRemote().sendText(message);
    } catch (IOException e) {
    e.printStackTrace();
    }
    }
    }

    // 连接关闭
    @OnClose
    public void onClose(Session session) {
    sessionMap.remove(session.getId());
    }
    }

    4.9 SpringTask定时任务处理超时未支付订单

    难点:用户下单后若长时间未支付,订单会一直处于待支付状态,占用库存、产生无效订单数据,需要自动关闭超时订单、释放购物车数据、还原商品可售状态。手动判断极不现实,需要定时任务自动巡检处理。

    解决方案:基于 SpringTask 实现定时任务巡检

    SpringTask 是 Spring 自带的轻量级定时任务框架,无需额外引入第三方依赖,配置简单、开箱即用,适合项目常规定时巡检场景。

    核心业务规则:定时扫描数据库,自动关闭 下单超过15分钟未支付 的订单,修改订单状态、恢复用户购物车数据。

    执行流程:

    • 项目启动自动加载定时任务;

    • 按照指定频率巡检所有【待支付】订单;

    • 判断订单创建时间是否超过有效支付时长;

    • 超时则自动关闭订单,回滚相关数据,保证业务闭环。

    核心代码实现

    @Component
    @EnableScheduling // 开启定时任务功能
    @Slf4j
    public class OrderTask {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ShoppingCartMapper shoppingCartMapper;

    // 每分钟执行一次,处理超时未支付订单
    @Scheduled(cron = "0 * * * * ?")
    @Transactional(rollbackFor = Exception.class)
    public void processTimeoutOrder() {
    log.info("开始巡检超时未支付订单…");
    // 1. 查询15分钟前的待支付订单
    LocalDateTime time = LocalDateTime.now().minusMinutes(15);
    List<Orders> timeoutOrders = orderMapper.listTimeoutOrders(Orders.PENDING_PAYMENT, time);

    if (timeoutOrders == null || timeoutOrders.isEmpty()) {
    return;
    }

    // 2. 遍历处理超时订单
    for (Orders orders : timeoutOrders) {
    // 3. 修改订单状态为已取消
    orders.setStatus(Orders.CANCELLED);
    orders.setCancelReason("订单超时未支付,自动取消");
    orders.setCancelTime(LocalDateTime.now());
    orderMapper.updateById(orders);

    // 4. 恢复用户购物车数据(可选业务)
    // shoppingCartMapper.restoreCart(orders.getUserId());
    }
    }
    }

    五、开发中踩过的坑与解决经验

  • 事务失效问题:同类方法内部调用导致事务不生效,解决:通过Aop上下文获取当前代理对象调用方法,保证事务生效。

  • Token过期与刷新问题:用户长时间操作Token过期导致登录失效,优化:设置合理过期时间,前端拦截401状态码跳转登录。

  • 缓存数据一致性问题:更新数据库后未清空缓存,导致前端展示旧数据,解决:写操作后主动删除对应Redis缓存。

  • 微信回调签名校验失败:参数排序、加密方式错误,解决:严格按照微信官方文档规则进行参数拼接、MD5加密校验。

  • ThreadLocal内存泄漏:请求结束未清空用户信息,解决:拦截器最后主动移除ThreadLocal数据,避免内存溢出。

  • 六、总结

    苍穹外卖单体项目是Java后端从基础语法到企业级实战的过渡标杆项目,没有冗余功能,每一个模块、每一个技术点都是实际开发中的高频核心内容。通过从零开发、逐功能调试、解决各类bug和业务问题,我彻底夯实了Spring Boot核心技术栈,理解了前后端分离开发模式、业务闭环设计、性能优化、异常兜底等工程化思想。

    相比于单纯的视频学习,项目实战真正让我做到了学以致用,不仅巩固了基础知识,更培养了独立开发、问题排查、业务思考的能力,为后续微服务、分布式项目的学习打下了坚实的基础。

    赞(0)
    未经允许不得转载:171主机测评 » 苍穹外卖单体项目实战总结
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址