10-[专业实战] 分层架构与用户 CRUD API

2. [专业实战] 分层架构与用户 CRUD API

摘要: 欢迎来到项目的核心实战章节。我们将彻底告别简单的“玩具代码”,引入后端开发中至关重要的 分层解耦思想领域对象模型 (VO/DTO/PO)。本章,我们将搭建一个标准的 Controller-Service-Mapper 三层架构,并在这个坚实的基础上,遵循 先单元测试、后 API 测试 的严谨流程,完成用户管理模块的全套 CRUD 接口开发。

2.1. 严谨的分层架构:VO, DTO, PO 的职责与转换

在开始编写业务代码前,我们必须先解决一个核心的架构问题:我们的数据应该如何在不同层之间流转?

一个常见的、但 不推荐 的做法是,只创建一个 User 实体类,让它从数据库一直贯穿到前端。这种“一招鲜,吃遍天”的模式,在项目初期看似便捷,但随着业务变复杂,会迅速带来一系列问题:

  • 数据冗余:查询用户列表时,前端可能只需要用户的 idusername,但实体类通常包含 password, create_time, update_time 等全部 20 个字段,这会造成不必要的数据库查询和网络传输开销。
  • 安全性问题:实体类直接映射数据库,通常包含密码、盐值等敏感信息。如果不慎将其直接序列化并返回给前端,将造成严重的安全漏洞。
  • 耦合度高:前端的一个展示需求变更(比如需要一个新的组合字段,displayName = username + nickname),可能会迫使我们去修改数据库实体类,这严重违反了各层独立、职责单一的设计原则。

为了解决这些问题,专业的后端开发(如《阿里巴巴 Java 开发手册》中强制规定)都会遵循“领域模型”分层的思想,为不同场景创建不同的 Java 对象。在我们的项目中,将严格遵循以下约定:

对象类型全称约定包名核心职责
POPersistent Objectentity持久化对象。与数据库中的表结构一一对应,一个 PO 对象就是数据库中的一条记录。它只应出现在数据访问层(Mapper)与服务层(Service)之间。
DTOData Transfer Objectdto数据传输对象。用于在各个层之间传递数据,我们主要用它来 接收前端传递到 Controller 的请求数据。它的字段完全根据业务操作的需求来定义。
VOView Objectvo视图对象。由 Controller 层 返回给前端的数据对象。它的字段完全根据前端界面的展示需求来定制,可以隐藏敏感字段,也可以组合多个 PO 的数据。

转换的挑战与解决方案

看到这里,您可能会想:在这么多对象之间转换数据,会不会非常麻烦?我们用一个真实的场景来直面这个挑战。

场景设定:假设数据库中的 User (PO) 包含 usernamestatus (整型 1 代表“正常”,2 代表“禁用”)。而前端展示时,需要的是 name 字段和文本描述 statusText (“正常”, “已禁用”)。

首先,我们来定义这两个类。

文件路径: src/main/java/com/example/springbootdemo/entity/User.java (新增文件,替换之前的 model 包)

1
2
3
4
5
6
7
8
9
10
package com.example.springbootdemo.entity;

import lombok.Data;
@Data
public class User {
private Long id;
private String username;
private String password; // 敏感字段
private Integer status; // 1-正常, 2-禁用
}

文件路径: src/main/java/com/example/springbootdemo/vo/UserVO.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
package com.example.springbootdemo.vo;

import lombok.Data;

@Data
public class UserVO {
private Long id;
private String name; // 对应 User 的 username
private String statusText; // 对应 User 的 status
// 注意:VO 中没有 password 属性,保证了安全
}

如果我们天真地直接使用 BeanUtil.copyProperties,由于 usernamename 名称不匹配,且 statusstatusText 类型和逻辑都不同,转换会失败。

接下来,我们将在一个模拟的 Service 和 Controller 中,演示处理这个问题的三种专业方案。

方案一:手动设置(最清晰直接)

这是最基础、最直观的方法。先用 BeanUtil.copyProperties 拷贝同名属性,再对不匹配或需要逻辑处理的属性进行手动的 set 操作。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.springbootdemo.service;

import cn.hutool.core.bean.BeanUtil;
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;

@Service
public class UserService {

// 模拟数据库
private static final Map<Long, User> userDatabase = new HashMap<>();
static {
User user1 = new User();
user1.setId(1L);
user1.setUsername("zhangsan");
user1.setPassword("123456");
user1.setStatus(1);
userDatabase.put(1L, user1);
}

public User getUserById(Long id) {
return userDatabase.get(id);
}

// 方案一:手动转换
public UserVO convertToVOManual(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
// 1. 先拷贝名称和类型都相同的属性
BeanUtil.copyProperties(user, userVO);
// 2. 手动处理名称不同或需要逻辑转换的属性
userVO.setName(user.getUsername());
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
return userVO;
}
}

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.springbootdemo.controller;

import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/users")
public class UserController {

@Autowired
private UserService userService;

// ... 此前已有的方法可以删除掉 ...

@GetMapping("/vo/manual/{id}")
public UserVO getUserVOManual(@PathVariable Long id) {
User user = userService.getUserById(id);
return userService.convertToVOManual(user);
}
}

启动应用,访问 http://localhost:8080/users/vo/manual/1

1
curl http://localhost:8080/users/vo/manual/1

方案二:Hutool @Alias 注解(推荐用法)

Hutool 提供了一个非常方便的 @Alias 注解,专门用来解决属性名不一致的问题。

文件路径: src/main/java/com/example/springbootdemo/vo/UserVO.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.springbootdemo.vo;

import cn.hutool.core.annotation.Alias;
import lombok.Data;

@Data
public class UserVO {
private Long id;
@Alias("username") // 告诉 Hutool, 这个 name 属性对应源对象的 username 属性
private String name;
private String statusText;
}

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... 省略 import 和类 ...
@Service
public class UserService {
// ... 已有代码 ...

// 方案二:使用 @Alias 注解
public UserVO convertToVOWithAlias(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
// Hutool 会自动识别 @Alias 注解并完成 username -> name 的拷贝
BeanUtil.copyProperties(user, userVO);
// 依然需要手动处理 status -> statusText 的逻辑转换
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
return userVO;
}
}

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
// ... 省略 import 和类 ...
@RestController
@RequestMapping("/users")
public class UserController {
// ... 已有代码 ...

@GetMapping("/vo/alias/{id}")
public UserVO getUserVOWithAlias(@PathVariable Long id) {
User user = userService.getUserById(id);
return userService.convertToVOWithAlias(user);
}
}

