Skip to content

第 10 章 Spring Boot 综合项目实战——瑞吉外卖 ​

写给新手的话:

前面九章,我们学了 Spring Boot 的各种功能:Web、数据访问、缓存、安全、消息、任务调度...

这一章,我们把这些知识综合起来,做一个完整的项目:瑞吉外卖。

这是一个真实的外卖项目,包括后台管理系统和用户端手机网站。

通过这个项目,你会学到:

  • 一个完整的 Spring Boot 项目长什么样
  • 企业级项目的代码结构和规范
  • 各种技术怎么组合在一起用
  • 实际开发中的一些技巧和最佳实践

学习建议:

  • 先整体了解项目结构,再逐个模块深入
  • 重点理解设计思想,而不只是代码
  • 多思考:为什么这么设计?有没有更好的方式?
  • 最好自己动手敲一遍,印象更深

准备好了吗?我们开始做项目吧!


10.1 项目概述 ​

10.1.1 项目介绍 ​

瑞吉外卖是一个外卖点餐系统,类似美团、饿了么的简化版。

两大模块:

  1. 后台管理系统(给商家用)

    • 员工管理:员工登录、退出、增删改查
    • 分类管理:菜品分类、套餐分类
    • 菜品管理:菜品的增删改查、上下架、口味管理
    • 套餐管理:套餐的增删改查、上下架
    • 订单管理:查看订单
  2. 用户端(给消费者用)

    • 用户登录:手机号 + 验证码
    • 地址管理:收货地址的增删改查
    • 菜品展示:浏览菜品、套餐
    • 购物车:添加、减少、清空
    • 下单:提交订单

10.1.2 技术栈 ​

技术版本说明
Spring Boot2.7.6基础框架
Spring MVC-Web 层
MyBatis-Plus3.4.2ORM 框架
MySQL8.x数据库
Redis-缓存
Spring Cache-缓存抽象
Lombok1.18.20简化代码
Druid1.1.23数据源
FastJSON1.2.76JSON 处理
Apache Commons Lang2.6工具类

10.1.3 项目结构 ​

reggie
├── src/main/java/com/itheima/reggie
│   ├── ReggieApplication.java          # 启动类
│   ├── common                          # 公共类
│   │   ├── R.java                      # 统一返回结果
│   │   ├── BaseContext.java            # 线程上下文
│   │   ├── CustomException.java        # 自定义异常
│   │   ├── GlobalExceptionHandler.java # 全局异常处理
│   │   ├── JacksonObjectMapper.java    # JSON 序列化配置
│   │   └── MyMetaObjectHandler.java    # 自动填充
│   ├── config                          # 配置类
│   │   ├── WebMvcConfig.java           # Web MVC 配置
│   │   └── MybatisPlusConfig.java      # MyBatis-Plus 配置
│   ├── filter                          # 过滤器
│   │   └── LoginCheckFilter.java       # 登录检查过滤器
│   ├── entity                          # 实体类
│   │   ├── Employee.java
│   │   ├── Category.java
│   │   ├── Dish.java
│   │   ├── DishFlavor.java
│   │   ├── Setmeal.java
│   │   ├── SetmealDish.java
│   │   ├── User.java
│   │   ├── AddressBook.java
│   │   ├── ShoppingCart.java
│   │   ├── Orders.java
│   │   └── OrderDetail.java
│   ├── dto                             # 数据传输对象
│   │   ├── DishDto.java
│   │   ├── SetmealDto.java
│   │   └── OrdersDto.java
│   ├── mapper                          # Mapper 接口
│   │   └── ...
│   ├── service                         # 业务层
│   │   ├── impl                        # 实现类
│   │   └── ...
│   ├── controller                      # 控制层
│   │   ├── EmployeeController.java
│   │   ├── CategoryController.java
│   │   ├── DishController.java
│   │   ├── SetmealController.java
│   │   ├── UserController.java
│   │   ├── AddressBookController.java
│   │   ├── ShoppingCartController.java
│   │   ├── OrderController.java
│   │   ├── OrderDetailController.java
│   │   └── CommonController.java
│   └── utils                           # 工具类
│       └── ValidateCodeUtils.java
└── src/main/resources
    ├── application.yml                  # 配置文件
    ├── backend/                         # 后台管理前端页面
    └── front/                           # 用户端前端页面

三层架构:

  • Controller:接收请求,返回结果
  • Service:业务逻辑
  • Mapper / DAO:数据访问

标准的 MVC 三层架构,企业项目基本都是这个结构。

10.1.4 数据库表 ​

主要的数据库表:

表名说明
employee员工表
category分类表(菜品分类、套餐分类)
dish菜品表
dish_flavor菜品口味表
setmeal套餐表
setmeal_dish套餐菜品关联表
user用户表(C 端用户)
address_book地址表
shopping_cart购物车表
orders订单表
order_detail订单明细表

10.2 项目搭建 ​

10.2.1 创建项目 ​

创建一个 Spring Boot 项目,选择依赖:

  • Spring Web
  • MyBatis Framework
  • MySQL Driver
  • Lombok

然后手动加其他依赖。

10.2.2 pom.xml ​

xml
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- MyBatis-Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.4.2</version>
    </dependency>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.20</version>
    </dependency>
    
    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Druid 数据源 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.1.23</version>
    </dependency>
    
    <!-- FastJSON -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.76</version>
    </dependency>
    
    <!-- Commons Lang -->
    <dependency>
        <groupId>commons-lang</groupId>
        <artifactId>commons-lang</artifactId>
        <version>2.6</version>
    </dependency>
    
    <!-- Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- Cache -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    
    <!-- Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

10.2.3 application.yml ​

yaml
server:
  port: 8080

spring:
  application:
    name: reggie
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/reggie?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
      username: root
      password: root
  redis:
    host: localhost
    port: 6379
    database: 0
  cache:
    redis:
      time-to-live: 1800000  # 缓存过期时间 30 分钟

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true  # 下划线转驼峰
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # SQL 日志
  global-config:
    db-config:
      id-type: assign_id  # 主键策略:雪花算法

reggie:
  path: D:\reggie\img\  # 图片上传路径

10.2.4 启动类 ​

java
@Slf4j
@SpringBootApplication
@ServletComponentScan        // 扫描 Servlet 组件(过滤器等)
@EnableTransactionManagement // 开启事务
public class ReggieApplication {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class, args);
        log.info("项目启动成功...");
    }
}

三个注解:

  • @SpringBootApplication:Spring Boot 核心注解
  • @ServletComponentScan:扫描 @WebFilter、@WebServlet 等注解
  • @EnableTransactionManagement:开启事务管理

10.3 公共基础模块 ​

做项目之前,先把公共基础类准备好,后面开发就方便了。

10.3.1 统一返回结果 R ​

所有接口的返回结果都统一成一个格式,前端好处理。

java
@Data
public class R<T> {
    private Integer code; // 编码:1 成功,0 失败
    private String msg;   // 错误信息
    private T data;       // 数据
    private Map map = new HashMap(); // 动态数据

    // 成功
    public static <T> R<T> success(T object) {
        R<T> r = new R<T>();
        r.data = object;
        r.code = 1;
        return r;
    }

    // 失败
    public static <T> R<T> error(String msg) {
        R r = new R();
        r.msg = msg;
        r.code = 0;
        return r;
    }

    // 追加动态数据
    public R<T> add(String key, Object value) {
        this.map.put(key, value);
        return this;
    }
}

统一格式的好处:

  • 前端处理方便,不用每种接口写一套解析逻辑
  • 结构清晰,code 表示成功失败,data 是数据,msg 是错误信息
  • 便于全局异常处理

企业项目基本都有统一返回结果。 这是规范,一定要有。

10.3.2 自定义异常 ​

业务异常用自定义异常,方便全局处理。

java
public class CustomException extends RuntimeException {
    public CustomException(String message) {
        super(message);
    }
}

10.3.3 全局异常处理 ​

所有异常统一处理,不用每个方法都 try-catch。

java
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 处理 SQL 唯一约束异常(重复添加)
     */
    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex) {
        log.error(ex.getMessage());
        
        if (ex.getMessage().contains("Duplicate entry")) {
            String[] split = ex.getMessage().split(" ");
            String msg = split[2] + " 已存在";
            return R.error(msg);
        }
        return R.error("未知错误");
    }

    /**
     * 处理自定义业务异常
     */
    @ExceptionHandler(CustomException.class)
    public R<String> exceptionHandler(CustomException ex) {
        log.error(ex.getMessage());
        return R.error(ex.getMessage());
    }
}

全局异常处理的好处:

  • 代码简洁,不用到处写 try-catch
  • 异常处理统一,返回格式一致
  • 便于记录日志

@ControllerAdvice + @ExceptionHandler 是 Spring MVC 提供的全局异常处理机制。 非常实用,一定要掌握。

10.3.4 自动填充 MyMetaObjectHandler ​

MyBatis-Plus 的自动填充功能,插入和更新时自动填充某些字段。

比如:创建时间、更新时间、创建人、更新人。