启动应用,访问 http://localhost:8080/users/vo/alias/1,结果与方案一完全相同。此方案让 Service 层的代码更简洁,将映射关系维护在了 VO 定义中,权责更分明。

对于复杂的、大量的 DTO 转换,MapStruct 是业界公认的最佳实践。它在 编译期 生成映射代码,性能极高(接近手写 get/set),并且功能强大。MapStruct 功能强大但需要额外配置,在本系列笔记的早期,我们将主要采用 方案一和方案二。后续高级篇章中,我们再深入探讨其详细配置与使用。


对象类型全称约定包名核心职责
POPersistent Objectentity持久化对象。与数据库中的表结构一一对应,一个 PO 对象就是数据库中的一条记录。它只应出现在数据访问层(Mapper)与服务层(Service)之间。
DTOData Transfer Objectdto数据传输对象。用于在各个层之间传递数据,我们主要用它来 接收前端传递到 Controller 的请求数据。它的字段完全根据业务操作的需求来定义。
VOView Objectvo视图对象。由 Controller 层 返回给前端的数据对象。它的字段完全根据前端界面的展示需求来定制,可以隐藏敏感字段,也可以组合多个 PO 的数据。
BOBusiness Objectservice/bo业务对象。封装了核心的业务逻辑,是业务规则的载体。在复杂业务中,Service 层会处理 BO,并由 BO 完成具体的业务计算和状态变更。
QOQuery Objectdto/query查询对象。用于封装复杂的查询条件,通常作为 Controller 方法的参数,接收前端传递的筛选、排序、分页等请求参数。

您可能已经注意到,上表中除了我们使用的 PO, DTO, VO 之外,还出现了两个更高级的对象:BO 和 QO。了解它们将有助于我们建立更完整的后端分层思想。

  • BO (Business Object): 业务对象
    这是纯粹的业务层核心,封装了最核心的业务逻辑和规则。在一个非常复杂的系统中(例如,包含复杂计价、风控、状态流转的订单系统),Service 层可能会创建和操作 BO 来执行业务计算。比如,一个 OrderBO 可能有一个 calculateTotalPrice() 方法,其中包含了复杂的折扣、优惠券和运费计算逻辑。

    在我们当前的用户管理 CRUD 项目中,业务逻辑相对简单(主要是数据库操作的组合),我们将直接在 Service 层中实现这些逻辑,因此 暂时不会创建独立的 BO 类,但您需要理解这个概念,它对于驾驭复杂系统至关重要。

  • QO (Query Object): 查询对象
    当我们的查询条件变得复杂时,QO 就派上了大用场。想象一下,如果我们需要根据用户名、邮箱、状态、注册时间范围来筛选用户,并且还要支持分页和排序,Controller 的方法签名可能会有七八个 @RequestParam 参数,显得非常臃肿。

    此时,我们可以创建一个 UserQuery 对象,将所有这些查询参数都作为其属性。这样,Controller 方法就只需要接收一个 UserQuery 对象即可,代码会变得非常整洁和易于扩展。我们将在后续实现复杂查询功能时,正式引入 QO。


2.2. 项目骨架搭建与持久化整合

在上一节,我们明确了 VO, DTO, PO 等领域对象的职责。现在,我们将正式动手“施工”。本节的目标是双重的:首先,我们将搭建起一个专业、可扩展的三层架构骨架;其次,我们将为项目接入真实的 MySQL 数据库,为后续的 CRUD 实战做好万全准备。

2.2.1. 搭建三层架构的包与类结构

我们再次明确各层的职责:

  • Controller 层: Web 入口,负责处理 HTTP 请求、参数校验、调用 Service 并返回响应。
  • Service 层: 业务核心,负责实现业务逻辑、管理事务。
  • Mapper 层: 数据持久层,负责与数据库直接交互。

首先,请按照下图的结构,在您的项目中创建或重构出相应的包。一个清晰的目录结构是项目可维护性的第一保障。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
. 📂 src/main/java/com/example/springbootdemo
├── 📂 controller <- Controller 层
│ └── 📄 UserController.java
├── 📂 dto <- DTOs (Data Transfer Objects)
├── 📂 entity <- POs (Persistent Objects), 即实体类
│ └── 📄 User.java
├── 📂 mapper <- Mapper 层 (数据访问接口)
│ └── 📄 UserMapper.java
├── 📂 service <- Service 接口层
│ └── 📄 UserService.java
├── 📂 service/impl <- Service 实现层
│ └── 📄 UserServiceImpl.java
├── 📂 vo <- VOs (View Objects)
│ └── 📄 UserVO.java
└── 📄 SpringBootDemoApplication.java

现在,请将我们在 2.1 节中定义的 User.java 类移动到 entity 包下,将 UserVO.java 类移动到 vo 包下。

接下来,我们创建 Service 层的接口和它的实现类骨架。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.springbootdemo.service;

import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {
/**
* 根据 ID 查询用户
* @param id 用户 ID
* @return 用户视图对象
*/
UserVO findUserById(Long id);

/**
* 查询所有用户
* @return 用户视图对象列表
*/
List<UserVO> findAllUsers();
}

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.springbootdemo.service.impl;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.stereotype.Service;

import java.util.List;

@Service // Service 注解注解在实现类上,因为只有实现类才有业务逻辑
public class UserServiceImpl implements UserService {


@Override
public UserVO findUserById(Long id) {
return null;
}

@Override
public List<UserVO> findAllUsers() {
return List.of();
}
}

2.2.2. 整合 MyBatis-Plus 与数据库连接

项目骨架已经搭好,现在我们来为它注入灵魂——连接真实的数据库。

1. 添加 Maven 依赖

文件路径: pom.xml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!--        引入HuTool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.13</version>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<!-- 注意这里,这里是一个很常见的坑,AI总喜欢给spring没有-boot3的包,然后就会有兼容性问题 -->
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

2. 配置数据源

文件路径: src/main/resources/application.yml(修改)

1
2
3
4
5
6
7
8
9
10
11
# --------------------
# 主配置文件 (共享配置)
# --------------------
# DataSource Settings
spring:
datasource:
url: jdbc:mysql://localhost:3306/springboot_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver

请确保您已创建名为 springboot_demo 的数据库,并将用户名和密码替换为您自己的配置。

3. 改造实体类并建表