java
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

    /**
     * 插入操作,自动填充
     */
    @Override
    public void insertFill(MetaObject metaObject) {
        metaObject.setValue("createTime", LocalDateTime.now());
        metaObject.setValue("updateTime", LocalDateTime.now());
        metaObject.setValue("createUser", BaseContext.getCurrentId());
        metaObject.setValue("updateUser", BaseContext.getCurrentId());
    }

    /**
     * 更新操作,自动填充
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        metaObject.setValue("updateTime", LocalDateTime.now());
        metaObject.setValue("updateUser", BaseContext.getCurrentId());
    }
}

实体类上要加注解:

java
@Data
public class Employee {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    
    private String username;
    private String name;
    private String password;
    private String phone;
    private String sex;
    private String idNumber;  // 身份证号
    private Integer status;
    
    @TableField(fill = FieldFill.INSERT)  // 插入时填充
    private LocalDateTime createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)  // 插入和更新时都填充
    private LocalDateTime updateTime;
    
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
}

自动填充太方便了!不用每个方法都去 set 创建时间、更新时间。 这就是框架的好处,帮你做重复的工作。

10.3.5 线程上下文 BaseContext ​

问题:自动填充的时候,怎么拿到当前登录用户的 ID?

Service 层、Mapper 层没有 request,拿不到 session。

解决方案:用 ThreadLocal。

ThreadLocal 是线程级别的变量,同一个线程里共享数据。

java
public class BaseContext {
    private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    // 设置当前用户 ID
    public static void setCurrentId(Long id) {
        threadLocal.set(id);
    }

    // 获取当前用户 ID
    public static Long getCurrentId() {
        return threadLocal.get();
    }
}

工作流程:

  1. 请求进来,过滤器里拿到用户 ID,set 到 ThreadLocal
  2. Service 层、Mapper 层需要的时候,从 ThreadLocal get
  3. 请求结束,线程销毁,ThreadLocal 也清了

同一个请求是同一个线程处理的,所以 ThreadLocal 里的数据整个请求过程都能拿到。 这是 Web 项目里常用的技巧。

10.3.6 登录过滤器 LoginCheckFilter ​

所有请求都要检查登录状态,没登录的拦截。

用 Filter 实现,统一处理。

java
@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
public class LoginCheckFilter implements Filter {

    // 路径匹配器,支持通配符
    public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

    @Override
    public void doFilter(ServletRequest servletRequest, 
                         ServletResponse servletResponse, 
                         FilterChain filterChain) throws IOException, ServletException {
        
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        // 1. 获取本次请求的 URI
        String requestURI = request.getRequestURI();

        // 2. 定义不需要处理的请求路径
        String[] urls = new String[]{
                "/employee/login",
                "/employee/logout",
                "/backend/**",
                "/front/**",
                "/common/**",
                "/user/sendMsg",
                "/user/login"
        };

        // 3. 判断本次请求是否需要处理
        boolean check = check(urls, requestURI);

        // 4. 如果不需要处理,直接放行
        if (check) {
            filterChain.doFilter(request, response);
            return;
        }

        // 5-1. 判断后台员工登录状态
        if (request.getSession().getAttribute("employee") != null) {
            Long empId = (Long) request.getSession().getAttribute("employee");
            BaseContext.setCurrentId(empId);  // 放到 ThreadLocal
            filterChain.doFilter(request, response);
            return;
        }

        // 5-2. 判断用户端登录状态
        if (request.getSession().getAttribute("user") != null) {
            Long userId = (Long) request.getSession().getAttribute("user");
            BaseContext.setCurrentId(userId);  // 放到 ThreadLocal
            filterChain.doFilter(request, response);
            return;
        }

        // 6. 如果未登录,返回未登录结果
        response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
    }

    /**
     * 路径匹配
     */
    public boolean check(String[] urls, String requestURI) {
        for (String url : urls) {
            boolean match = PATH_MATCHER.match(url, requestURI);
            if (match) {
                return true;
            }
        }
        return false;
    }
}

关键点:

  • @WebFilter 声明过滤器
  • AntPathMatcher 路径匹配,支持通配符
  • 两种用户都检查:员工(employee)和用户(user)
  • 把用户 ID 放到 ThreadLocal,后面用
  • 未登录返回 JSON,前端判断跳转

过滤器是 Java Web 的基础,前面第 3 章学过的。 这里就是实际应用。

10.3.7 JSON 序列化配置 ​

问题 1:Long 类型的 ID,前端 JS 处理会丢失精度。

  • JS 的 Number 最大安全整数是 2^53-1
  • Java 的 Long 最大是 2^63-1
  • 雪花算法生成的 ID 是 19 位,超过 JS 的安全范围

解决方案:Long 转成字符串返回。

问题 2:日期格式不统一。

解决方案:统一日期格式。

java
public class JacksonObjectMapper extends ObjectMapper {

    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    public JacksonObjectMapper() {
        super();
        
        // 收到未知属性时不报异常
        this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

        SimpleModule simpleModule = new SimpleModule()
                // 反序列化(JSON → Java)
                .addDeserializer(LocalDateTime.class, 
                    new LocalDateTimeDeserializer(
                        DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addDeserializer(LocalDate.class, 
                    new LocalDateDeserializer(
                        DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                
                // 序列化(Java → JSON)
                .addSerializer(Long.class, ToStringSerializer.instance)  // Long 转字符串
                .addSerializer(LocalDateTime.class, 
                    new LocalDateTimeSerializer(
                        DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addSerializer(LocalDate.class, 
                    new LocalDateSerializer(
                        DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)));

        this.registerModule(simpleModule);
    }
}

配置到 MVC 里:

java
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {

    /**
     * 静态资源映射
     */
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/backend/**")
                .addResourceLocations("classpath:/backend/");
        registry.addResourceHandler("/front/**")
                .addResourceLocations("classpath:/front/");
    }

    /**
     * 扩展消息转换器
     */
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        MappingJackson2HttpMessageConverter messageConverter = 
            new MappingJackson2HttpMessageConverter();
        
        // 设置自定义的 ObjectMapper
        messageConverter.setObjectMapper(new JacksonObjectMapper());
        
        // 加到最前面,优先使用
        converters.add(0, messageConverter);
    }
}

Long 转字符串这个坑,很多新手都会踩。 前端拿到的 ID 跟后端不一样,就是精度丢失了。 这个解决方案一定要记住。

10.3.8 MyBatis-Plus 配置 ​

java
@Configuration
public class MybatisPlusConfig {

    /**
     * 分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    }
}

分页插件要配置,不然分页功能不好使。

10.3.9 文件上传下载 ​

图片上传下载是通用功能,抽成 CommonController。

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

    @Value("${reggie.path}")
    private String basePath;

    /**
     * 图片上传
     */
    @PostMapping("/upload")
    public R<String> upload(MultipartFile file) {
        // 原始文件名
        String originalFilename = file.getOriginalFilename();
        // 后缀
        String suffix = originalFilename.substring(
            originalFilename.lastIndexOf("."));
        
        // UUID 生成新文件名,防止重名覆盖
        String fileName = UUID.randomUUID().toString() + suffix;

        // 目录不存在就创建
        File dir = new File(basePath);
        if (!dir.exists()) {
            dir.mkdirs();
        }

        try {
            // 转存到指定位置
            file.transferTo(new File(basePath + fileName));
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        return R.success(fileName);
    }

    /**
     * 图片下载
     */
    @GetMapping("/download")
    public void download(String name, HttpServletResponse response) {
        try {
            // 输入流,读文件
            FileInputStream fis = new FileInputStream(
                new File(basePath + name));
            
            // 输出流,写回浏览器
            ServletOutputStream os = response.getOutputStream();
            response.setContentType("image/jpeg");

            byte[] bytes = new byte[1024];
            int len = 0;
            while ((len = fis.read(bytes)) != -1) {
                os.write(bytes, 0, len);
                os.flush();
            }
            
            os.close();
            fis.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

文件上传下载是 Web 项目的常见功能。 第 3 章学过的,这里就是实际应用。


10.4 后台管理 — 员工管理 ​

基础搭好了,开始做第一个功能:员工管理。

10.4.1 员工登录 ​

java
@RestController
@RequestMapping("/employee")
@Slf4j
public class EmployeeController {

    @Autowired
    private EmployeeService employeeService;

    /**
     * 员工登录
     */
    @PostMapping("/login")
    public R<Employee> login(HttpServletRequest request, 
                             @RequestBody Employee employee) {
        
        // 1. 密码 MD5 加密
        String password = employee.getPassword();
        password = DigestUtils.md5DigestAsHex(password.getBytes());

        // 2. 根据用户名查数据库
        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Employee::getUsername, employee.getUsername());
        Employee emp = employeeService.getOne(queryWrapper);

        // 3. 用户名或密码不对
        if (emp == null || !emp.getPassword().equals(password)) {
            return R.error("用户名或密码错误!");
        }

        // 4. 账号被禁用
        if (emp.getStatus() == 0) {
            return R.error("账号已禁用");
        }

        // 5. 登录成功,把员工 ID 存到 Session
        request.getSession().setAttribute("employee", emp.getId());
        return R.success(emp);
    }

    /**
     * 员工退出
     */
    @PostMapping("/logout")
    public R<String> logout(HttpServletRequest request) {
        // 清除 Session
        request.getSession().removeAttribute("employee");
        return R.success("退出成功");
    }
}

登录流程:

  1. 接收用户名密码
  2. 密码 MD5 加密
  3. 根据用户名查用户
  4. 比对密码
  5. 检查状态
  6. 登录成功,存 Session

这里用的是 Session 登录,传统项目常用。 前后端分离项目一般用 JWT。

10.4.2 新增员工 ​

java
@PostMapping
public R<String> save(@RequestBody Employee employee) {
    // 初始密码 123456,MD5 加密
    employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
    
    // 这些都自动填充了,不用手动 set
    // employee.setCreateTime(LocalDateTime.now());
    // employee.setUpdateTime(LocalDateTime.now());
    // employee.setCreateUser(BaseContext.getCurrentId());
    // employee.setUpdateUser(BaseContext.getCurrentId());
    
    employeeService.save(employee);
    return R.success("新增员工成功");
}

有了自动填充,代码简洁多了。 新增、修改都不用手动 set 时间和人。

10.4.3 员工分页查询 ​

java
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name) {
    // 分页构造器
    Page<Employee> pageInfo = new Page<>(page, pageSize);
    
    // 条件构造器
    LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
    // 模糊查询
    queryWrapper.like(StringUtils.isNotEmpty(name), Employee::getName, name);
    // 按更新时间倒序
    queryWrapper.orderByDesc(Employee::getUpdateTime);
    
    // 执行查询
    employeeService.page(pageInfo, queryWrapper);
    
    return R.success(pageInfo);
}

分页查询是 MyBatis-Plus 的标配。 Page 对象 + LambdaQueryWrapper,非常方便。

10.4.4 编辑员工 ​

java
@PutMapping
public R<String> update(@RequestBody Employee employee) {
    // updateTime 和 updateUser 自动填充
    employeeService.updateById(employee);
    return R.success("员工信息修改成功");
}

就这么简单!


10.5 后台管理 — 分类管理 ​

分类管理:菜品分类、套餐分类。

10.5.1 新增分类 ​

java
@PostMapping
public R<String> save(@RequestBody Category category) {
    categoryService.save(category);
    return R.success("新增分类成功");
}

10.5.2 分类分页 ​

java
@GetMapping("/page")
public R<Page> page(int page, int pageSize) {
    Page<Category> pageInfo = new Page<>(page, pageSize);
    
    LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.orderByAsc(Category::getSort);
    
    categoryService.page(pageInfo, queryWrapper);
    return R.success(pageInfo);
}

10.5.3 删除分类(重点) ​

删除分类的时候要检查:

  • 分类下面有没有菜品?有就不能删
  • 分类下面有没有套餐?有就不能删

这就是业务逻辑,要在 Service 层处理。

java
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> 
        implements CategoryService {

    @Autowired
    private DishService dishService;

    @Autowired
    private SetmealService setmealService;

    /**
     * 根据 ID 删除分类,删除前检查
     */
    @Override
    public void remove(Long id) {
        // 1. 查询当前分类是否关联了菜品
        LambdaQueryWrapper<Dish> dishQueryWrapper = new LambdaQueryWrapper<>();
        dishQueryWrapper.eq(Dish::getCategoryId, id);
        int count1 = dishService.count(dishQueryWrapper);
        
        if (count1 > 0) {
            // 有关联菜品,抛异常
            throw new CustomException("当前分类下关联了菜品,不能删除");
        }

        // 2. 查询当前分类是否关联了套餐
        LambdaQueryWrapper<Setmeal> setmealQueryWrapper = new LambdaQueryWrapper<>();
        setmealQueryWrapper.eq(Setmeal::getCategoryId, id);
        int count2 = setmealService.count(setmealQueryWrapper);
        
        if (count2 > 0) {
            throw new CustomException("当前分类下关联了套餐,不能删除");
        }

        // 3. 正常删除
        super.removeById(id);
    }
}

Controller 层:

java
@DeleteMapping
public R<String> delete(Long id) {
    categoryService.remove(id);
    return R.success("分类信息删除成功");
}

这个设计很好:

  • Controller 层很薄,只管调用
  • 业务逻辑都在 Service 层
  • 用自定义异常来处理业务错误
  • 全局异常处理器统一返回

10.6 后台管理 — 菜品管理 ​

菜品管理是比较复杂的一个模块,因为菜品还有口味(DishFlavor)。

10.6.1 DTO 模式 ​

问题:新增菜品的时候,既要存菜品基本信息,又要存口味。

  • Dish 表里存菜品信息
  • DishFlavor 表里存口味
  • 一个菜品对应多个口味

前端传过来的是一个对象,里面既有菜品信息,又有口味列表。

解决方案:用 DTO(Data Transfer Object,数据传输对象)。

java
@Data
public class DishDto extends Dish {
    // 口味列表
    private List<DishFlavor> flavors = new ArrayList<>();
    
    // 分类名称
    private String categoryName;
    
    // 份数
    private Integer copies;
}

DTO 继承了 Dish,所以有 Dish 的所有属性,再扩展一些。 前端传过来的 JSON 可以直接封装成 DishDto。

10.6.2 新增菜品 ​

Service 层处理,涉及两张表,要加事务。

java
@Service
@Transactional
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> 
        implements DishService {

    @Autowired
    private DishFlavorService dishFlavorService;

    /**
     * 新增菜品,同时保存口味
     */
    @Override
    public void saveWithFlavor(DishDto dishDto) {
        // 1. 保存菜品基本信息
        this.save(dishDto);
        
        Long dishId = dishDto.getId();
        
        // 2. 给口味设置菜品 ID
        List<DishFlavor> flavors = dishDto.getFlavors();
        flavors = flavors.stream().map((item) -> {
            item.setDishId(dishId);
            return item;
        }).collect(Collectors.toList());
        
        // 3. 保存口味列表
        dishFlavorService.saveBatch(flavors);
    }
}

Controller 层:

java
@PostMapping
public R<String> save(@RequestBody DishDto dishDto) {
    dishService.saveWithFlavor(dishDto);
    return R.success("新增菜品成功");
}

@Transactional 事务注解,保证两张表要么都成功,要么都失败。 涉及多表操作一定要加事务。

10.6.3 菜品分页查询(带分类名称) ​

分页查询菜品,还要显示分类名称。 分类名称在 category 表,dish 表只有 category_id。

两种方式:

  1. 联表查询(写 SQL)
  2. 查出来再补(查菜品 → 循环查分类 → 拼结果)

这个项目用的是第二种方式。

java
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name) {
    // 菜品分页
    Page<Dish> pageInfo = new Page<>(page, pageSize);
    // DTO 分页(返回给前端的)
    Page<DishDto> dishDtoPage = new Page<>();

    LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.like(name != null, Dish::getName, name);
    queryWrapper.orderByDesc(Dish::getUpdateTime);
    
    dishService.page(pageInfo, queryWrapper);

    // 对象拷贝(除了 records)
    BeanUtils.copyProperties(pageInfo, dishDtoPage, "records");

    // 处理 records:给每个菜品补上分类名称
    List<Dish> records = pageInfo.getRecords();
    List<DishDto> list = records.stream().map((item) -> {
        DishDto dishDto = new DishDto();
        BeanUtils.copyProperties(item, dishDto);
        
        // 根据分类 ID 查分类名称
        Long categoryId = item.getCategoryId();
        Category category = categoryService.getById(categoryId);
        if (category != null) {
            dishDto.setCategoryName(category.getName());
        }
        return dishDto;
    }).collect(Collectors.toList());

    dishDtoPage.setRecords(list);
    return R.success(dishDtoPage);
}

这种方式简单,不用写 SQL。 但如果数据量大,循环查数据库性能不好。 实际项目中数据量大的话,建议联表查询。

10.6.4 根据 ID 查询菜品(带口味) ​

java
@Override
public DishDto getByIdWithFlavor(Long id) {
    // 1. 查询菜品基本信息
    Dish dish = this.getById(id);
    
    DishDto dishDto = new DishDto();
    BeanUtils.copyProperties(dish, dishDto);
    
    // 2. 查询口味
    LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(DishFlavor::getDishId, id);
    List<DishFlavor> flavors = dishFlavorService.list(queryWrapper);
    
    dishDto.setFlavors(flavors);
    return dishDto;
}

10.6.5 修改菜品 ​

java
@Override
@Transactional
public void updateWithFlavor(DishDto dishDto) {
    // 1. 更新菜品基本信息
    this.updateById(dishDto);
    
    // 2. 先删掉原来的口味
    LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(DishFlavor::getDishId, dishDto.getId());
    dishFlavorService.remove(queryWrapper);
    
    // 3. 再添加新的口味
    List<DishFlavor> flavors = dishDto.getFlavors();
    flavors = flavors.stream().map((item) -> {
        item.setDishId(dishDto.getId());
        return item;
    }).collect(Collectors.toList());
    
    dishFlavorService.saveBatch(flavors);
}

修改口味的思路:先删后加。 简单粗暴,但是好用。

10.6.6 菜品起售/停售 ​

java
@PostMapping("/status/{statusNum}")
public R<String> updateStatus(@PathVariable Integer statusNum, 
                              @RequestParam List<Long> ids) {
    LambdaUpdateWrapper<Dish> updateWrapper = new LambdaUpdateWrapper<>();
    updateWrapper.in(Dish::getId, ids);
    updateWrapper.set(Dish::getStatus, statusNum);
    dishService.update(updateWrapper);
    
    return R.success(statusNum == 0 ? "停售成功" : "起售成功");
}

批量修改状态,用 in 条件。


10.7 后台管理 — 套餐管理 ​

套餐管理跟菜品管理很像,也是主表 + 关联表。

10.7.1 新增套餐 ​

java
@Override
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
    // 1. 保存套餐基本信息
    this.save(setmealDto);
    
    Long setmealId = setmealDto.getId();
    
    // 2. 保存套餐和菜品的关联
    List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
    setmealDishes = setmealDishes.stream().map((item) -> {
        item.setSetmealId(setmealId);
        return item;
    }).collect(Collectors.toList());
    
    setmealDishService.saveBatch(setmealDishes);
}

跟菜品新增几乎一样的套路。

10.7.2 删除套餐 ​

删除套餐要检查状态:在售的不能删。

java
@Override
@Transactional
public void removeWithDish(List<Long> ids) {
    // 1. 查询状态,确定是否可以删除
    LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.in(Setmeal::getId, ids);
    queryWrapper.eq(Setmeal::getStatus, 1);  // 在售状态
    
    int count = this.count(queryWrapper);
    if (count > 0) {
        throw new CustomException("套餐正在售卖中,不能删除");
    }
    
    // 2. 删除套餐表
    this.removeByIds(ids);
    
    // 3. 删除套餐菜品关联表
    LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();
    lambdaQueryWrapper.in(SetmealDish::getSetmealId, ids);
    setmealDishService.remove(lambdaQueryWrapper);
}

套路都一样:先检查,再删除。 业务逻辑都在 Service 层。


10.8 用户端 — 用户登录 ​

用户端是给消费者用的,登录方式不一样:手机号 + 短信验证码。

10.8.1 发送验证码 ​

java
@PostMapping("/sendMsg")
public R<String> sendMsg(@RequestBody User user, HttpSession session) {
    // 获取手机号
    String phone = user.getPhone();
    
    if (StringUtils.isNotEmpty(phone)) {
        // 生成 4 位验证码
        String code = ValidateCodeUtils.generateValidateCode(4).toString();
        log.info("code={}", code);
        
        // 把验证码存到 Session
        session.setAttribute(phone, code);
        
        // 这里应该调用短信服务发送,先模拟
        return R.success("手机验证码短信发送成功");
    }
    return R.error("短信发送失败");
}

真实项目里要对接短信服务商(阿里云、腾讯云等)。 这里先存在 Session,登录的时候比对。

10.8.2 验证码登录 ​

java
@PostMapping("/login")
public R<User> login(@RequestBody Map map, HttpSession session) {
    // 手机号
    String phone = map.get("phone").toString();
    // 验证码
    String code = map.get("code").toString();
    
    // 从 Session 取保存的验证码
    Object codeInSession = session.getAttribute(phone);
    
    // 比对验证码
    if (codeInSession != null && codeInSession.equals(code)) {
        // 登录成功
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone, phone);
        User user = userService.getOne(queryWrapper);
        
        // 新用户自动注册
        if (user == null) {
            user = new User();
            user.setPhone(phone);
            user.setStatus(1);
            userService.save(user);
        }
        
        session.setAttribute("user", user.getId());
        return R.success(user);
    }
    return R.error("登录失败");
}

登录流程:

  1. 用户输入手机号,点获取验证码
  2. 后端生成验证码,存 Session,发短信
  3. 用户输入验证码,提交登录
  4. 后端比对验证码
  5. 验证码对了,查用户,新用户自动注册
  6. 登录成功,存 Session

这种登录方式很常见,App、小程序基本都是这样。 不用记密码,方便。


10.9 用户端 — 购物车 ​

购物车是电商项目的核心功能之一。

10.9.1 添加购物车 ​

java
@PostMapping("/add")
public R<ShoppingCart> add(@RequestBody ShoppingCart shoppingCart) {
    // 设置用户 ID
    Long currentId = BaseContext.getCurrentId();
    shoppingCart.setUserId(currentId);

    Long dishId = shoppingCart.getDishId();
    LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(ShoppingCart::getUserId, currentId);

    if (dishId != null) {
        // 添加的是菜品
        queryWrapper.eq(ShoppingCart::getDishId, dishId);
    } else {
        // 添加的是套餐
        queryWrapper.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
    }

    // 查购物车里有没有这个商品
    ShoppingCart cartOne = shoppingCartService.getOne(queryWrapper);

    if (cartOne != null) {
        // 已经有了,数量 +1
        Integer number = cartOne.getNumber();
        cartOne.setNumber(number + 1);
        shoppingCartService.updateById(cartOne);
    } else {
        // 没有,新增,数量默认 1
        shoppingCart.setNumber(1);
        shoppingCart.setCreateTime(LocalDateTime.now());
        shoppingCartService.save(shoppingCart);
        cartOne = shoppingCart;
    }
    
    return R.success(cartOne);
}

逻辑:

  • 先查购物车里有没有这个商品
  • 有就数量 +1
  • 没有就新增一条

购物车既可以加菜品,也可以加套餐。 用 dishId 和 setmealId 区分。

10.9.2 减少购物车 ​

java
@PostMapping("/sub")
public R<ShoppingCart> sub(@RequestBody ShoppingCart shoppingCart) {
    Long currentId = BaseContext.getCurrentId();
    shoppingCart.setUserId(currentId);

    // 查询当前商品
    LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(ShoppingCart::getUserId, currentId);
    
    if (shoppingCart.getDishId() != null) {
        queryWrapper.eq(ShoppingCart::getDishId, shoppingCart.getDishId());
    } else {
        queryWrapper.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
    }

    ShoppingCart cartOne = shoppingCartService.getOne(queryWrapper);
    Integer number = cartOne.getNumber();

    if (number > 1) {
        // 数量 > 1,就 -1
        cartOne.setNumber(number - 1);
        shoppingCartService.updateById(cartOne);
    } else {
        // 数量 = 1,就删掉
        shoppingCartService.removeById(cartOne);
    }
    
    return R.success(cartOne);
}

10.9.3 查看购物车 ​

java
@GetMapping("/list")
public R<List<ShoppingCart>> list() {
    LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());
    queryWrapper.orderByAsc(ShoppingCart::getCreateTime);
    
    List<ShoppingCart> list = shoppingCartService.list(queryWrapper);
    return R.success(list);
}

10.9.4 清空购物车 ​

java
@DeleteMapping("/clean")
public R<String> clean() {
    LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());
    
    shoppingCartService.remove(queryWrapper);
    return R.success("清空购物车成功");
}

10.10 用户端 — 下单 ​

下单是最复杂的功能,涉及多张表。

10.10.1 下单逻辑 ​

下单要做的事:

  1. 检查用户是否登录
  2. 检查购物车是否为空
  3. 检查地址是否存在
  4. 计算总金额
  5. 插入订单表(orders)
  6. 插入订单明细表(order_detail)
  7. 扣库存(如果有的话)
  8. 清空购物车
java
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Orders> 
        implements OrderService {

    @Autowired
    private ShoppingCartService shoppingCartService;
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private AddressBookService addressBookService;
    
    @Autowired
    private OrderDetailService orderDetailService;

    /**
     * 用户下单
     */
    @Override
    @Transactional
    public void submit(Orders orders) {
        // 1. 获取当前用户 ID
        Long userId = BaseContext.getCurrentId();

        // 2. 查询购物车数据
        LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(ShoppingCart::getUserId, userId);
        List<ShoppingCart> shoppingCarts = shoppingCartService.list(queryWrapper);
        
        if (shoppingCarts == null || shoppingCarts.size() == 0) {
            throw new CustomException("购物车为空,不能下单");
        }

        // 3. 查询用户数据
        User user = userService.getById(userId);

        // 4. 查询地址数据
        Long addressBookId = orders.getAddressBookId();
        AddressBook addressBook = addressBookService.getById(addressBookId);
        if (addressBook == null) {
            throw new CustomException("地址信息有误,不能下单");
        }

        // 5. 生成订单号
        long orderId = IdWorker.getId();
        AtomicInteger amount = new AtomicInteger(0);

        // 6. 组装订单明细
        List<OrderDetail> orderDetails = shoppingCarts.stream().map((item) -> {
            OrderDetail orderDetail = new OrderDetail();
            orderDetail.setOrderId(orderId);
            orderDetail.setName(item.getName());
            orderDetail.setImage(item.getImage());
            orderDetail.setDishId(item.getDishId());
            orderDetail.setSetmealId(item.getSetmealId());
            orderDetail.setDishFlavor(item.getDishFlavor());
            orderDetail.setNumber(item.getNumber());
            orderDetail.setAmount(item.getAmount());
            // 累加金额
            amount.addAndGet(item.getAmount().multiply(
                new BigDecimal(item.getNumber())).intValue());
            return orderDetail;
        }).collect(Collectors.toList());

        // 7. 组装订单
        orders.setId(orderId);
        orders.setOrderTime(LocalDateTime.now());
        orders.setCheckoutTime(LocalDateTime.now());
        orders.setStatus(2);  // 待派送
        orders.setAmount(new BigDecimal(amount.get()));  // 总金额
        orders.setUserId(userId);
        orders.setNumber(String.valueOf(orderId));
        orders.setUserName(user.getName());
        orders.setConsignee(addressBook.getConsignee());
        orders.setPhone(addressBook.getPhone());
        orders.setAddress((addressBook.getProvinceName() == null ? "" : addressBook.getProvinceName())
                + (addressBook.getCityName() == null ? "" : addressBook.getCityName())
                + (addressBook.getDistrictName() == null ? "" : addressBook.getDistrictName())
                + (addressBook.getDetail() == null ? "" : addressBook.getDetail()));
        
        // 8. 插入订单表
        this.save(orders);

        // 9. 插入订单明细表
        orderDetailService.saveBatch(orderDetails);

        // 10. 清空购物车
        shoppingCartService.remove(queryWrapper);
    }
}

Controller 层:

java
@PostMapping("/submit")
public R<String> submit(@RequestBody Orders orders) {
    orderService.submit(orders);
    return R.success("下单成功");
}

下单是电商项目的核心,逻辑比较复杂。 涉及多张表,一定要加事务。


10.11 项目优化与扩展 ​

10.11.1 缓存优化 ​

有些数据不经常变,比如菜品、套餐、分类,可以加缓存。

用 Spring Cache + Redis。

开启缓存:

java
@SpringBootApplication
@EnableCaching  // 开启缓存
public class ReggieApplication {
    // ...
}

菜品列表加缓存:

java
@Cacheable(value = "dishCache", key = "#categoryId")
public List<DishDto> list(Long categoryId) {
    // ... 查询逻辑
}

新增、修改、删除时清缓存:

java
@CacheEvict(value = "dishCache", allEntries = true)
public void saveWithFlavor(DishDto dishDto) {
    // ...
}

缓存可以大大提高性能,减少数据库压力。 第 6 章学过的,这里就是实际应用。

10.11.2 读写分离 ​

如果项目访问量大,可以做读写分离:

  • 写操作走主库
  • 读操作走从库

用 ShardingSphere 或者 MyCat 都可以实现。

10.11.3 前后端分离 ​

这个项目是服务端渲染 + 静态页面的模式。 现在更流行前后端分离:

  • 前端:Vue / React
  • 后端:RESTful API
  • 认证:JWT

10.11.4 分布式部署 ​

项目大了,一个实例扛不住,要集群部署:

  • Nginx 负载均衡
  • Redis 共享 Session
  • 分布式锁
  • 消息队列解耦

10.12 企业最佳实践 ​

10.12.1 代码规范 ​

  1. 分层清晰:Controller / Service / Mapper,各司其职
  2. 命名规范:见名知意,驼峰命名
  3. 统一返回:所有接口返回统一格式
  4. 全局异常:统一处理异常
  5. 参数校验:前端传的参数要校验
  6. 日志规范:关键操作记日志

10.12.2 安全 ​

  1. 密码加密:密码不能明文存
  2. SQL 注入:用参数化查询,不要拼接 SQL
  3. XSS 防护:前端输出转义
  4. 权限控制:后台接口要做权限校验
  5. 越权检查:用户只能操作自己的数据

10.12.3 性能 ​

  1. 缓存:热点数据加缓存
  2. 分页:列表查询一定要分页
  3. 索引:常用查询字段加索引
  4. 异步:耗时操作异步处理
  5. 批量操作:批量插入、批量更新

10.12.4 可维护性 ​