请在您的 springboot_demo 数据库中执行以下 SQL 语句来创建 t_user 表:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `t_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(255) DEFAULT NULL COMMENT '用户名',
`password` varchar(255) DEFAULT NULL COMMENT '密码(后续章节会加密)',
`email` varchar(255) DEFAULT NULL COMMENT '邮箱',
`status` int DEFAULT NULL COMMENT '状态: 1-正常, 2-禁用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO `t_user` (`username`, `password`, `email`, `status`) VALUES
('张三', 'e10adc3949ba59abbe56e057f20f883e', 'zhangsan@163.com', 1),
('李四', 'e10adc3949ba59abbe56e057f20f883e', 'lisi_dev@qq.com', 1),
('王五', 'e10adc3949ba59abbe56e057f20f883e', 'wang.wu@gmail.com', 2),
('赵六', 'e10adc3949ba59abbe56e057f20f883e', 'zhaoliu@outlook.com', 1),
('孙悟空', 'e10adc3949ba59abbe56e057f20f883e', 'sunwukong@huaguoshan.com', 1),
('陈晓明', 'e10adc3949ba59abbe56e057f20f883e', 'chen.xm@126.com', 1),
('刘静', 'e10adc3949ba59abbe56e057f20f883e', 'liujing88@hotmail.com', 1),
('周伟', 'e10adc3949ba59abbe56e057f20f883e', 'zhouwei_cool@qq.com', 1),
('吴磊', 'e10adc3949ba59abbe56e057f20f883e', 'wulei.actor@gmail.com', 2),
('郑秀丽', 'e10adc3949ba59abbe56e057f20f883e', 'zhengxiuli@163.net', 1),
('马云', 'e10adc3949ba59abbe56e057f20f883e', 'jack.ma@alibaba-inc.com', 1),
('黄蓉', 'e10adc3949ba59abbe56e057f20f883e', 'huangrong@taohuadao.net', 1),
('郭靖', 'e10adc3949ba59abbe56e057f20f883e', 'guojing@xiangyang.gov', 1),
('杨过', 'e10adc3949ba59abbe56e057f20f883e', 'yangguo_daxia@gumu.org', 2),
('林黛玉', 'e10adc3949ba59abbe56e057f20f883e', 'lin.daiyu@rongguofu.com', 1),
('贾宝玉', 'e10adc3949ba59abbe56e057f20f883e', 'jia.baoyu@rongguofu.com', 1),
('曹操', 'e10adc3949ba59abbe56e057f20f883e', 'caocao.ceo@san.guo', 1),
('诸葛亮', 'e10adc3949ba59abbe56e057f20f883e', 'zhuge.liang@shuhan.cn', 1),
('陆小凤', 'e10adc3949ba59abbe56e057f20f883e', 'luxiaofeng@sitaimei.com', 1),
('花满楼', 'e10adc3949ba59abbe56e057f20f883e', 'huamanlou@baihuayuan.com', 1);

现在,我们修改 User.java 实体类,为其添加 MyBatis-Plus 的注解。

文件路径: src/main/java/com/example/springbootdemo/entity/User.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.springbootdemo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("t_user")
public class User {

@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String email;
private Integer status;
}

4. 创建 Mapper 接口

文件路径: src/main/java/com/example/springbootdemo/mapper/UserMapper.java (新增文件)

1
2
3
4
5
6
7
package com.example.springbootdemo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.springbootdemo.entity.User;

public interface UserMapper extends BaseMapper<User> {
}

仅需继承 BaseMapper<User>UserMapper 就立刻拥有了强大的 CRUD 能力。

5. 启用 Mapper 扫描

最后一步,让 Spring Boot 知道去哪里查找我们的 Mapper 接口。

文件路径: src/main/java/com/example/springbootdemo/SpringBootDemoApplication.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.springbootdemo.mapper") // 扫描 mapper 包下的所有接口
public class SpringBootDemoApplication {

public static void main(String[] args) {
SpringApplication.run(SpringBootDemoApplication.class, args);
}

}

至此,我们已经完成了项目最关键的奠基工作。我们不仅拥有了专业的分层结构,还成功地将应用与 MySQL 数据库连接了起来。现在,我们的项目已经万事俱备,只待我们去实现真正的业务功能。下一节,我们将从用户查询功能开始,正式进入 CRUD 接口的开发。


2.3. [R] 用户查询功能开发

项目骨架和持久化层已经准备就绪。现在,我们正式开始实现第一个核心功能——用户查询。我们将严格遵循我们制定的专业流程,并在实践中应用我们刚刚学到的分层思想。

2.3.1. DTO/QO 设计:封装查询参数

2.1 节,我们明确了 DTO 的职责之一是 接收前端的请求数据。当查询条件变得复杂时,我们通常会使用一种特殊的 DTO——查询对象 (Query Object, QO) 来封装这些参数。

为什么需要 QO?
设想一下,我们的“查询所有用户”功能未来肯定需要支持 分页,甚至可能需要根据 用户名关键词 进行筛选。如果直接在 Controller 方法里写多个 @RequestParam 参数,代码会显得非常臃肿且难以扩展。

因此,我们首先创建一个 UserPageQuery,用于封装分页查询的参数。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserPageQuery.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.springbootdemo.dto.User;

import lombok.Data;

@Data
public class UserPageQuery {
// 页码,默认值为 1
private int pageNo = 1;

// 每页条数,默认值为 10
private int pageSize = 10;
}

2.3.2. 配置 MyBatis-Plus 分页插件

在使用 MyBatis-Plus 的分页功能(即 selectPage 方法)之前,我们需要先通过配置来启用它的 分页插件。这是保证分页查询能够正常工作的关键一步。

文件路径: src/main/java/com/example/springbootdemo/config/MybatisPlusConfig.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.springbootdemo.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

/**
* 添加分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 向 MyBatis-Plus 的过滤器链中添加分页拦截器,并指定数据库类型为 MySQL
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

2.3.3. Service 层开发与单元测试

1. Service 层功能实现

现在,我们来填充 UserServiceImpl 中的业务逻辑。首先,确保 UserService 接口的定义是正确的。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {

UserVO findUserById(Long id);

/**
* 修改方法签名,以接收分页查询参数
* @param query 分页查询参数
* @return 用户视图对象列表
*/
List<UserVO> findAllUsers(UserPageQuery query);
}

接下来,实现 UserServiceImpl

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.example.springbootdemo.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.mapper.UserMapper;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;

@Service // Service 注解注解在实现类上,因为只有实现类才有业务逻辑
public class UserServiceImpl implements UserService {

@Autowired
private UserMapper userMapper;

@Override
public UserVO findUserById(Long id) {
User user = userMapper.selectById(id);
// 1. 调用 Mapper 方法从数据库查询 PO
if (user == null) {
return null;
}
// 2. 将 PO 转换为 VO
return convertToVO(user);
}

@Override
public List<UserVO> findAllUsers(UserPageQuery query) {
Page<User> page = new Page<>(query.getPageNo(), query.getPageSize());

// selectPage 返回的 Page 对象永远不会是 null,它的 records 列表也非 null
Page<User> userPage = userMapper.selectPage(page, null);

// 直接在 stream 上操作,如果 records 是空列表,stream().map()... 会自然地返回一个空列表
return userPage.getRecords().stream()
.map(this::convertToVO)
.toList();
}

// 私有的转换方法,遵循在 2.1 节定义的转换逻辑
private UserVO convertToVO(User user) {
if (user == null) {
return null;
}
// 此处我们 UserVO 中没有与 User 不同的属性名,所以直接拷贝
// 如果有,则需在此处手动处理,如 userVO.setName(user.getUsername());
UserVO userVO = new UserVO();
BeanUtil.copyProperties(user, userVO);
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
return userVO;
}
}

2. Service 层单元测试

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.vo.UserVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest // 启动完整的 Spring Boot 应用上下文来进行测试
public class UserServiceTest {

@Autowired
private UserService userService;


@Test
void testFindAllUsers_Pagination() {
// 测试分页查询
UserPageQuery query = new UserPageQuery();
query.setPageNo(1);
query.setPageSize(5);
List<UserVO> users = userService.findAllUsers(query);
Assertions.assertNotNull(users);
Assertions.assertEquals(5, users.size()); // 断言返回了 5 条记录
System.out.println("第一页,5条数据:" + users);
}
// ...其余的 test 方法删除
}

2.3.4. Controller 层开发与优雅响应

Service 层通过单元测试后,我们来编写 Controller。

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.springbootdemo.controller;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

// 注意,这里的 RestController 其实就是 @Controller + @ResponseBody
@RestController
@RequestMapping("/users") // 在类上定义父路径 /users
// 这个注解独属于 Lombok,针对类中用 final 修饰或标记了 @NonNull 的字段,自动生成对应的构造函数,减少代码冗余。
@RequiredArgsConstructor
public class UserController {
// 构造函数注入能确保 userService 对象一定被注入,利于保证对象状态完整
// 而且在进行依赖替换和测试时更方便,@Autowired 字段注入相对没这么严格。
private final UserService userService;

@GetMapping
public List<UserVO> getAllUsers(UserPageQuery query) {
return userService.findAllUsers(query);
}

@GetMapping("/{id}")
public ResponseEntity<UserVO> getUserById(@PathVariable Long id) {
UserVO userVO = userService.findUserById(id);
// 使用 ResponseEntity 来处理查询结果可能为 null 的情况
// 如果 userVO 不为 null,返回 200 OK 和 userVO 数据
// 如果 userVO 为 null,返回 404 Not Found
return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build();
}

}

getUserById 方法中,我们使用了一行关键代码:

1
`return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build();`

这行代码背后,体现了专业 API 设计的核心思想:精确控制 HTTP 响应。

  • 为什么需要它?
    如果我们直接返回 UserVO 对象,当查询的用户不存在时,userService 会返回 null。Spring MVC 默认会将 null 转换为空的响应体,并返回 200 OK 状态码。这对前端来说是有歧义的:是成功地查到了一个“空”的用户,还是用户根本不存在?这不符合 RESTful 的设计原则。

  • ResponseEntity<T> 是什么?
    它是 Spring 提供的一个泛型类,用于 完整地封装一个 HTTP 响应。通过它,我们可以随心所欲地控制:

    • 响应状态码 (Status Code): 如 200 OK, 404 Not Found, 400 Bad Request 等。
    • 响应头 (Headers): 如 Content-Type, Location 等。
    • 响应体 (Body): 我们实际返回的数据(比如我们的 UserVO)。

通过使用 ResponseEntity,我们的 API 变得更加健壮和语义化,能够清晰地向客户端传达操作的结果。

2.3.5. API 接口测试

现在,启动您的 Spring Boot 主应用,并使用 cURL 或 Apifox 等工具,手动对我们刚刚完成的接口进行验证。

测试用例 1:分页查询用户

  • 请求方法: GET
  • 请求 URL: http://localhost:8080/users?pageNo=1&pageSize=5
1
curl "http://localhost:8080/users?pageNo=1&pageSize=5"

测试用-例 2:查询不存在的用户

  • 请求方法: GET
  • 请求 URL: http://localhost:8080/users/999
1
curl -i "http://localhost:8080/users/999"

通过手动测试,我们验证了接口的正确性。但您可能也发现了,每次都需要手动构建 URL、查看 JSON 响应,当接口变多、参数变复杂时,这个过程会变得相当繁琐且容易出错。有没有更高效、更直观的方式呢?下一节,我们将正式引入 SpringDoc 来彻底解决这个“痛点”


2.4. API 文档与测试:SpringDoc 的引入与实践

随着 UserController 中的查询功能编写完毕,一个现实的团队协作问题摆在了我们面前:

我们如何将这些 API 接口的信息,准确、高效地传递给其他人(比如前端同事,或者未来的自己)?


2.4.1. 痛点:为什么需要自动化 API 文档?

在没有自动化工具的时代,我们通常依赖以下方式,但它们都存在明显弊端:

  • 手动编写文档 (如 Word, Wiki): 极其繁琐、容易出错,而且一旦代码更新,文档几乎总是会忘记同步,导致文档与代码不一致,造成更大的困扰。
  • 口头沟通或发送消息: 效率低下,信息零散,无法作为可靠的、可追溯的技术凭证。
  • 代码注释: 虽然必要,但无法提供一个全局的、可交互的 API 视图。

这些痛点最终都指向一个核心需求:我们需要一个能够 与代码自动同步、标准化且支持在线交互 的 API 文档解决方案。

2.4.2 解决方案:SpringDoc 与 OpenAPI 3

  • OpenAPI 3: 它是当今 RESTful API 领域的事实标准规范(前身是 Swagger 2.0 规范)。它定义了一套标准的、与语言无关的格式(通常是 YAML 或 JSON),用于完整地描述 API 的所有细节。
  • Swagger UI: 这是一个开源工具,它可以解析符合 OpenAPI 规范的文档,并生成一个美观、可交互的 HTML 界面,让开发者可以直接在浏览器中浏览和测试 API。
  • SpringDoc: 这是一个 Java 库,它能够 自动扫描 我们的 Spring Boot 应用中的 @RestController 等注解,并 自动生成 符合 OpenAPI 3 规范的 API 文档。

简而言之,我们的工作流程是:编写代码 -> SpringDoc 自动生成文档 -> Swagger UI 可视化文档


2.4.3 技术选型:为什么是 SpringDoc,而不是传统的 Swagger (SpringFox)?

在 Spring Boot 2.x 时代,SpringFox 是集成 Swagger 2 的主流选择。但随着技术发展,SpringDoc 已经成为当下的最佳实践,原因如下:

特性SpringDoc (我们选择的)SpringFox (旧方案)
核心规范OpenAPI 3.xSwagger 2.0
Spring Boot 兼容性完美兼容 Spring Boot 3.x / 2.x对 Spring Boot 3.x 支持停滞,存在兼容性问题
社区活跃度持续活跃开发与维护社区已基本停止维护
配置依赖更少,自动化配置程度更高配置相对繁琐

结论: SpringDoc 是面向未来的、与 Spring Boot 生态结合最紧密的选择,因此我们毫无疑问地选择它。


2.4.4 实战:集成 SpringDoc 到我们的项目中

第一步:添加 Maven 依赖

我们只需添加一个依赖,即可同时拥有 OpenAPI 文档生成和 Swagger UI 界面的能力。

文件路径: pom.xml (修改)

1
2
3
4
5
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.9</version>
</dependency>

第二步:丰富 API 注解

为了让生成的文档信息更丰富、更易读,我们可以使用 SpringDoc 提供的注解来“装饰”我们的 Qo 和 Controller 。

文件路径: src/main/java/com/example/springbootdemo/dto/UserPageQuery.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

package com.example.springbootdemo.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Schema(description = "用户分页查询参数")
@Data
public class UserPageQuery {

@Schema(description = "页码,从0开始", example = "0", defaultValue = "0")
private int pageNo = 0;

@Schema(description = "每页大小", example = "10", defaultValue = "10")
private int pageSize = 10;
}

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example.springbootdemo.controller;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") // 新增
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;


@GetMapping
@Operation(summary = "查询所有用户列表")
public List<UserVO> getAllUsers(
@Parameter(
description = "分页查询参数",
required = true,
schema = @Schema(implementation = UserPageQuery.class)
)
UserPageQuery query) {
return userService.findAllUsers(query);
}

@GetMapping("/{id}")
@Operation(summary = "根据ID查询单个用户") // 新增
public ResponseEntity<UserVO> getUserById(
// 新增
@Parameter(description = "用户ID", required = true, example = "1")
@PathVariable Long id
) {
UserVO userVO = userService.findUserById(id);
return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build();
}

}
  • @Tag: 在类上使用,用于对整个 Controller 的接口进行 分组
  • @Operation: 在方法上使用,用于 一句话描述 该接口的功能。
  • @Parameter: 在方法参数上使用,用于 描述参数 的含义、是否必需等信息。
  • @Schema:在属性上使用,用于描述 参数的示例值 提供给接口调用传参使用

第三步:启动与验证

现在,请 重启 您的 Spring Boot 主应用。

无需打开 Apifox,直接在浏览器中访问:

1
http://localhost:8080/swagger-ui.html

您将会看到一个专业、美观且功能强大的 API 文档界面。在这里,您可以清晰地看到我们定义的接口分组、描述、参数等信息,并可以直接点击 “Try it out” 按钮,在线发起请求并查看实时响应。这套动态文档将成为我们后续开发和测试的“指挥中心”。

这仅仅是 SpringDoc 的入门。在后续章节中,当我们遇到 DTO 的详细描述、统一的认证配置等更复杂的场景时,我们还会学习 @Schema@ApiResponse 等更多高级注解,让我们的 API 文档变得更加专业和完善。


2.5. 统一响应封装:构建全局 Result 返回值

2.3 节的查询功能中,我们已经能成功返回 UserVO 列表或单个 UserVO。但这还不够“专业”。一个成熟的后端 API,其所有接口都应该返回 结构统一 的响应数据。本节,我们将引入企业级开发中的一项最佳实践——构建全局响应封装。

2.5.1. 为什么要统一响应格式?

想象一下前端同事在调用我们的 API 时的场景,如果没有统一的响应格式,他们会遇到以下痛点:

  • 前端处理困难:前端开发者需要为每个接口编写不同的逻辑来处理成功和失败。GET /users 成功时返回一个 UserVO[] 数组,而 GET /users/1 成功时返回一个 UserVO 对象,失败时又可能返回空或 404。这种不一致性会极大地增加前端的处理逻辑复杂度。
  • 成功与否判断不清晰:仅通过 HTTP 状态码(200)无法区分所有业务场景。例如,“用户名已存在”是一个业务逻辑上的失败,但 HTTP 状态码可能依然是 200,前端无法仅凭状态码判断操作是否真正成功。
  • 缺乏元信息:响应中只包含业务数据 data,缺少了像 业务状态码 code提示信息 message 这样的元数据,不利于前端进行统一的提示或错误处理。

为了解决这些问题,我们需要定义一个通用的响应体结构,通常称为 ResultApiResponse


2.5.2. 创建通用响应类 Result < T >

我们首先创建一个通用的、支持泛型的 Result<T> 类,并约定所有 API 接口都返回这个结构的对象。

我们先创建一个枚举来标准化业务状态码。

文件路径: src/main/java/com/example/springbootdemo/common/ResultCode.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.springbootdemo.common;

import lombok.Getter;
import lombok.AllArgsConstructor;

@Getter
@AllArgsConstructor
public enum ResultCode {
// 2xx: 成功
SUCCESS(200, "操作成功"),

// 4xx: 客户端错误
BAD_REQUEST(400, "错误的请求"),
UNAUTHORIZED(401, "未经授权"),
NOT_FOUND(404, "资源未找到"),

// 5xx: 服务器错误
ERROR(500, "服务器内部错误"),
SERVICE_UNAVAILABLE(503, "服务不可用"),

// 100x: 自定义用户业务逻辑
UserNotFound(1001, "用户未找到"),
UserAlreadyExists(1002, "用户已存在"),
UserNotLogin(1003, "用户未登录");

private final int code;
private final String message;
}

接下来,创建 Result<T> 类。

文件路径: src/main/java/com/example/springbootdemo/common/Result.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.springbootdemo.common;

import lombok.Getter;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import java.io.Serializable;

/**
* 最终推荐版本:
* 1. 使用 @Getter 代替 @Data 去掉 setter,实现不变性。
* 2. 字段声明为 final,确保只能在构造时赋值。
* 3. 使用 @AllArgsConstructor(access = AccessLevel.PRIVATE) 自动生成私有全参构造,代码更简洁。
*/
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 自动生成私有全参构造函数
public final class Result<T> implements Serializable { // class 也可以声明为 final

private final Integer code; // 业务状态码
private final String message; // 提示信息
private final T data; // 数据

// 成功-无数据
public static <T> Result<T> success() {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null);
}

// 成功-有数据
public static <T> Result<T> success(T data) {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}

// 失败-使用预定义的 ResultCode 枚举,这是最规范的方式
public static <T> Result<T> error(ResultCode resultCode) {
return new Result<>(resultCode.getCode(), resultCode.getMessage(), null);
}

// 失败-自定义消息
public static <T> Result<T> error(String message) {
return new Result<>(ResultCode.ERROR.getCode(), message, null);
}

// 失败-自定义错误码和消息
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}

// 失败-通用错误
public static <T> Result<T> error() {
return error(ResultCode.ERROR);
}
}

2.5.3. ResponseEntity 深度应用

2.3.4 节,我们初步接触了 ResponseEntity。现在,我们将深度挖掘它的潜力,学习如何将我们自定义的 Result<T> 对象与精确的 HTTP 状态码结合,构建出真正专业的 API 响应。

我们需要明确两者之间的职责分工:

  • Result<T> (响应体 Body): 负责承载 业务层面 的信息。codemessage 反映的是业务的成功、失败或校验结果。
  • ResponseEntity (HTTP 响应): 负责承载 HTTP 协议层面 的信息。它的 Status Code 反映的是 HTTP 请求本身的处理结果(如 200 OK, 404 Not Found, 500 Internal Server Error)。

最佳实践映射关系:

操作场景HTTP Status (ResponseEntity)响应体 (Result Body)Controller 返回类型
查询成功 (GET)200 OKResult.success(data)ResponseEntity<Result<UserVO>>
创建成功 (POST)201 CreatedResult.success()ResponseEntity<Result<Void>>
更新成功 (PUT)200 OKResult.success(updatedData)ResponseEntity<Result<UserVO>>
删除成功 (DELETE)204 No Content(无响应体)ResponseEntity<Void>
客户端错误400 Bad RequestResult.error(错误信息)ResponseEntity<Result<Void>>
资源未找到404 Not FoundResult.error("用户不存在")ResponseEntity<Result<Void>>
服务端异常500 Internal Server ErrorResult.error("系统异常")ResponseEntity<Result<Void>>

现在,我们就根据上方的实践关系来修改我们的 Controller 业务代码

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

package com.example.springbootdemo.controller;

import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

// 通过构造函数注入 UserService 依赖
private final UserService userService;

@GetMapping
@Operation(summary = "查询所有用户列表")
public ResponseEntity<Result<List<UserVO>>> getAllUsers(
@Parameter(
description = "分页查询参数",
required = true,
schema = @Schema(implementation = UserPageQuery.class)
)
UserPageQuery query) {
// 调用服务层获取用户列表数据
List<UserVO> users = userService.findAllUsers(query);
// 使用 Result.success()包装数据并返回统一格式的响应
return ResponseEntity.ok(Result.success(users));
}

@GetMapping("/{id}")
@Operation(summary = "根据ID查询单个用户")
public ResponseEntity<Result<UserVO>> getUserById(
@Parameter(description = "用户ID", required = true, example = "1")
@PathVariable Long id
) {
// 调用服务层根据 ID 查询用户
UserVO userVO = userService.findUserById(id);

// 判断用户是否存在并返回相应结果
if (userVO != null) {
// 用户存在,返回成功结果
return ResponseEntity.ok(Result.success(userVO));
} else {
// 用户不存在,返回错误信息 - 注意无论是成功或是失败都最好返回 200 响应码
return ResponseEntity.ok(Result.error("用户不存在"));
}
}
}

现在我们打开 http://localhost: 8080/swagger-ui/index.html 即可查看到如下的内容信息:

可以看到我们的返回值完全符合大型级别返回规范,且前端可以根据不同的返回状态码和信息去接收数据!

image-20250816114701281


2.6. [C] 新增功能开发 (POST)

完成了查询(Read)功能后,我们来继续实现 CRUD 中的创建(Create)功能。我们将严格遵循之前确立的 分层DTO“单元测试 -> API 测试” 的严谨流程。

2.6.1. DTO 设计与 Service 层开发

1. DTO 设计

为“新增用户”操作创建一个专门的 UserSaveDTO 是一个非常好的实践。它的职责是精确地承载创建用户时 所有必需 的、且 允许客户端提供 的数据。

为什么需要独立的 UserSaveDTO

  • 安全性: 防止客户端通过 API 请求传递一些不应由他们设置的字段,例如 id(应由数据库生成)、status(可能有默认值或由特定逻辑控制)等。
  • 职责单一: DTO 的字段完全为“新增”这个业务场景服务。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.springbootdemo.dto.User;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "用户新增数据传输对象")
public class UserSaveDTO {

@Schema(description = "用户名", required = true, example = "newuser")
private String username;

@Schema(description = "密码", required = true, example = "123456")
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
private String email;
}

2. Service 层功能实现

首先,更新 UserService 接口,添加 saveUser 方法。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.UserSaveDTO; // 新增导入
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {

UserVO findUserById(Long id);

List<UserVO> findAllUsers(UserPageQuery query);

/**
* 新增用户
* @param dto 用户新增数据
* @return 新增用户的 ID
*/
Long saveUser(UserSaveDTO dto);
}

接下来,在 UserServiceImpl 中实现该方法。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.springbootdemo.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.UserSaveDTO; // 新增导入
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.mapper.UserMapper;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import lombok.RequiredArgsConstructor; // 使用构造函数注入
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor // Lombok 注解,为所有 final 字段生成一个构造函数
public class UserServiceImpl implements UserService {

private final UserMapper userMapper; // 使用 final 和构造函数注入

// ... findUserById 和 findAllUsers 方法保持不变 ...

@Override
public Long saveUser(UserSaveDTO dto) {
// 1. 业务校验:断言用户名不存在
User existingUser = userMapper.selectOne(
// 这里的条件转换为 SQL: SELECT * FROM t_user WHERE username = ?
new QueryWrapper<User>().lambda().eq(User::getUsername, dto.getUsername())
);
// 使用 HuTool 提供的断言工具优雅的抛出异常
Assert.isNull(existingUser, "用户名 [{}] 已存在,请更换!", dto.getUsername());

// 2. DTO -> PO: 使用 Convert 一步到位,创建并拷贝属性
User user = Convert.convert(User.class, dto);

// 3. 设置默认值
user.setStatus(1); // 默认为正常状态

// 4. 插入数据
userMapper.insert(user);

// 5. 返回 ID
return user.getId();
}

// ... convertToVO 方法保持不变 ...
}

这里细心的朋友可能会发现,我在代码里面使用了 Hutool 中的 Convert.convert 而不使用原来的 ConvertToVo 方法,其实他们的差距就如下表所示

简单来说:

  • Convert.convert(User.class, dto) 能成功,是因为 UserSaveDTOUser 之间的属性(如 username, password)是同名同类型的,可以直接复制。
  • Convert.convert(UserVO.class, user) 无法 正确生成 statusText 字段,因为它不知道 User 里的 status 字段和 UserVO 里的 statusText 字段之间存在 1 -> "正常" 的映射关系。

2.6.2. Service 层单元测试

我们必须为新的 saveUser 方法编写单元测试,以确保其逻辑的正确性。

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.springbootdemo.service;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.User.UserSaveDTO;
import com.example.springbootdemo.vo.UserVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest // 启动完整的 Spring Boot 应用上下文来进行测试
public class UserServiceTest {

@Autowired
private UserService userService;

@Test
void testSaveUser() {
// 1. 准备 DTO
UserSaveDTO dto = new UserSaveDTO();
// 使用 Hutool 的 IdUtil 生成一个随机的用户名,防止重复
String username = IdUtil.fastSimpleUUID();
dto.setUsername(username);
dto.setPassword("123456");
dto.setEmail(username + "@test.com");
// 2. 调用 Service 方法
Long newUserId = userService.saveUser(dto);
// 3. 断言返回的 ID 不为 null
Assertions.assertNotNull(newUserId);
// 4. 从数据库中查出新用户并进行验证
UserVO savedUser = userService.findUserById(newUserId);
Assertions.assertNotNull(savedUser);
Assertions.assertEquals(username, savedUser.getName());
System.out.println(StrUtil.format("用户 {} 保存成功", username));
}


@Test
void testSaveUser_UsernameExists() {
// 准备一个已存在的用户名的 DTO
UserSaveDTO dto = new UserSaveDTO();
dto.setUsername("张三"); // 假设 "张三" 已存在
dto.setPassword("123456");
userService.saveUser(dto); // java.lang.IllegalArgumentException: 用户名 [张三] 已存在,请更换!
}
}

2.6.3. Controller 层开发与 API 测试 (SpringDoc)

Service 层逻辑验证无误后,我们来创建对应的 Controller 接口。

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.springbootdemo.controller;

import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.dto.UserSaveDTO;
// ... 其他 import ...
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

// ... 已有的 GET 接口 ...

@Operation(summary = "新增用户")
@PostMapping
public ResponseEntity<Result<Long>> saveUser(@RequestBody UserSaveDTO dto) {
Long userId = userService.saveUser(dto);
// 对于创建操作 (POST),RESTful 风格推荐返回 HTTP 状态码 201 Created
// 我们将新创建的资源 ID 作为 data 返回
return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId));
}
}

API 接口测试 (使用 SpringDoc)

重启您的 Spring Boot 应用,并访问 http://localhost:8080/swagger-ui.html。您会看到新增的 POST /users 接口。

  1. 展开 POST /users 接口。
  2. 点击 “Try it out”
  3. Request body 的 JSON 编辑区中,输入以下内容:
    1
    2
    3
    4
    5
    {
    "username": "new_user_from_swagger",
    "password": "password123",
    "email": "swagger@example.com"
    }
  4. 点击 “Execute”

您将会看到如下的响应结果,这表明用户已成功创建。

data 字段中的 21 是数据库为新用户生成的自增 ID,您的实际结果可能会不同。同时请注意,我们遵循 RESTful 最佳实践,为“创建成功”返回了 201 Created 状态码。


2.7. [U] 修改功能开发 (PUT)

完成了新增(Create)和查询(Read)之后,我们来继续实现 CRUD 中的更新(Update)功能。我们将继续遵循之前确立的严谨流程。

2.7.1. DTO 设计与 Service 层开发

1. DTO 设计

与新增操作类似,为“修改用户”创建一个专门的 UserUpdateDTO 也至关重要。

为什么需要独立的 UserUpdateDTO

  • 明确意图: DTO 的字段清晰地表明了哪些信息是允许被修改的。例如,我们通常不允许用户修改他们的 username,这个规则就可以在 DTO 的字段定义中体现。
  • 数据绑定: DTO 中必须包含 id 字段,以便 Service 层知道要更新的是哪一条记录。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserUpdateDTO.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.springbootdemo.dto.User;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "用户修改数据传输对象")
public class UserUpdateDTO {

@Schema(description = "用户ID", required = true, example = "1")
private Long id;

// 注意:我们在这里没有提供 username 字段,意味着我们不允许通过此接口修改用户名

@Schema(description = "密码", example = "new_password_123")
private String password;

@Schema(description = "邮箱", example = "new_email@example.com")
private String email;

@Schema(description = "状态: 1-正常, 2-禁用", example = "2")
private Integer status;
}

2. Service 层功能实现

首先,更新 UserService 接口。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.User.UserSaveDTO;
import com.example.springbootdemo.dto.User.UserUpdateDTO; // 新增导入
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {

// ... 已有方法 ...

Long saveUser(UserSaveDTO dto);

/**
* 修改用户
* @param dto 用户修改数据
*/
void updateUser(UserUpdateDTO dto);
}

接下来,在 UserServiceImpl 中实现该方法。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.example.springbootdemo.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Assert;
// ... 其他 import ...
import com.example.springbootdemo.dto.User.UserUpdateDTO;
import com.example.springbootdemo.entity.User;
// ...

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

// ... 已有方法 ...

@Override
public void updateUser(UserUpdateDTO dto) {
// 1. 业务校验:断言要修改的用户必须存在
User user = userMapper.selectById(dto.getId());
Assert.notNull(user, "用户 ID [{}] 不存在,无法修改!", dto.getId());

// 2. DTO -> PO: 使用 Convert 转换
User updatedUser = Convert.convert(User.class, dto);

// 3. 调用 Mapper 方法更新数据
// updateById 方法会根据传入实体的主键 ID 去更新数据
userMapper.updateById(updatedUser);
}

// ... convertToVO 方法保持不变 ...
}

2.7.2. Service 层单元测试

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.springbootdemo.service;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.User.UserSaveDTO;
import com.example.springbootdemo.dto.User.UserUpdateDTO;
import com.example.springbootdemo.vo.UserVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest // 启动完整的 Spring Boot 应用上下文来进行测试
public class UserServiceTest {

@Autowired
private UserService userService;

@Test
void testUpdateUser() {
// 1. 准备一个待更新的 DTO
UserUpdateDTO dto = new UserUpdateDTO();
dto.setId(1L);
String newEmail = IdUtil.fastSimpleUUID() + "@updated.com";
dto.setEmail(newEmail);
dto.setStatus(2); // 将状态更新为“已禁用”
// 2. 调用更新方法
userService.updateUser(dto);
// 3. 重新查询该用户进行验证
UserVO updatedUser = userService.findUserById(1L);
System.out.println(StrUtil.format("用户 {} 更新成功", updatedUser.getName()));
}


@Test
void testUpdateUser_NotFound() {
// 准备一个不存在的用户 ID 的 DTO
UserUpdateDTO dto = new UserUpdateDTO();
dto.setId(9999L);
dto.setEmail("test@test.com");

// 断言调用 updateUser 会因为找不到用户而抛出异常
// java.lang.IllegalArgumentException: 用户 ID [9999] 不存在,无法修改!
Assertions.assertThrows(IllegalArgumentException.class, () -> {
userService.updateUser(dto);
});
}
}

2.7.3. Controller 层开发与 API 测试 (SpringDoc)

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.example.springbootdemo.controller;

import com.example.springbootdemo.common.Result;
// ... 其他 import ...
import com.example.springbootdemo.dto.User.UserUpdateDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

// ... 已有的 GET 和 POST 接口 ...

@Operation(summary = "修改用户信息")
@PutMapping
public ResponseEntity<Result<Void>> updateUser(@RequestBody UserUpdateDTO dto) {
userService.updateUser(dto);
// 对于修改操作(PUT),如果成功,通常返回 200 OK 和一个成功的业务响应
return ResponseEntity.ok(Result.success());
}
}

API 接口测试 (使用 SpringDoc)

重启您的 Spring Boot 应用,并访问 http://localhost:8080/swagger-ui.html。您会看到新增的 PUT /users 接口。

  1. 展开 PUT /users 接口。
  2. 点击 “Try it out”
  3. Request body 的 JSON 编辑区中,输入以下内容来修改 ID 为 2 的用户:
    1
    2
    3
    4
    5
    {
    "id": 2,
    "email": "lisi_updated@example.com",
    "status": 2
    }
  4. 点击 “Execute”

您将会看到一个 200 OK 的成功响应,表示用户数据已成功更新。您可以再次调用 GET /users/2 接口来验证数据是否真的发生了变化。


2.8. [D] 删除功能开发 (DELETE)

现在,我们来实现用户管理 CRUD 功能的最后一部分:删除指定的用户。我们将继续遵循之前确立的严谨流程,确保代码的健壮性和专业性。

2.8.1. Service 层开发与单元测试

对于删除操作,我们不需要设计新的 DTO,因为通常只需要一个 id 即可唯一确定要删除的资源。

1. Service 层功能实现

首先,在 UserService 接口中添加我们的新方法。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.springbootdemo.service;

// ... other imports ...

public interface UserService {

// ... 已有方法 ...

void updateUser(UserUpdateDTO dto);

/**
* 根据 ID 删除用户
* @param id 用户 ID
*/
void deleteUserById(Long id);
}

接下来,在 UserServiceImpl 中实现该方法。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.springbootdemo.service.impl;

import cn.hutool.core.lang.Assert;
// ... other imports ...

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

// ... 已有方法 ...

@Override
public void deleteUserById(Long id) {
// 1. 业务校验:断言要删除的用户必须存在
User user = userMapper.selectById(id);
Assert.notNull(user, "用户 ID [{}] 不存在,无法删除!", id);

// 2. 调用 Mapper 方法删除数据
userMapper.deleteById(id);
}

// ... convertToVO 方法保持不变 ...
}

2. Service 层单元测试

为确保删除逻辑及其前置校验的正确性,我们需要编写相应的单元测试。

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example.springbootdemo.service;

import cn.hutool.core.util.IdUtil;
// ... other imports ...
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.mapper.UserMapper;

@SpringBootTest
public class UserServiceTest {

@Autowired
private UserService userService;

@Autowired
private UserMapper userMapper; // 注入 UserMapper 以便准备测试数据

// ... 已有测试方法 ...

@Test
void testDeleteUser() {
// 1. 准备一条可供删除的测试数据
User testUser = new User();
testUser.setUsername(IdUtil.fastSimpleUUID());
testUser.setPassword("to_be_deleted");
userMapper.insert(testUser);
Long newUserId = testUser.getId();
Assertions.assertNotNull(newUserId, "测试数据插入失败");

// 2. 调用删除方法
userService.deleteUserById(newUserId);

// 3. 验证用户是否真的被删除
UserVO deletedUser = userService.findUserById(newUserId);
Assertions.assertNull(deletedUser);
System.out.println(StrUtil.format("用户 ID [{}] 删除成功", newUserId));
}

@Test
void testDeleteUser_NotFound() {
// 准备一个不存在的用户 ID
Long nonExistentUserId = 9999L;

// 断言调用 deleteUserById 会因为找不到用户而抛出异常
// java.lang.IllegalArgumentException: 用户 ID [9999] 不存在,无法删除!
Assertions.assertThrows(IllegalArgumentException.class, () -> {
userService.deleteUserById(nonExistentUserId);
});
}
}

2.8.2. Controller 层开发与 API 测试 (SpringDoc)

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.springbootdemo.controller;

// ... other imports ...
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

// ... 已有的 GET, POST, PUT 接口 ...

@Operation(summary = "根据ID删除用户")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(
@Parameter(description = "用户ID", required = true, example = "20") @PathVariable Long id) {
userService.deleteUserById(id);
// 对于删除操作(DELETE),如果成功,RESTful 风格的最佳实践是返回 204 No Content
return ResponseEntity.noContent().build();
}
}

RESTful 最佳实践: 对于 DELETE 操作,如果成功执行,最佳实践是返回 HTTP 204 No Content 状态码。这个状态码表示服务器成功处理了请求,但响应体中 没有内容ResponseEntity.noContent().build() 正是用于构建这种标准响应。注意,由于没有响应体,我们返回的类型是 ResponseEntity<Void>,也就不再需要包装 Result 对象了。