  1. 注释:复杂逻辑加注释
  2. 常量抽取:魔法值要定义成常量
  3. 配置外置:可变的配置放到配置文件
  4. 接口文档:用 Swagger 等工具生成接口文档

10.13 本章小结 ​

恭喜你!瑞吉外卖项目学完了!

这一章我们做了一个完整的外卖项目,把前面学的知识都综合运用了。

你都学了什么 ​

项目基础:

  • 项目结构和技术栈
  • 环境搭建
  • 数据库设计

公共基础模块:

  • 统一返回结果 R
  • 全局异常处理
  • 自动填充(MyBatis-Plus)
  • 线程上下文(ThreadLocal)
  • 登录过滤器
  • JSON 序列化配置(Long 转字符串、日期格式化)
  • 文件上传下载
  • MyBatis-Plus 分页插件

后台管理:

  • 员工管理(登录、退出、增删改查、分页)
  • 分类管理(增删改查、删除前检查)
  • 菜品管理(DTO、口味、起售停售、批量操作)
  • 套餐管理(套餐菜品关联)
  • 订单管理

用户端:

  • 短信验证码登录
  • 地址管理
  • 菜品展示
  • 购物车(添加、减少、清空)
  • 下单

项目优化:

  • 缓存优化
  • 读写分离
  • 前后端分离
  • 分布式部署

最佳实践:

  • 代码规范
  • 安全
  • 性能
  • 可维护性

项目中的设计亮点 ​

  1. 统一返回结果:所有接口格式一致,前端好处理
  2. 全局异常处理:代码简洁,统一处理
  3. 自动填充:不用手动 set 创建时间、更新时间
  4. ThreadLocal:同一线程共享用户信息
  5. DTO 模式:前后端数据传输更灵活
  6. Service 层业务逻辑:Controller 层很薄
  7. 自定义异常:业务错误统一抛异常
  8. 过滤器登录检查:统一处理登录校验

给实习生的建议 ​

  1. 先整体后细节:先搞清楚项目整体结构,再逐个模块深入。

  2. 多思考为什么:不要只看代码怎么写,要想为什么这么设计。比如为什么用 DTO?为什么要分层?

  3. 动手敲一遍:看十遍不如敲一遍。自己敲一遍,理解会深很多。

  4. 调试很重要:遇到问题,用 Debug 一步步跟,看数据是怎么流转的。

  5. 规范很重要:企业项目不是一个人写的,规范的代码别人才看得懂,才好维护。

  6. 安全意识:写代码的时候要考虑安全问题,不能有漏洞。

  7. 性能意识:写 SQL、做查询的时候,要想想性能好不好,数据量大了会不会慢。

到这里,Spring Boot 企业级开发的主要内容就学完了! 从入门到项目实战,相信你已经掌握了 Spring Boot 开发的核心技能。

接下来就是多练、多做项目,在实践中不断提升。

加油!未来的架构师就是你!


基于 Vite 强力驱动 | 纯静态轻量托管