1.创建项目

保持默认创建(项目)


修改pom.xml配置项目依赖
javascript
1.将 Spring Boot 版本改为 3.4.9
<version>3.4.9</version>
2.把基础依赖替换成 Web 依赖
找到
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
替换为:
<!-- Web 开发:提供 HTTP 接口、JSON 转换和内嵌 Tomcat。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
starter 可以理解为"一套配好的依赖组合"。引入 Web Starter 后,就可以编写 Controller 接口。它已经间接包含基础 Starter,所以不用重复添加。
3.保存并重新加载 Maven
在 IDEA 中:
按 Ctrl + S 保存。
打开右侧 Maven 面板。
点击 Reload All Maven Projects(重新加载所有 Maven 项目)。
等待依赖下载完成。
报错调试:

首先修改project structure 
然后修改Settings
javascript
Build, Execution, Deployment
→ Compiler
→ Java Compiler



依赖啥的都装上了,就是爆红,说缺少依赖





选
idea日志查看:左上角 ☰ → Help → Show Log in Explorer


2.第一个接口3
java
接下来写第一个接口:让浏览器访问 /api/hello,返回"你好,天工"。
1.创建 controller 包
在左侧项目目录中,右键:
com.example.skyforgelearning
选择 New → Package,输入:
controller
创建后的完整包名是:
com.example.skyforgelearning.controller
把它放在启动类所在包的下面,Spring Boot 才能默认扫描到。
2.创建 HelloController 类
右键刚创建的 controller 包,选择 New → Java Class,输入:
HelloController
将文件内容改为:
package com.example.skyforgelearning.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 入门接口,验证 HTTP 请求与响应。 */
@RestController
@RequestMapping("/api")
public class HelloController {
/** 返回欢迎信息。 */
@GetMapping("/hello")
public String hello() {
return "你好,天工";
}
}
三个注解先这样理解:
注解 作用
@RestController 声明这是接收请求、返回响应内容的类
@RequestMapping("/api") 给这个类的接口设置公共路径
@GetMapping("/hello") 让 GET 请求调用下面的方法
两个路径拼起来,就是:
/api + /hello = /api/hello
3.重启应用
保存文件,停止原来的运行进程,再运行 SkyForgeLearningApplication。当前还没有配置自动重启,新增代码后需要重新启动。
4.用浏览器访问
如果启动日志显示端口为 8080,打开:
http://localhost:8080/api/hello
预期页面显示:
你好,天工
3.让接口接收参数,查询参数@RequestParam。
java
下一步学习:让接口接收参数,根据你传入的名字返回问候语。
例如访问:
http://localhost:8080/api/hello?name=张三
返回:
你好,张三
1.修改 HelloController
把原来的代码改成:
package com.example.skyforgelearning.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 入门接口,学习 HTTP 请求与响应。 */
@RestController
@RequestMapping("/api")
public class HelloController {
/** 根据请求中的姓名返回问候语。 */
@GetMapping("/hello")
public String hello(
@RequestParam(name = "name", defaultValue = "天工") String name) {
return "你好," + name;
}
}
这次新增了:
@RequestParam(name = "name", defaultValue = "天工") String name
拆开理解:
部分 含义
@RequestParam 从 URL 查询参数中取值
name = "name" 读取名为 name 的参数
defaultValue = "天工" 没有提供参数或参数值为空时,使用"天工"
String name 将参数作为字符串传入 Java 方法
这里注解中的 "name" 是 URL 参数名,后面的变量 name 是 Java 方法参数名。我们明确写出 URL 参数名,便于理解它们的对应关系。
2.重启并验证
保存代码,重启应用,分别访问:
地址 预期返回
http://localhost:8080/api/hello 你好,天工
http://localhost:8080/api/hello?name=张三 你好,张三
http://localhost:8080/api/hello?name=李四 你好,李四
http://localhost:8080/api/hello?name= 你好,天工
URL 中:
/api/hello?name=张三
└─接口路径 └─查询参数
? 表示查询参数开始;以后传多个参数时,用 & 连接,例如 ?name=张三&age=18。不过当前方法只读取 name。
4.路径参数 @PathVariable
java
接下来学习 路径参数 @PathVariable。它在查询用户、项目、工单详情时很常见。
你已经学过查询参数:
/api/hello?name=张三&age=20
路径参数则把值放进地址中:
/api/users/1
这里的 1 表示要查询的用户 ID。
1.创建 UserController
在已有的 controller 包下,新建 Java 类 UserController:
package com.example.skyforgelearning.controller;
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("/api/users")
public class UserController {
/** 根据用户 ID 返回查询提示,暂不查询数据库。 */
@GetMapping("/{userId}")
public String getUser(
@PathVariable(name = "userId") Long userId) {
return "你正在查询 ID 为 " + userId + " 的用户";
}
}
原来的 HelloController 保留,两个 Controller 可以同时存在。
2.理解参数是怎么传进来的
这两处通过名称对应:
@GetMapping("/{userId}")
@PathVariable(name = "userId") Long userId
5.返回 JSON 对象,让前端拿到结构化的数据
java
返回 JSON 对象,让前端拿到结构化的数据。
目前接口返回的是一整段文字:
你正在查询 ID 为 100 的用户,是否显示手机号码true
如果前端要分别展示用户 ID、姓名、手机号,返回对象会更方便:
{
"id": 100,
"name": "张三",
"phone": "13800138000"
}
1.创建用户响应类
在 com.example.skyforgelearning 下创建 dto 包,再创建 UserResponse 类:
package com.example.skyforgelearning.dto;
/** 用户详情响应。 */
public class UserResponse {
/** 用户 ID。 */
private Long id;
/** 用户姓名。 */
private String name;
/** 手机号。 */
private String phone;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
}
这里先手写 getter、setter,之后再学用 Lombok 简化。
- private:限制外部直接访问字段。
- setId(...):给对象的 ID 赋值。
- getId():读取对象的 ID。
- DTO:用于传递数据的对象,这个类专门描述接口返回的数据。
2.修改 getUser 方法
在 UserController 顶部添加:
import com.example.skyforgelearning.dto.UserResponse;
把原来的方法替换为:
/** 根据用户 ID 返回模拟的用户详情。 */
@GetMapping("/user/{userId}")
public UserResponse getUser(
@PathVariable(name = "userId") Long userId,
@RequestParam(name = "showPhone", defaultValue = "false")
Boolean showPhone) {
UserResponse user = new UserResponse();
user.setId(userId);
user.setName("张三");
if (Boolean.TRUE.equals(showPhone)) {
user.setPhone("13800138000");
}
return user;
}
注意返回类型发生了变化:
public String getUser(...) // 原来返回字符串
public UserResponse getUser(...) // 现在返回用户对象
Boolean.TRUE.equals(showPhone) 用于判断值是否为 true,即使变量为 null 也不会报空指针错误。
6.POST 请求和 @RequestBody
java
1.创建新增用户请求类
在 dto 包中,新建 CreateUserRequest:
package com.example.skyforgelearning.dto;
/** 新增用户请求。 */
public class CreateUserRequest {
/** 用户姓名。 */
private String name;
/** 手机号。 */
private String phone;
/** 年龄。 */
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
这里使用 Integer,这样没有提交年龄时,值可以是 null,能与年龄 0 区分。
2.在 UserController 中新增方法
保留原来的查询方法,在同一个类中新增:
/** 接收新增用户资料,暂时原样返回,不保存。 */
@PostMapping("/user")
public CreateUserRequest createUser(
@RequestBody CreateUserRequest request) {
return request;
}
文件顶部添加导入:
import com.example.skyforgelearning.dto.CreateUserRequest;
因为你的类上已有:
@RequestMapping("/api")
所以新增接口的完整地址是:
POST http://localhost:8080/api/user
两个新注解的作用:
注解 作用
@PostMapping("/user") 接收指定路径的 POST 请求
@RequestBody 将请求体中的 JSON 转换为 Java 对象
3.使用 Apifox 发送请求
保存并重启应用,然后在 Apifox 中:
1. 新建 HTTP 请求。
2. 请求方式选择 POST。
3. 地址填写 http://localhost:8080/api/user。
4. 在 Body 中选择 JSON。
5. 填入以下内容并发送:
{
"name": "李四",
"phone": "13900139000",
"age": 25
}
确认请求头包含:
Content-Type: application/json
选择 JSON 请求体时,Apifox 通常会自动添加。
预期返回同样的数据:
{
"name": "李四",
"phone": "13900139000",
"age": 25
}
浏览器地址栏直接访问会发送 GET 请求,测试这个 POST 接口要使用 Apifox 等工具。
4.理解这次的数据流
Apifox 提交 JSON
↓
@RequestBody 配合 Jackson 转换
↓
CreateUserRequest 对象
↓
createUser 方法返回对象
↓
Jackson 转换为 JSON 响应
请求类与之前的响应类职责不同:
- CreateUserRequest:描述调用方提交什么。
- UserResponse:描述后端返回什么。
这次原样返回请求对象是为了观察转换过程;后面实现真正的新增逻辑时,会返回响应对象。
7.参数校验
java
例如,这份 JSON 能被转换成 Java 对象:
{
"name": "",
"phone": "123",
"age": -5
}
但姓名为空、手机号格式错误、年龄为负数,我们希望后端拒绝它。
1.添加校验依赖
在 pom.xml 的 <dependencies> 中新增:
<!-- 请求参数校验。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
保存后重新加载 Maven,等待依赖导入完成。
2.给请求字段添加规则
打开 CreateUserRequest.java,添加以下 import:
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
将字段部分修改为下面这样,原来的 getter、setter 保留:
/** 用户姓名。 */
@NotBlank(message = "姓名不能为空")
private String name;
/** 手机号。 */
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9][0-9]{9}$", message = "手机号格式不正确")
private String phone;
/** 年龄。 */
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
private Integer age;
各注解的含义:
注解 作用
@NotBlank 字符串不能为 null、空字符串或全是空白
@NotNull 值不能为 null
@Pattern 字符串必须符合正则表达式
@Min、`@Max` 限制数值范围
手机号正则这里只做常见的中国大陆手机号格式练习,不能证明号码真实存在或属于提交者。
注意:`@Min`、`@Max` 允许字段为 null,因此年龄必填还需要配合 @NotNull。
3.在 Controller 中触发校验
在 UserController 中添加:
import jakarta.validation.Valid;
然后将新增接口修改为:
/** 校验新增用户资料,通过后原样返回,暂不保存。 */
@PostMapping("/user")
public CreateUserRequest createUser(
@Valid @RequestBody CreateUserRequest request) {
return request;
}
这两个注解分工不同:
@RequestBody:把 JSON 转成 Java 对象
@Valid:按对象字段上的规则进行校验
校验不通过时,请求会在进入这个方法体之前被拒绝。
4.重启并使用 Apifox 验证
每次只修改一个字段,方便看清是哪条规则生效:
测试情况 预期
姓名"李四"、手机号 13900139000、年龄 25 HTTP 200,正常返回
姓名改为 "" HTTP 400
姓名改为 " " HTTP 400
手机号改为 "123" HTTP 400
年龄改为 -5 HTTP 400
年龄改为 151 HTTP 400
删除年龄字段 HTTP 400
这一节先观察 HTTP 状态码。 默认错误响应不一定会展示我们填写的中文提示,不要因为没看到中文就认为校验失效。
8.全局异常处理
java
接下来学习 全局异常处理:把刚才的校验错误整理成前端能直接展示的 JSON。
例如提交空姓名,返回:
{
"message": "请求参数校验失败",
"errors": [
"name:姓名不能为空"
]
}
HTTP 状态码仍然是 400。
1.创建错误响应对象
在 dto 包下,新建 ErrorResponse.java:
package com.example.skyforgelearning.dto;
import java.util.List;
/** 接口错误响应。 */
public class ErrorResponse {
/** 错误概述。 */
private final String message;
/** 具体错误列表。 */
private final List<String> errors;
public ErrorResponse(String message, List<String> errors) {
this.message = message;
this.errors = errors;
}
public String getMessage() {
return message;
}
public List<String> getErrors() {
return errors;
}
}
这里有两个新知识:
- List<String>:字符串列表,可以保存多个字段的错误。
- final:字段赋值后不能重新赋值。本例通过构造方法一次性设置,不需要 setter。
Jackson 可以通过 getter 读取这些属性,生成 JSON。
2.创建全局异常处理类
在 com.example.skyforgelearning 下创建 exception 包,再创建 GlobalExceptionHandler.java:
package com.example.skyforgelearning.exception;
import com.example.skyforgelearning.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.ArrayList;
import java.util.List;
/** 统一处理 Controller 请求中的异常。 */
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 将请求体字段校验错误转换为清晰的错误响应。 */
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleRequestValidation(
MethodArgumentNotValidException exception) {
List<String> errors = new ArrayList<>();
for (FieldError fieldError
: exception.getBindingResult().getFieldErrors()) {
String errorMessage = fieldError.getField()
+ ":" + fieldError.getDefaultMessage();
errors.add(errorMessage);
}
return new ErrorResponse("请求参数校验失败", errors);
}
}
新增的包结构是:
com.example.skyforgelearning
├─ controller
│ └─ UserController
├─ dto
│ ├─ CreateUserRequest
│ ├─ UserResponse
│ └─ ErrorResponse
├─ exception
│ └─ GlobalExceptionHandler
└─ SkyForgeLearningApplication
3.理解三个注解
注解 作用
@RestControllerAdvice 声明一个为 Controller 统一处理异常等问题的类,返回值写入响应体
ExceptionHandler(...) 指定这个方法负责处理哪一种异常
@ResponseStatus(HttpStatus.BAD_REQUEST) 将响应状态码设置为 400
本次处理的异常是:
MethodArgumentNotValidException
在当前接口中,`@Valid @RequestBody` 检查字段不通过时,会产生这种异常。
4.理解错误是怎样被取出来的
这段代码:
exception.getBindingResult().getFieldErrors()
取得字段校验错误列表。
循环中的:
fieldError.getField()
取得字段名,例如 name。
fieldError.getDefaultMessage()
取得你写在注解里的提示,例如"姓名不能为空"。
最终拼成:
name:姓名不能为空
5.保留 Controller 原来的写法
新增接口继续保持:
@PostMapping("/user")
public CreateUserRequest createUser(
@Valid @RequestBody CreateUserRequest request) {
return request;
}
不需要在这里手动调用异常处理类,也不需要添加 try-catch。Spring 会把匹配的异常交给它处理。
6.重启后验证
在 Apifox 中发送:
POST http://localhost:8080/api/user
请求体:
{
"name": "",
"phone": "123",
"age": -5
}
预期状态码为 400,响应类似:
{
"message": "请求参数校验失败",
"errors": [
"name:姓名不能为空",
"phone:手机号格式不正确",
"age:年龄不能小于0"
]
}
错误排列顺序可能不同,属于正常情况。
再发送一份正确数据,确认仍能正常返回:
{
"name": "李四",
"phone": "13900139000",
"age": 25
}
这一节只处理字段校验失败。JSON 写错、给年龄传 "abc" 等问题属于其他异常,我们后面再逐步补充。
9.统一响应结构 Result<T>
java
我们先统一成一个简化格式。成功时:
{
"code": 0,
"message": "成功",
"data": {
"name": "李四",
"phone": "13900139000",
"age": 25
}
}
校验失败时:
{
"code": 400,
"message": "请求参数校验失败",
"data": [
"name:姓名不能为空"
]
}
这里的 code 是响应体中的业务字段;HTTP 状态码仍单独设置。这一节使用简化字段,后续再与原项目的 result_code 等字段对齐。
1.创建 Result 类
在 com.example.skyforgelearning 下创建 common 包,然后新建 Result.java:
package com.example.skyforgelearning.common;
/** 统一接口响应。 */
public class Result<T> {
/** 结果码:0 表示成功。 */
private final int code;
/** 结果说明。 */
private final String message;
/** 响应数据。 */
private final T data;
public Result(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
public T getData() {
return data;
}
}
T 是泛型类型参数,可以理解为"数据类型的占位符":
写法 data 的类型
Result<UserResponse> 用户响应对象
Result<CreateUserRequest> 新增用户请求对象
Result<List<String>> 字符串列表
Result<String> 字符串
一个 Result 类就可以包装不同类型的数据。
2.修改新增用户接口
在 UserController 中导入:
import com.example.skyforgelearning.common.Result;
修改新增方法:
/** 校验用户资料,暂时返回收到的数据,不保存。 */
@PostMapping("/user")
public Result<CreateUserRequest> createUser(
@Valid @RequestBody CreateUserRequest request) {
return new Result<>(0, "成功", request);
}
这里:
new Result<>(0, "成功", request)
表示创建一个统一响应对象。`<>` 让 Java 根据上下文推断泛型类型,不必重复写出 CreateUserRequest。
3.修改查询用户接口
将之前的查询方法改为:
/** 根据用户 ID 返回模拟的用户详情。 */
@GetMapping("/user/{userId}")
public Result<UserResponse> getUser(
@PathVariable(name = "userId") Long userId,
@RequestParam(name = "showPhone", defaultValue = "false")
Boolean showPhone) {
UserResponse user = new UserResponse();
user.setId(userId);
user.setName("张三");
if (Boolean.TRUE.equals(showPhone)) {
user.setPhone("13800138000");
}
return new Result<>(0, "成功", user);
}
如果你之前添加过年龄赋值,可以继续保留。
4.修改全局异常处理
在 GlobalExceptionHandler 中删除原来的 ErrorResponse 导入,添加:
import com.example.skyforgelearning.common.Result;
将原来的处理方法替换为:
/** 将字段校验失败转换为统一响应。 */
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<List<String>> handleRequestValidation(
MethodArgumentNotValidException exception) {
List<String> errors = new ArrayList<>();
for (FieldError fieldError
: exception.getBindingResult().getFieldErrors()) {
String errorMessage = fieldError.getField()
+ ":" + fieldError.getDefaultMessage();
errors.add(errorMessage);
}
return new Result<>(400, "请求参数校验失败", errors);
}
这个方法仍通过:
@ResponseStatus(HttpStatus.BAD_REQUEST)
返回 HTTP 400。只在 JSON 中填写 code: 400,不会自动改变 HTTP 状态码。
之前的 ErrorResponse 已经不再使用,确认没有其他引用后可以删除。
5.重启并验证
操作 HTTP 状态码 响应体
GET /api/user/100?showPhone=true 200 code 为 0,`data` 是用户对象
POST /api/user,提交正确资料 200 code 为 0,`data` 是提交的资料
POST /api/user,提交空姓名 400 code 为 400,`data` 是错误列表
如果保留了最初的 Hello 接口,它仍返回文字,不影响这次练习。
这一节先掌握:Result<T> 负责统一外层结构,`T` 决定里面的数据类型,HTTP 状态码由响应配置决定。
10.Service 分层:把业务逻辑从 Controller 中移出去
java
你现在的 getUser 方法既接收请求,又创建用户数据。随着业务增加,Controller 会越来越复杂。我们先把"查询用户"的逻辑放到 Service 中。
浏览器请求 → Controller 接收参数 → Service 提供用户数据 → Controller 返回响应
1.创建 service 包和类
在 com.example.skyforgelearning 下创建 service 包,再创建 UserService.java:
package com.example.skyforgelearning.service;
import com.example.skyforgelearning.dto.UserResponse;
import org.springframework.stereotype.Service;
/** 用户业务服务。 */
@Service
public class UserService {
/** 根据用户 ID 返回模拟的用户详情。 */
public UserResponse getUser(Long userId, boolean showPhone) {
UserResponse user = new UserResponse();
user.setId(userId);
user.setName("张三");
if (showPhone) {
user.setPhone("13800138000");
}
return user;
}
}
如果之前添加过年龄赋值,可以一并移到这里。
@Service 表示这个类承担业务服务职责,也让 Spring 扫描并管理它。这个阶段先使用一个具体类,后面有实际需要时再学习接口与实现类。
2.在 Controller 中接收 UserService
在 UserController 中添加 import:
import com.example.skyforgelearning.service.UserService;
然后在类中添加字段和构造方法:
/** 用户业务服务。 */
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
这叫 构造方法注入。
你不用自己写:
new UserService()
Spring 会找到它管理的 UserService 对象,在创建 UserController 时,通过构造方法传进来。当前类只有一个构造方法,不需要添加 @Autowired。
3.简化查询方法
将 Controller 中原来的 getUser 方法替换为:
/** 查询用户详情。 */
@GetMapping("/user/{userId}")
public Result<UserResponse> getUser(
@PathVariable(name = "userId") Long userId,
@RequestParam(name = "showPhone", defaultValue = "false")
Boolean showPhone) {
UserResponse user = userService.getUser(
userId, Boolean.TRUE.equals(showPhone));
return new Result<>(0, "成功", user);
}
新增用户的 POST 方法先保持不变。
这次职责分工是:
位置 负责什么
Controller 接收路径、查询参数,调用 Service,包装响应
Service 提供用户数据,决定是否填充手机号
UserResponse 描述返回给调用方的用户字段
Result<T> 统一响应外层结构
这里的手机号开关仍然只是练习,实际查看权限以后再加入。
4.理解完整调用过程
访问:
GET http://localhost:8080/api/user/100?showPhone=true
执行顺序:
1. Controller 收到 userId=100、showPhone=true
2. Controller 调用 userService.getUser(...)
3. Service 创建并返回 UserResponse
4. Controller 用 Result 包装用户对象
5. Spring 将结果转换为 JSON
11.先用内存集合保存,把新增、查询和异常处理连起来,再接数据库
java
先用内存集合保存,把新增、查询和异常处理连起来,再接数据库。
1.创建业务异常
在 exception 包中新建 UserNotFoundException.java:
package com.example.skyforgelearning.exception;
/** 查询的用户不存在。 */
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long userId) {
super("用户不存在,ID:" + userId);
}
}
extends RuntimeException 表示这是一个运行时异常。Service 查询不到用户时,抛出它,让全局异常处理类生成响应。
2.修改 UserService
将 UserService 替换为:
package com.example.skyforgelearning.service;
import com.example.skyforgelearning.dto.CreateUserRequest;
import com.example.skyforgelearning.dto.UserResponse;
import com.example.skyforgelearning.exception.UserNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/** 用户业务服务,当前使用内存保存数据。 */
@Service
public class UserService {
/** 按用户 ID 保存用户资料,应用重启后会清空。 */
private final Map<Long, UserResponse> users =
new ConcurrentHashMap<>();
/** 为新增用户分配递增 ID。 */
private final AtomicLong userIdSequence = new AtomicLong(0);
/** 新增用户并保存资料。 */
public UserResponse createUser(CreateUserRequest request) {
Long userId = userIdSequence.incrementAndGet();
UserResponse user = new UserResponse();
user.setId(userId);
user.setName(request.getName());
user.setPhone(request.getPhone());
users.put(userId, user);
return copyUser(user, true);
}
/** 查询用户,并按请求决定是否返回手机号。 */
public UserResponse getUser(Long userId, boolean showPhone) {
UserResponse user = users.get(userId);
if (user == null) {
throw new UserNotFoundException(userId);
}
return copyUser(user, showPhone);
}
/** 创建独立的响应对象,避免修改内存中保存的用户资料。 */
private UserResponse copyUser(
UserResponse user, boolean showPhone) {
UserResponse response = new UserResponse();
response.setId(user.getId());
response.setName(user.getName());
if (showPhone) {
response.setPhone(user.getPhone());
}
return response;
}
}
这版先保存 id、`name`、`phone`。如果你的 UserResponse 已有 age 字段,可以在新增和复制对象时,分别补上:
user.setAge(request.getAge());
response.setAge(user.getAge());
几个新知识:
代码 含义
Map<Long, UserResponse> 用用户 ID 对应一份用户资料
users.put(id, user) 保存资料
users.get(id) 按 ID 取出资料,找不到时返回 null
ConcurrentHashMap 支持多个请求并发访问的 Map
AtomicLong 支持并发递增,这里用于生成 ID
throw new ... 抛出异常,中断当前正常流程
为什么查询时复制对象?
如果直接把保存的用户手机号设为 null,就会把内存中的原始资料也改掉。这里创建独立响应对象,让"隐藏手机号"只影响本次返回。
这是学习用的简化实现;后续会把存储对象与响应对象分开,并把映射逻辑整理到 Converter。
3.修改 Controller 的新增方法
原来的 GET 查询方法保持不变。将 POST 方法改为:
/** 新增用户。 */
@PostMapping("/user")
public Result<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserResponse user = userService.createUser(request);
return new Result<>(0, "成功", user);
}
注意返回类型变成了:
Result<UserResponse>
现在请求对象负责接收资料,响应对象负责返回新增结果,其中包含后端分配的 ID。
4.处理用户不存在的异常
在 GlobalExceptionHandler 中新增一个方法,保留原来的参数校验处理方法:
/** 将用户不存在转换为 HTTP 404 响应。 */
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Result<Void> handleUserNotFound(
UserNotFoundException exception) {
return new Result<>(404, exception.getMessage(), null);
}
两个类都在 exception 包中,不需要额外导入 UserNotFoundException。
Result<Void> 表示这个响应没有业务数据,`data` 为 null。
5.按顺序测试
保存并重启一次,然后完成以下操作,中间不要重启。
① 新增用户:
POST http://localhost:8080/api/user
{
"name": "李四",
"phone": "13900139000",
"age": 25
}
预期得到分配的 ID,例如:
{
"code": 0,
"message": "成功",
"data": {
"id": 1,
"name": "李四",
"phone": "13900139000"
}
}
② 使用返回的实际 ID 查询:
GET http://localhost:8080/api/user/1?showPhone=true
应返回刚才新增的李四。
③ 先隐藏手机号,再重新显示:
GET http://localhost:8080/api/user/1
GET http://localhost:8080/api/user/1?showPhone=true
第二次仍应返回手机号,说明原始资料没有被修改。
④ 查询不存在的用户:
GET http://localhost:8080/api/user/999
预期 HTTP 404:
{
"code": 404,
"message": "用户不存在,ID:999",
"data": null
}
⑤ 最后重启应用,再查询 ID 1,应返回 404,因为内存数据已经清空。
完成后,你就跑通了第一个业务闭环:接收资料 → 校验 → Service 保存 → 按 ID 查询 → 处理不存在的情况。
12.查询用户列表
java
下一步学习 查询用户列表:一次返回多个用户。
目前你能新增用户、按 ID 查询用户。现在增加:
GET /api/users
它会返回内存中保存的全部用户。
1.在 UserService 中新增方法
先添加 import:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
保留原来的代码,在类中新增:
/** 查询全部用户,按 ID 升序返回。 */
public List<UserResponse> listUsers(boolean showPhone) {
List<UserResponse> responses = new ArrayList<>();
for (UserResponse user : users.values()) {
responses.add(copyUser(user, showPhone));
}
responses.sort(Comparator.comparing(UserResponse::getId));
return responses;
}
这段代码按顺序做了三件事:
1. 遍历内存中的所有用户。
2. 为每个用户创建独立的响应对象。
3. 按 ID 从小到大排序后返回。
几个新知识:
代码 含义
List<UserResponse> 保存多个用户响应对象的列表
new ArrayList<>() 创建一个空列表
users.values() 取得 Map 中的所有用户对象
responses.add(...) 向列表添加一个对象
Comparator.comparing(UserResponse::getId) 使用每个对象的 ID 作为排序依据
UserResponse::getId 是"方法引用",这里先理解为:排序时读取用户的 ID。
之所以主动排序,是因为 ConcurrentHashMap 不保证遍历时按新增顺序返回。
2.在 UserController 中新增列表接口
添加 import:
import java.util.List;
然后在类中新增方法:
/** 查询用户列表。 */
@GetMapping("/users")
public Result<List<UserResponse>> listUsers(
@RequestParam(name = "showPhone", defaultValue = "false")
boolean showPhone) {
List<UserResponse> users = userService.listUsers(showPhone);
return new Result<>(0, "成功", users);
}
你的 Controller 类上已有:
@RequestMapping("/api")
所以完整路径是:
GET http://localhost:8080/api/users
注意返回类型:
Result<List<UserResponse>>
从里面往外读:
UserResponse 一个用户
List<UserResponse> 多个用户组成的列表
Result<...> 用统一响应结构包装这个列表
3.重启后,先查询空列表
访问:
GET http://localhost:8080/api/users
因为重启会清空内存,预期返回:
{
"code": 0,
"message": "成功",
"data": []
}
列表为空是正常查询结果,返回 HTTP 200,不需要抛出"用户不存在"异常。
单个详情查询找不到时返回 404,列表没有数据时返回空数组,这两种情况的含义不同。
4.新增两个用户,再查询
使用原来的 POST 接口分别提交:
{
"name": "张三",
"phone": "13800138000",
"age": 20
}
{
"name": "李四",
"phone": "13900139000",
"age": 25
}
然后访问:
GET http://localhost:8080/api/users?showPhone=true
预期返回类似:
{
"code": 0,
"message": "成功",
"data": [
{
"id": 1,
"name": "张三",
"phone": "13800138000"
},
{
"id": 2,
"name": "李四",
"phone": "13900139000"
}
]
}
如果之前已加入年龄字段,并在 copyUser 中复制了年龄,结果还会包含 age。
最后访问不带参数的:
GET http://localhost:8080/api/users
确认所有用户的手机号都为 null,再带 showPhone=true 查询,确认手机号仍然存在。
这一节掌握后,你就能区分:详情接口返回一个对象,列表接口返回对象数组;两者都可以用 Result<T> 包装。
13.修改用户资料
java
下一步学习 修改用户资料。这次要把"查找用户 → 检查是否存在 → 修改 → 返回结果"串起来。
我们使用:
PUT /api/user/1
路径中的 1 指定修改谁,请求体指定改成什么。这一节采用完整更新:姓名、手机号、年龄都必须提交。
1.创建修改请求类
在 dto 包中新建 UpdateUserRequest.java:
package com.example.skyforgelearning.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
/** 修改用户资料请求。 */
public class UpdateUserRequest {
/** 用户姓名。 */
@NotBlank(message = "姓名不能为空")
private String name;
/** 手机号。 */
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9][0-9]{9}$", message = "手机号格式不正确")
private String phone;
/** 年龄。 */
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
虽然它暂时与新增请求字段相同,仍分开定义。因为新增和修改是两个操作,之后可能拥有不同的字段和规则。
请求体不需要放 ID,路径已经明确指定了用户。
2.确保响应对象包含年龄
如果你的 UserResponse 还没有年龄,补上:
/** 年龄。 */
private Integer age;
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
同时确认 UserService 原来的两个方法中分别有:
// createUser 方法中,保存年龄。
user.setAge(request.getAge());
// copyUser 方法中,复制年龄。
response.setAge(user.getAge());
3.在 Service 中添加修改方法
在 UserService 顶部添加:
import com.example.skyforgelearning.dto.UpdateUserRequest;
然后在类中新增:
/** 修改已有用户资料;用户不存在时拒绝修改。 */
public UserResponse updateUser(
Long userId, UpdateUserRequest request) {
UserResponse updatedUser = users.compute(
userId, (id, existingUser) -> {
if (existingUser == null) {
throw new UserNotFoundException(id);
}
UserResponse replacement = new UserResponse();
replacement.setId(id);
replacement.setName(request.getName());
replacement.setPhone(request.getPhone());
replacement.setAge(request.getAge());
return replacement;
});
return copyUser(updatedUser, true);
}
这里出现了新写法:
users.compute(userId, (id, existingUser) -> { ... });
可以理解成:针对这个 ID,读取旧值,并计算要保存的新值。
部分 含义
userId 要修改的用户 ID
id 本次处理的 ID
existingUser 原来的用户,找不到时为 null
-> { ... } 一段交给 compute 执行的逻辑,称为 Lambda 表达式
return replacement 将新对象作为这个 ID 对应的值保存
这里底层是 ConcurrentHashMap,`compute` 会把同一个键的检查和替换作为原子操作完成,避免在"先检查、再保存"之间被其他操作改变。
我们创建新对象替换旧对象,也避免查询请求读到"姓名改了、手机号还没改"的中间状态。
4.在 Controller 中添加接口
添加导入:
import com.example.skyforgelearning.dto.UpdateUserRequest;
新增方法:
/** 修改用户资料。 */
@PutMapping("/user/{userId}")
public Result<UserResponse> updateUser(
@PathVariable(name = "userId") Long userId,
@Valid @RequestBody UpdateUserRequest request) {
UserResponse user = userService.updateUser(userId, request);
return new Result<>(0, "成功", user);
}
如果没有使用通配符导入,记得添加:
import org.springframework.web.bind.annotation.PutMapping;
这次同时使用了:
@PathVariable:确定修改谁
@RequestBody:接收修改内容
@Valid:校验修改内容
5.重启后按顺序验证
先通过 POST 新增一个用户,记下响应中的实际 ID。然后在 Apifox 新建请求:
PUT http://localhost:8080/api/user/1
Body 选择 JSON:
{
"name": "张三丰",
"phone": "13700137000",
"age": 30
}
预期 HTTP 200,返回修改后的资料。再调用:
GET http://localhost:8080/api/user/1?showPhone=true
确认修改已经保存。
继续验证:
情况 预期
修改已有用户 200,ID 不变,资料更新
修改不存在的用户,例如 999 404,不会创建新用户
姓名为空或年龄不合法 400,原资料不变
请求体遗漏年龄 400,因为这是完整更新
最后一个关键区别:POST 新增时分配新 ID,PUT 修改时沿用路径指定的 ID。 这一节完成后,我们再学习删除用户,补齐基本增删改查。
14.删除用户
java
下一步学习 删除用户,补齐基本的增删改查。
按照原项目的约定,删除接口使用 POST:
POST /api/user/1/delete
它表示删除 ID 为 1 的用户。HTTP 也有 DELETE 方法,这里先遵循原项目规范。
1.在 UserService 中添加删除方法
保留原来的方法,在类中新增:
/** 删除用户;用户不存在时返回明确错误。 */
public void deleteUser(Long userId) {
UserResponse removedUser = users.remove(userId);
if (removedUser == null) {
throw new UserNotFoundException(userId);
}
}
理解这行代码:
UserResponse removedUser = users.remove(userId);
Map.remove 会删除对应记录,并返回被删除的对象:
返回值 含义
用户对象 原来存在,已经删除
null 原来不存在
这样不需要先查询、再删除,避免两步操作之间数据发生变化。
返回类型 void 表示:这个方法不返回数据。删除失败时,通过异常说明原因。
2.在 UserController 中添加接口
/** 删除指定用户。 */
@PostMapping("/user/{userId}/delete")
public Result<Void> deleteUser(
@PathVariable(name = "userId") Long userId) {
userService.deleteUser(userId);
return new Result<>(0, "删除成功", null);
}
注意这两个写法的区别:
void // 方法没有返回值
Result<Void> // 方法返回 Result,但其中没有业务数据
删除成功后的响应是:
{
"code": 0,
"message": "删除成功",
"data": null
}
用户不存在时,会复用之前的 UserNotFoundException 和全局异常处理,不需要再写一遍错误响应。
3.重启并测试
重启会清空内存,因此先通过 POST 新增两个用户,记住实际返回的 ID。
假设生成了 ID 1 和 2,依次验证:
操作 预期
GET /api/users 列出两个用户
POST /api/user/1/delete HTTP 200,删除成功
GET /api/user/1 HTTP 404,用户不存在
GET /api/users 只剩 ID 为 2 的用户
再次 POST /api/user/1/delete HTTP 404,用户不存在
POST /api/user/999/delete HTTP 404,不影响其他用户
Apifox 中选择 POST,删除接口不需要请求体。
4.回顾现在的接口
功能 请求方式 路径
新增用户 POST /api/user
查询详情 GET /api/user/{userId}
查询列表 GET /api/users
修改用户 PUT /api/user/{userId}
删除用户 POST /api/user/{userId}/delete
目前的删除是直接从内存移除数据。以后接数据库时,还会学习物理删除和逻辑删除的区别。
完成后,你就有了一组完整的用户接口。接下来我们会把用于存储的用户对象与 UserResponse 分开,理解为什么数据库实体、请求 DTO、响应 DTO 不应该混在一起。
15.区分存储对象、请求对象和响应对象,并用 Converter 负责转换。
java
下一步学习 区分存储对象、请求对象和响应对象,并用 Converter 负责转换。
目前内存里保存的是:
Map<Long, UserResponse>
但 UserResponse 是用来返回接口数据的,随着业务变复杂,存储字段和展示字段会不同。我们把它们分开:
CreateUserRequest / UpdateUserRequest:接收调用方提交的数据
User:保存内部用户资料
UserResponse:返回允许展示的数据
UserConverter:负责对象之间的转换
这次接口地址、请求体和响应结构都保持不变。
1.创建内部用户对象
在 com.example.skyforgelearning 下创建 entity 包,新建 User.java:
package com.example.skyforgelearning.entity;
/** 内部保存的用户资料,后续用于学习数据库映射。 */
public class User {
/** 用户 ID。 */
private Long id;
/** 用户姓名。 */
private String name;
/** 手机号。 */
private String phone;
/** 年龄。 */
private Integer age;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
现在还没有连接数据库,这只是内存中保存的数据对象。
2.创建转换类
创建 converter 包,新建 UserConverter.java:
package com.example.skyforgelearning.converter;
import com.example.skyforgelearning.dto.CreateUserRequest;
import com.example.skyforgelearning.dto.UpdateUserRequest;
import com.example.skyforgelearning.dto.UserResponse;
import com.example.skyforgelearning.entity.User;
import org.springframework.stereotype.Component;
/** 用户请求、内部资料和响应之间的转换。 */
@Component
public class UserConverter {
/** 将新增请求转换为内部用户资料,ID 由业务服务分配。 */
public User toUser(CreateUserRequest request) {
User user = new User();
user.setName(request.getName());
user.setPhone(request.getPhone());
user.setAge(request.getAge());
return user;
}
/** 将修改请求转换为替换资料,保留指定的用户 ID。 */
public User toUpdatedUser(
Long userId, UpdateUserRequest request) {
User user = new User();
user.setId(userId);
user.setName(request.getName());
user.setPhone(request.getPhone());
user.setAge(request.getAge());
return user;
}
/** 将内部用户资料转换为响应,按已确定的展示选项填充手机号。 */
public UserResponse toResponse(
User user, boolean showPhone) {
UserResponse response = new UserResponse();
response.setId(user.getId());
response.setName(user.getName());
response.setAge(user.getAge());
if (showPhone) {
response.setPhone(user.getPhone());
}
return response;
}
}
@Component 让 Spring 管理这个转换类,之后可以通过构造方法注入。
注意职责:Converter 只做字段转换,不决定用户是否有权限。 这里是否展示手机号,由调用方传入;真实权限规则以后放在 Service 中。
3.修改 UserService
将 UserService 替换为:
package com.example.skyforgelearning.service;
import com.example.skyforgelearning.converter.UserConverter;
import com.example.skyforgelearning.dto.CreateUserRequest;
import com.example.skyforgelearning.dto.UpdateUserRequest;
import com.example.skyforgelearning.dto.UserResponse;
import com.example.skyforgelearning.entity.User;
import com.example.skyforgelearning.exception.UserNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/** 用户业务服务,当前使用内存保存数据。 */
@Service
public class UserService {
/** 按用户 ID 保存内部用户资料。 */
private final Map<Long, User> users = new ConcurrentHashMap<>();
/** 新增用户 ID 序列。 */
private final AtomicLong userIdSequence = new AtomicLong(0);
/** 用户对象转换器。 */
private final UserConverter userConverter;
public UserService(UserConverter userConverter) {
this.userConverter = userConverter;
}
/** 新增用户。 */
public UserResponse createUser(CreateUserRequest request) {
User user = userConverter.toUser(request);
user.setId(userIdSequence.incrementAndGet());
users.put(user.getId(), user);
return userConverter.toResponse(user, true);
}
/** 查询用户详情。 */
public UserResponse getUser(Long userId, boolean showPhone) {
User user = users.get(userId);
if (user == null) {
throw new UserNotFoundException(userId);
}
return userConverter.toResponse(user, showPhone);
}
/** 查询全部用户,按 ID 升序返回。 */
public List<UserResponse> listUsers(boolean showPhone) {
List<UserResponse> responses = new ArrayList<>();
for (User user : users.values()) {
responses.add(
userConverter.toResponse(user, showPhone));
}
responses.sort(Comparator.comparing(UserResponse::getId));
return responses;
}
/** 修改已有用户资料。 */
public UserResponse updateUser(
Long userId, UpdateUserRequest request) {
User updatedUser = users.compute(
userId, (id, existingUser) -> {
if (existingUser == null) {
throw new UserNotFoundException(id);
}
return userConverter.toUpdatedUser(id, request);
});
return userConverter.toResponse(updatedUser, true);
}
/** 删除用户。 */
public void deleteUser(Long userId) {
User removedUser = users.remove(userId);
if (removedUser == null) {
throw new UserNotFoundException(userId);
}
}
}
原来的 copyUser 方法不再需要,它的职责已交给 UserConverter.toResponse。
Controller 不需要修改。
4.理解为什么字段相同还要分开
现在两个类字段相似,但用途不同。例如以后可能增加:
字段 内部 User UserResponse
用户 ID、姓名 保存 返回
手机号 保存完整号码 根据权限返回
密码哈希 保存 不应返回
内部删除标记 保存 通常不需要返回
我们暂时不添加这些新字段,只通过这个例子理解:接口返回什么,应由响应对象明确决定。
5.重启并回归验证
依次测试:
1. 新增用户,确认返回 ID、姓名、手机号和年龄。
2. 查询详情,分别测试显示和隐藏手机号。
3. 查询列表。
4. 修改用户,再查询确认。
5. 删除用户,再查询应为 404。
这一节完成后,你的 Service 开始主要表达业务步骤,字段映射集中到 Converter,内存里保存的也变成了内部 User 对象。下一步再把内存访问提取到 Repository,为接入数据库做准备。
16.Repository(仓库层)
java
下一步学习 Repository(仓库层):把"数据存在哪里、怎么读写"从 Service 中移出去。
目前 UserService 同时负责业务和操作 Map。调整后:
Controller:接收请求
↓
Service:执行业务规则
↓
Repository:保存、查询、修改、删除数据
这次仍使用内存,接口行为不变。之后接数据库时,我们就有一个清晰的数据访问边界。
1.创建 UserRepository
在 com.example.skyforgelearning 下创建 repository 包,新建 UserRepository.java:
package com.example.skyforgelearning.repository;
import com.example.skyforgelearning.entity.User;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/** 用户数据仓库,当前使用内存实现。 */
@Repository
public class UserRepository {
/** 按用户 ID 保存资料,应用重启后清空。 */
private final Map<Long, User> users = new ConcurrentHashMap<>();
/** 用户 ID 序列。 */
private final AtomicLong userIdSequence = new AtomicLong(0);
/** 插入用户并分配 ID。 */
public User insert(User user) {
user.setId(userIdSequence.incrementAndGet());
users.put(user.getId(), user);
return user;
}
/** 按 ID 查询,不存在时返回 null。 */
public User findById(Long userId) {
return users.get(userId);
}
/** 返回当前用户列表,不保证顺序。 */
public List<User> findAll() {
return new ArrayList<>(users.values());
}
/** 替换已有记录,不存在时返回 false。 */
public boolean update(User user) {
return users.replace(user.getId(), user) != null;
}
/** 删除记录,不存在时返回 false。 */
public boolean deleteById(Long userId) {
return users.remove(userId) != null;
}
}
@Repository 表示这个类负责数据访问,也让 Spring 扫描并管理它。
这里的仓库不抛出 UserNotFoundException,只报告数据操作结果:
方法 不存在时返回
findById null
update false
deleteById false
"用户不存在时,业务应该怎么处理",交给 Service 决定。
注意更新使用:
users.replace(user.getId(), user)
它只替换已有记录。如果用 put,不存在的 ID 也会被插入,就可能把"修改"变成"新增"。
2.修改 UserService
将 Service 中的 Map、ID 序列以及相关 import 移除,改为注入 Repository。
完整代码如下:
package com.example.skyforgelearning.service;
import com.example.skyforgelearning.converter.UserConverter;
import com.example.skyforgelearning.dto.CreateUserRequest;
import com.example.skyforgelearning.dto.UpdateUserRequest;
import com.example.skyforgelearning.dto.UserResponse;
import com.example.skyforgelearning.entity.User;
import com.example.skyforgelearning.exception.UserNotFoundException;
import com.example.skyforgelearning.repository.UserRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/** 用户业务服务。 */
@Service
public class UserService {
/** 用户数据仓库。 */
private final UserRepository userRepository;
/** 用户对象转换器。 */
private final UserConverter userConverter;
public UserService(
UserRepository userRepository,
UserConverter userConverter) {
this.userRepository = userRepository;
this.userConverter = userConverter;
}
/** 新增用户。 */
public UserResponse createUser(CreateUserRequest request) {
User user = userConverter.toUser(request);
User savedUser = userRepository.insert(user);
return userConverter.toResponse(savedUser, true);
}
/** 查询用户详情。 */
public UserResponse getUser(Long userId, boolean showPhone) {
User user = userRepository.findById(userId);
if (user == null) {
throw new UserNotFoundException(userId);
}
return userConverter.toResponse(user, showPhone);
}
/** 查询全部用户,按 ID 升序返回。 */
public List<UserResponse> listUsers(boolean showPhone) {
List<User> users = userRepository.findAll();
List<UserResponse> responses = new ArrayList<>();
for (User user : users) {
responses.add(
userConverter.toResponse(user, showPhone));
}
responses.sort(Comparator.comparing(UserResponse::getId));
return responses;
}
/** 修改用户,不存在时拒绝修改。 */
public UserResponse updateUser(
Long userId, UpdateUserRequest request) {
User replacement =
userConverter.toUpdatedUser(userId, request);
boolean updated = userRepository.update(replacement);
if (!updated) {
throw new UserNotFoundException(userId);
}
return userConverter.toResponse(replacement, true);
}
/** 删除用户,不存在时返回明确错误。 */
public void deleteUser(Long userId) {
boolean deleted = userRepository.deleteById(userId);
if (!deleted) {
throw new UserNotFoundException(userId);
}
}
}
Controller 和 Converter 保持原样。
3.理解这次调整
以修改用户为例:
Controller
接收用户 ID 和修改请求
↓
Service
把请求转成用户资料,发起更新
↓
Repository
尝试替换记录,返回是否成功
↓
Service
更新失败时抛出"用户不存在"
↓
Controller 或全局异常处理类
返回成功结果或 HTTP 404
Service 不再需要知道数据保存在 Map 里,也不需要管理 ID 序列。
当前 Repository 会返回内存中对象的引用。我们通过 Converter 创建响应,并在更新时替换整个对象;学习阶段要继续保持这个约定,避免拿到存储对象后随意修改它。
4.重启并验证
按顺序验证:
操作 预期
新增用户 成功,返回生成的 ID
查询详情、列表 能查到新增资料
修改已有用户 成功,ID 不变
修改不存在的用户 404,且列表中没有新增该 ID
删除已有用户 成功
再次查询被删除用户 404
重启后查询 数据清空,仍然是内存存储
这节的重点是:Service 决定业务怎么走,Repository 负责数据怎么读写。
完成之后,我们就开始准备 MySQL,把用户资料保存到数据库,让应用重启后数据依然存在。
17.接入 MySQL 数据库
java
接下来开始接入 MySQL 数据库。这一节先完成"创建数据库和用户表",下一节再让 Java 连接它。
目标是把:
Map 保存用户 → 重启后丢失
逐步替换成:
MySQL 保存用户 → 重启后仍然存在
1.先确认是否已有 MySQL
如果你已经安装 MySQL,或电脑上已经运行了 MySQL 容器,直接使用现有服务即可,不要重复安装。
需要确认这些信息:
信息 示例
地址 localhost
端口 3306
用户名 你自己的数据库账号
密码 安装时设置的密码
这里的账号是连接数据库的账号,与我们练习创建的"张三、李四"用户不同。密码不用发给我。
如果还没有 MySQL,先告诉我是否安装了 Docker Desktop,我再按你的环境带你安装。下面的步骤适用于已经可以连接 MySQL 的情况。
2.连接数据库
使用 IDEA 的 Database 面板、DBeaver 或你已有的数据库工具,创建 MySQL 连接。
在 IDEA 中通常是:
Database → + → Data Source → MySQL
填写地址、端口、用户名和密码。首次使用如果提示缺少驱动,点击下载驱动,然后点击 Test Connection。
连接成功后,打开这个连接的 Query Console(查询控制台)。
3.创建独立的学习数据库
执行:
CREATE DATABASE IF NOT EXISTS sky_forge_learning
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
然后执行:
USE sky_forge_learning;
这里:
内容 含义
sky_forge_learning 学习项目专用数据库
utf8mb4 支持中文、表情等字符
utf8mb4_0900_ai_ci MySQL 8.0 使用的一种字符比较规则
务必使用这个独立学习库,不要在原项目的业务数据库中练习建表或删除数据。
4.创建用户表
执行:
CREATE TABLE learning_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
name VARCHAR(50) NOT NULL COMMENT '用户姓名',
phone VARCHAR(20) NOT NULL COMMENT '手机号',
age INT NOT NULL COMMENT '年龄',
PRIMARY KEY (id)
) COMMENT = '学习用用户表';
它对应我们目前的 User:
Java 字段 Java 类型 数据库字段 数据库类型
id Long id BIGINT
name String name VARCHAR(50)
phone String phone VARCHAR(20)
age Integer age INT
重点理解:
- 表保存同一类数据。
- 行是一名用户的资料。
- 列是姓名、手机号等字段。
- PRIMARY KEY 是主键,唯一标识一行。
- AUTO_INCREMENT 表示数据库自动分配 ID。
- NOT NULL 表示数据库不允许该字段为空。
手机号用字符串保存,因为它是标识信息,不用于数学运算。
如果提示表已经存在,先不要删除,查看现有结构:
SHOW CREATE TABLE learning_user;
5.手动新增一名用户
执行:
INSERT INTO learning_user (name, phone, age)
VALUES ('张三', '13800138000', 20);
这里没有提交 id,由 MySQL 自动生成。
再执行:
SELECT id, name, phone, age
FROM learning_user
ORDER BY id;
预期能看到一行数据,例如:
id name phone age
1 张三 13800138000 20
6.理解它与当前 Java 程序的关系
现在有两个相互独立的数据来源:
Java 接口 → UserRepository → Map
数据库工具 → SQL → MySQL
所以你在数据库里新增张三,目前调用接口还查不到他。我们还没有修改 Java 的数据访问逻辑,这是正常的。
这一节完成标准是:数据库连接成功,`sky_forge_learning` 中存在 learning_user 表,并且 SELECT 能查出你插入的用户。
你现在电脑上有可用的 MySQL 吗?如果有,先完成这些操作;如果没有,告诉我是否已有 Docker Desktop,我们从安装开始。
18.Java 真正连接 MySQL,并查询用户表
java
下一步让 Java 真正连接 MySQL,并查询用户表。这一节先验证连接;现有接口仍使用内存,下一节再替换 Repository。
1.添加数据库依赖
在 pom.xml 的 <dependencies> 中新增:
<!-- MyBatis-Plus:提供数据库访问与常用增删改查能力。 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.10.1</version>
</dependency>
<!-- MySQL 数据库驱动。 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
MyBatis-Plus 版本与原项目对齐;MySQL 驱动版本由 Spring Boot 父项目管理。Spring Boot 3 应使用名称中带 spring-boot3 的 Starter。官方安装说明
保存并重新加载 Maven。
2.配置数据库连接
打开:
src/main/resources/application.properties
保留已有配置,新增:
# 数据库连接:明确使用独立学习库。
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/sky_forge_learning?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&sslMode=DISABLED&allowPublicKeyRetrieval=true
# 数据库账号。
spring.datasource.username=skyforge
# 从环境变量读取密码。
spring.datasource.password=${LEARNING_DB_PASSWORD}
# MySQL 驱动类。
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
这个连接地址按你当前的本机 Docker 学习环境配置。
然后设置运行时的密码:
1. 顶部运行配置下拉框 → Edit Configurations。
2. 选择 SkyForgeLearningApplication。
3. 找到 Environment variables。如果没显示,通过 Modify options 启用。
4. 添加变量,名称为:LEARNING_DB_PASSWORD
5. 值填写你已经能登录的 skyforge 密码,保存。
这样配置中的 ${LEARNING_DB_PASSWORD} 会被替换成环境变量值。
3.给 User 添加数据库映射注解
打开 entity/User.java,添加 import:
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
在类和 ID 字段上加注解:
/** 数据库中保存的用户资料。 */
@TableName("learning_user")
public class User {
/** 用户 ID,由数据库自动生成。 */
@TableId(value = "id", type = IdType.AUTO)
private Long id;
// name、phone、age 和所有 getter、setter 保留。
}
这里是修改示意,不要用省略的片段覆盖整个类。
注解 含义
@TableName("learning_user") User 对应数据库中的 learning_user 表
TableId(...) 声明主键字段
IdType.AUTO 新增时使用数据库自增 ID
name、`phone`、`age` 与表字段同名,目前不需要额外声明映射。
4.创建 UserMapper 接口
在 com.example.skyforgelearning 下创建 mapper 包,再通过 New → Java Class → Interface 创建 UserMapper:
package com.example.skyforgelearning.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.skyforgelearning.entity.User;
import org.apache.ibatis.annotations.Mapper;
/** 用户表的数据访问接口。 */
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
这里出现了 interface,表示接口。
你没有编写查询实现,但框架会根据 BaseMapper<User> 和实体映射,提供常用数据库操作。`@Mapper` 让框架识别并创建这个接口的实现对象。官方入门说明
先认识几个方法:
方法 作用
selectById(id) 根据 ID 查询
selectList(...) 查询列表
insert(user) 插入用户
updateById(user) 根据 ID 更新
deleteById(id) 根据 ID 删除
5.写一个数据库查询测试
这次不通过 Controller,先单独验证 Mapper。
在下面的测试源码目录中创建 mapper 包:
src/test/java/com/example/skyforgelearning
然后创建 UserMapperTest.java:
package com.example.skyforgelearning.mapper;
import com.example.skyforgelearning.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/** 使用真实学习数据库验证用户查询。 */
@SpringBootTest
class UserMapperTest {
/** 待验证的用户 Mapper。 */
@Autowired
private UserMapper userMapper;
/** 确认能连接数据库,并查询用户表。 */
@Test
void shouldQueryUsersFromDatabase() {
List<User> users = userMapper.selectList(null);
assertNotNull(users);
System.out.println("数据库用户数量:" + users.size());
}
}
这几个新内容先这样理解:
内容 作用
@SpringBootTest 启动测试所需的 Spring 应用环境
@Autowired 把 Spring 管理的 Mapper 注入测试字段
@Test 标记一个可执行的测试方法
selectList(null) 不设置筛选条件,查询用户表
assertNotNull(users) 验证查询结果列表不是 null,空列表也能通过
这是需要真实 MySQL 的集成测试,运行时容器必须启动。
6.运行测试并检查结果
点击测试方法左侧的绿色按钮运行。
测试运行配置与应用运行配置是分开的:在该测试的 Edit Configurations 中,也添加 LEARNING_DB_PASSWORD 环境变量。
正常情况下:
- 测试显示绿色通过。
- 控制台出现:数据库用户数量:2
- 数量以数据库实际记录为准;没有数据时显示 0 也正常。
如果报错,可以先按下面判断:
错误关键词 优先检查
LEARNING_DB_PASSWORD 无法解析 测试运行配置有没有设置环境变量
Access denied 用户名、密码、数据库权限
Communications link failure MySQL 容器和 3306 端口
Table ... doesn't exist 学习库中是否已创建 learning_user
Unknown column 表是否包含 id、`name`、`phone`、`age`
这一节成功的标志是:测试实际调用 Mapper,从 MySQL 查询成功。 现有用户接口还没改为访问数据库,测试通过后,我们再替换内存 Repository。
19.内存 Repository 替换为数据库 Repository
java
现在把 内存 Repository 替换为数据库 Repository。完成后,你原来的增删改查接口就会真正操作 MySQL,重启应用也不会丢失用户。
调用流程变成:
Controller → Service → UserRepository → UserMapper → MySQL
1.替换 UserRepository
打开 repository/UserRepository.java,将整个类替换为:
package com.example.skyforgelearning.repository;
import com.example.skyforgelearning.entity.User;
import com.example.skyforgelearning.mapper.UserMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 用户数据仓库,使用 MySQL 保存资料。 */
@Repository
public class UserRepository {
/** 用户表数据访问接口。 */
private final UserMapper userMapper;
public UserRepository(UserMapper userMapper) {
this.userMapper = userMapper;
}
/** 插入用户,数据库生成的 ID 会回填到对象中。 */
public User insert(User user) {
userMapper.insert(user);
return user;
}
/** 按 ID 查询,不存在时返回 null。 */
public User findById(Long userId) {
return userMapper.selectById(userId);
}
/** 查询全部用户,不保证顺序。 */
public List<User> findAll() {
return userMapper.selectList(null);
}
/** 更新已有用户,返回是否匹配到记录。 */
public boolean update(User user) {
return userMapper.updateById(user) > 0;
}
/** 删除用户,返回是否删除了记录。 */
public boolean deleteById(Long userId) {
return userMapper.deleteById(userId) > 0;
}
}
原来这些代码不再需要:
Map<Long, User>
ConcurrentHashMap
AtomicLong
因为数据和 ID 都交给数据库管理了。
2.理解为什么 Service 不用修改
新的 Repository 保留了原来的方法:
Repository 方法 原来 现在
insert 放入 Map,生成 ID 执行 INSERT,数据库生成 ID
findById Map.get 按主键执行 SELECT
findAll 读取 Map 的值 查询数据库用户表
update Map.replace 按主键执行 UPDATE
deleteById Map.remove 按主键执行 DELETE
方法的输入、输出和业务含义保持一致,所以 Controller 和 Service 可以继续调用。 这就是前面分层带来的好处。
注意,目前数据库连接中没有设置 useAffectedRows=true。保持默认配置时,更新相同资料也可以按匹配到的记录判断成功。后续不要随意改变这个参数,否则需要重新考虑更新结果的判断方式。
3.理解新增 ID 如何产生
Service 原来的新增流程应是:
User user = userConverter.toUser(request);
User savedUser = userRepository.insert(user);
return userConverter.toResponse(savedUser, true);
对象在插入前:
id = null
执行:
userMapper.insert(user);
由于实体配置了:
@TableId(value = "id", type = IdType.AUTO)
数据库生成的 ID 会回填到 user.id,例如:
id = 3
因此返回给前端的响应可以包含新 ID。不要再在 Service 中通过 AtomicLong 分配 ID。
4.重启后先验证查询
确保应用运行配置中已经设置 LEARNING_DB_PASSWORD,然后重启应用。
访问:
GET http://localhost:8080/api/users?showPhone=true
这次应该能看到你之前在数据库工具中手动插入的用户。
如果数据库里确实有数据,但返回空列表,优先检查:
- 是否运行了修改后的应用。
- 连接地址是否为 sky_forge_learning。
- UserRepository 是否已经替换成新代码。
5.验证新增、修改和删除
先通过 Apifox 新增一名测试用户:
POST http://localhost:8080/api/user
{
"name": "王五",
"phone": "13700137000",
"age": 28
}
记下响应中的实际 ID,然后在数据库工具执行:
SELECT id, name, phone, age
FROM sky_forge_learning.learning_user
ORDER BY id;
应该能查到王五。
接着使用这个实际 ID 测试:
操作 验证方式
PUT /api/user/实际ID 修改资料 数据库查询结果也发生变化
GET /api/user/实际ID?showPhone=true 返回修改后的资料
重启应用后再次查询 用户仍然存在
POST /api/user/实际ID/delete 数据库中该行被删除
再次查询该 ID 返回 HTTP 404
另外测试一次:连续两次提交完全相同的修改内容,两次都应该成功。
现在的删除会真正删除数据库记录,先用刚新增的测试用户练习。
这一节完成标准是:接口与数据库数据一致,并且应用重启后用户仍然存在。 下一步我们再学习按姓名筛选,把查询条件交给数据库执行。
20.数据库根据查询条件返回结果
java
下一步学习 按姓名筛选用户:让数据库根据查询条件返回结果。
例如:
GET /api/users?keyword=张
查询姓名中包含"张"的用户。没有传 keyword 时,仍然返回全部用户。
这次的数据流是:
Controller 接收关键词
↓
Service 整理关键词
↓
Repository 构造查询条件
↓
MySQL 筛选并排序
1.修改 Repository 的列表查询方法
在 UserRepository 中添加 import:
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
将原来的 findAll() 方法替换为:
/** 按姓名关键词查询用户,关键词为空时查询全部,按 ID 升序。 */
public List<User> findByNameKeyword(String keyword) {
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
if (!keyword.isEmpty()) {
query.like(User::getName, keyword);
}
query.orderByAsc(User::getId);
return userMapper.selectList(query);
}
理解这三个操作:
代码 含义
new LambdaQueryWrapper<>() 创建查询条件对象
query.like(User::getName, keyword) 姓名包含指定关键词
query.orderByAsc(User::getId) 按 ID 升序排列
这里:
User::getName
用于告诉 MyBatis-Plus:对 User 的 name 属性对应的数据库字段添加条件。
假设关键词为"张",查询逻辑相当于:
SELECT id, name, phone, age
FROM learning_user
WHERE name LIKE '%张%'
ORDER BY id ASC;
实际执行时,框架通过参数绑定传递关键词,不要自己把请求内容拼进 SQL 字符串。
2.修改 Service 的列表方法
将 UserService 中原来的 listUsers 替换为:
/** 按姓名关键词查询用户列表。 */
public List<UserResponse> listUsers(
String keyword, boolean showPhone) {
String normalizedKeyword = keyword.trim();
List<User> users =
userRepository.findByNameKeyword(normalizedKeyword);
List<UserResponse> responses = new ArrayList<>();
for (User user : users) {
responses.add(
userConverter.toResponse(user, showPhone));
}
return responses;
}
trim() 用于去除关键词两端的空格:
" 张 " → "张"
" " → ""
原来的:
responses.sort(Comparator.comparing(UserResponse::getId));
不再需要,因为数据库已经完成排序。没有其他地方使用 Comparator 时,可以删除对应 import。
3.修改 Controller 的列表接口
将原来的列表方法替换为:
/** 按姓名关键词查询用户列表。 */
@GetMapping("/users")
public Result<List<UserResponse>> listUsers(
@RequestParam(name = "keyword", defaultValue = "")
String keyword,
@RequestParam(name = "showPhone", defaultValue = "false")
boolean showPhone) {
List<UserResponse> users =
userService.listUsers(keyword, showPhone);
return new Result<>(0, "成功", users);
}
这次新增了一个可选查询参数:
@RequestParam(name = "keyword", defaultValue = "") String keyword
没有传值时,使用空字符串。Service 再去掉两端空格,Repository 根据是否为空决定要不要添加筛选条件。
4.重启并验证
先确认数据库中有几名不同姓名的用户,例如:
张三
张三丰
李四
可以通过之前的新增接口创建,不用重复建表。
然后验证:
请求 预期
/api/users 返回全部用户
/api/users?keyword=张 返回张三、张三丰
/api/users?keyword=三丰 返回张三丰
/api/users?keyword=不存在的姓名 返回空数组
/api/users?keyword=张&showPhone=true 筛选姓名,并返回手机号
在 Apifox 的查询参数表格中填写关键词更方便,它会处理 URL 编码。
没有匹配结果时仍然返回 HTTP 200:
{
"code": 0,
"message": "成功",
"data": []
}
还有一个细节:SQL 的 LIKE 中,`%` 和 _ 是通配符。因此当前练习若输入这两个字符,会按通配符规则匹配;它们并不表示 SQL 注入。后续需要"完全按字面搜索"时,再学习转义处理。
这一节重点掌握:查询参数来自请求,Service 整理参数,Repository 把条件交给数据库执行。
21.分页查询
java
下一步学习 分页查询:用户很多时,每次只返回一部分数据。
例如:
GET /api/users?page=1&size=2
表示查询第 1 页,每页 2 条。我们还会保留姓名筛选:
GET /api/users?keyword=张&page=1&size=2
这一节代码稍多,按顺序完成即可。
1.添加分页插件依赖
当前使用 MyBatis-Plus 3.5.10.1。从 3.5.9 起,分页插件需要单独引入 SQL 解析依赖。官方说明
在 pom.xml 的 <dependencies> 中添加:
<!-- MyBatis-Plus 分页插件所需的 SQL 解析依赖。 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.10.1</version>
</dependency>
保存并重新加载 Maven。
2.注册分页插件
在 com.example.skyforgelearning 下创建 config 包,新建 MybatisPlusConfig.java:
package com.example.skyforgelearning.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;
/** MyBatis-Plus 配置。 */
@Configuration
public class MybatisPlusConfig {
/** 注册 MySQL 分页插件。 */
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor =
new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination =
new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(100L);
pagination.setOverflow(false);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
这里:
配置 含义
@Configuration 声明 Spring 配置类
@Bean 把方法创建的对象交给 Spring 管理
DbType.MYSQL 使用 MySQL 的分页语法
setMaxLimit(100L) 限制每页最多 100 条
setOverflow(false) 请求超过最后一页时,不自动跳回第一页
3.创建分页响应对象
在 common 包中新建 PageResponse.java:
package com.example.skyforgelearning.common;
import java.util.List;
/** 分页查询响应。 */
public class PageResponse<T> {
/** 当前页码,从 1 开始。 */
private final long page;
/** 每页条数。 */
private final long size;
/** 符合条件的总记录数。 */
private final long total;
/** 当前页的数据。 */
private final List<T> records;
public PageResponse(
long page, long size, long total, List<T> records) {
this.page = page;
this.size = size;
this.total = total;
this.records = records;
}
public long getPage() {
return page;
}
public long getSize() {
return size;
}
public long getTotal() {
return total;
}
public List<T> getRecords() {
return records;
}
}
total 是符合筛选条件的全部记录数量,不只是当前页的数量。
4.修改 Repository 查询方法
在 UserRepository 中添加:
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
把原来的 findByNameKeyword 方法替换为:
/** 按姓名筛选并分页查询,按 ID 升序排列。 */
public Page<User> findPageByNameKeyword(
String keyword, long page, long size) {
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
if (!keyword.isEmpty()) {
query.like(User::getName, keyword);
}
query.orderByAsc(User::getId);
Page<User> pageRequest = new Page<>(page, size);
return userMapper.selectPage(pageRequest, query);
}
这里的:
new Page<>(page, size)
告诉插件"查第几页,每页几条"。
返回的 Page<User> 中,既有当前页的数据,也有总记录数等信息。插件通常会先统计数量,再查询指定页的数据。
5.修改 Service 查询方法
在 UserService 中添加:
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.skyforgelearning.common.PageResponse;
将原来的 listUsers 方法替换为:
/** 按姓名筛选并分页返回用户资料。 */
public PageResponse<UserResponse> listUsers(
String keyword, boolean showPhone,
long page, long size) {
String normalizedKeyword = keyword.trim();
Page<User> userPage = userRepository.findPageByNameKeyword(
normalizedKeyword, page, size);
List<UserResponse> responses = new ArrayList<>();
for (User user : userPage.getRecords()) {
responses.add(
userConverter.toResponse(user, showPhone));
}
return new PageResponse<>(
userPage.getCurrent(),
userPage.getSize(),
userPage.getTotal(),
responses);
}
注意,这里只转换:
userPage.getRecords()
也就是当前页的用户。无需把全部用户读进 Java 后再截取。
6.修改 Controller 列表接口
添加 import:
import com.example.skyforgelearning.common.PageResponse;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
替换列表方法:
/** 分页查询用户列表。 */
@GetMapping("/users")
public Result<PageResponse<UserResponse>> listUsers(
@RequestParam(name = "keyword", defaultValue = "")
String keyword,
@RequestParam(name = "showPhone", defaultValue = "false")
boolean showPhone,
@RequestParam(name = "page", defaultValue = "1")
@Min(value = 1, message = "页码不能小于1")
long page,
@RequestParam(name = "size", defaultValue = "10")
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 100, message = "每页条数不能大于100")
long size) {
PageResponse<UserResponse> result =
userService.listUsers(keyword, showPhone, page, size);
return new Result<>(0, "成功", result);
}
你目前使用的 Spring Boot 3.4 支持 Controller 方法参数上的这种约束校验。这里不需要给 Controller 类添加 @Validated。
这些校验失败时会返回 HTTP 400,但异常类型与之前请求体校验不同,所以目前不一定经过你已有的统一异常处理方法;我们下一节再统一。
7.重启并验证
假设数据库中恰好有 3 名用户,访问:
GET http://localhost:8080/api/users?page=1&size=2&showPhone=true
返回类似:
{
"code": 0,
"message": "成功",
"data": {
"page": 1,
"size": 2,
"total": 3,
"records": [
{
"id": 1,
"name": "张三",
"phone": "13800138000",
"age": 20
},
{
"id": 2,
"name": "李四",
"phone": "13900139000",
"age": 25
}
]
}
}
再验证:
请求 预期
?page=2&size=2 返回剩余的第 3 名用户
?page=3&size=2 records 为空,`total` 仍是 3
?keyword=张&page=1&size=2 只统计和返回姓名匹配的用户
不传分页参数 默认第 1 页,每页 10 条
?page=0 HTTP 400
?size=101 HTTP 400
这次列表响应中的 data 从数组变成了包含页码、总数和记录的对象。以后前端读取列表,需要使用 data.records。
完成标准是:翻页不会重复返回第一页,筛选后的 total 正确,超出页数时返回空列表。
22.全局异常处理
java
下一步完善 全局异常处理,让分页参数错误也返回统一的 Result。
目前有两种不同的错误:
?page=0 → 能转换成数字,但不符合 @Min(1)
?page=abc → 无法转换成数字
它们产生的异常不同,需要分别处理。
1.添加需要的 import
打开 GlobalExceptionHandler.java,添加:
import org.springframework.context.MessageSourceResolvable;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
原有的 Result、`List`、`ArrayList`、`HttpStatus` 等 import 保留。
2.处理分页参数校验失败
保留原来的异常处理方法,在类中新增:
/** 将查询参数约束校验失败转换为统一响应。 */
@ExceptionHandler(HandlerMethodValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<List<String>> handleMethodValidation(
HandlerMethodValidationException exception) {
List<String> errors = new ArrayList<>();
for (MessageSourceResolvable error : exception.getAllErrors()) {
errors.add(error.getDefaultMessage());
}
return new Result<>(400, "请求参数校验失败", errors);
}
getAllErrors() 取得校验错误,`getDefaultMessage()` 取得你在注解中填写的提示。
例如:
@Min(value = 1, message = "页码不能小于1")
就能取到:
页码不能小于1
你之前处理的 MethodArgumentNotValidException 主要用于当前请求体对象的校验;直接写在 Controller 方法参数上的约束,会使用这里的 HandlerMethodValidationException。Spring 官方说明
当前课程只对输入参数设置约束,按 HTTP 400 处理。如果以后添加返回值约束,需要另外区分服务端返回数据不合规的情况。
3.处理参数类型转换失败
继续在同一个类中新增:
/** 将路径参数或查询参数类型转换失败转换为统一响应。 */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<List<String>> handleParameterTypeMismatch(
MethodArgumentTypeMismatchException exception) {
String parameterName = exception.getName();
List<String> errors = new ArrayList<>();
errors.add(parameterName + ":参数类型不正确");
return new Result<>(400, "请求参数类型错误", errors);
}
例如请求:
/api/users?page=abc
page 要接收 long,但 "abc" 无法转成整数。Spring 在进入 Controller 方法之前,就会产生类型转换异常。
这里的:
exception.getName()
可以取得出错参数名,例如 page、`size` 或 userId。
4.检查 Controller 的校验规则
列表接口中应保留:
@RequestParam(name = "page", defaultValue = "1")
@Min(value = 1, message = "页码不能小于1")
long page
@RequestParam(name = "size", defaultValue = "10")
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 100, message = "每页条数不能大于100")
long size
当前配置下,Controller 类上不需要添加 @Validated。
5.重启并测试
访问:
GET http://localhost:8080/api/users?page=0
预期 HTTP 400:
{
"code": 400,
"message": "请求参数校验失败",
"data": [
"页码不能小于1"
]
}
再访问:
GET http://localhost:8080/api/users?page=abc
预期 HTTP 400:
{
"code": 400,
"message": "请求参数类型错误",
"data": [
"page:参数类型不正确"
]
}
继续验证:
请求 预期
/api/users?size=101 400,提示每页条数不能大于100
/api/users?size=abc 400,提示 size 类型不正确
/api/user/abc 400,提示 userId 类型不正确
/api/users?page=1&size=2 正常分页
POST 新增用户,姓名为空 原来的请求体校验仍正常
现在你已经能区分:转换失败表示数据类型不对,校验失败表示类型正确但值不符合规则。 两种错误都由全局异常处理整理成统一响应。
23.业务校验:手机号不能重复。
java
下一步学习 业务校验:手机号不能重复。
你已经学过:
- @NotBlank:不能为空。
- @Pattern:手机号格式正确。
- Service:处理业务规则。
现在新增一条规则:不同用户不能使用相同手机号;修改用户时,允许保留自己的手机号。
这需要两层配合:
Service 提前检查 → 给出清楚提示
数据库唯一约束 → 防止并发请求产生重复数据
1.先检查数据库中有没有重复手机号
在学习库执行:
SELECT phone, COUNT(*) AS user_count
FROM sky_forge_learning.learning_user
GROUP BY phone
HAVING COUNT(*) > 1;
如果返回记录,说明已有重复数据。先通过查询确认对应用户,再用已有接口修改重复号码:
SELECT id, name, phone
FROM sky_forge_learning.learning_user
WHERE phone = '这里填写重复的手机号';
不要直接删除不清楚用途的数据。
确认没有重复后,执行:
ALTER TABLE sky_forge_learning.learning_user
ADD CONSTRAINT uk_learning_user_phone UNIQUE (phone);
这表示数据库不允许两行记录拥有相同的手机号。只需执行一次;如果提示索引已经存在,不要重复添加。
2.创建业务异常
在 exception 包下新建 PhoneAlreadyUsedException.java:
package com.example.skyforgelearning.exception;
/** 手机号已被其他用户使用。 */
public class PhoneAlreadyUsedException extends RuntimeException {
public PhoneAlreadyUsedException() {
super("手机号已被使用");
}
}
它与参数格式校验不同:手机号即使格式正确,也可能已经被其他用户占用。
3.在 Repository 中添加查询方法
在 UserRepository 中新增:
/** 查询手机号是否被指定用户以外的记录使用。 */
public boolean existsByPhone(
String phone, Long excludedUserId) {
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
query.eq(User::getPhone, phone);
if (excludedUserId != null) {
query.ne(User::getId, excludedUserId);
}
return userMapper.selectCount(query) > 0;
}
这里:
方法 含义
eq 等于
ne 不等于
selectCount 查询符合条件的记录数量
两种调用方式:
// 新增:检查所有用户。
userRepository.existsByPhone(phone, null);
// 修改:排除当前用户,检查其他用户。
userRepository.existsByPhone(phone, userId);
这里的 null 有明确含义:不排除任何用户。
4.在 Service 中加入规则
添加 import:
import com.example.skyforgelearning.exception.PhoneAlreadyUsedException;
新增一个内部校验方法:
/** 同一手机号只能属于一名用户。 */
private void ensurePhoneAvailable(
String phone, Long excludedUserId) {
if (userRepository.existsByPhone(phone, excludedUserId)) {
throw new PhoneAlreadyUsedException();
}
}
将新增用户方法修改为:
/** 新增用户,手机号不能重复。 */
public UserResponse createUser(CreateUserRequest request) {
ensurePhoneAvailable(request.getPhone(), null);
User user = userConverter.toUser(request);
User savedUser = userRepository.insert(user);
return userConverter.toResponse(savedUser, true);
}
将修改用户方法修改为:
/** 修改用户,手机号不能被其他用户占用。 */
public UserResponse updateUser(
Long userId, UpdateUserRequest request) {
User existingUser = userRepository.findById(userId);
if (existingUser == null) {
throw new UserNotFoundException(userId);
}
ensurePhoneAvailable(request.getPhone(), userId);
User replacement =
userConverter.toUpdatedUser(userId, request);
boolean updated = userRepository.update(replacement);
if (!updated) {
throw new UserNotFoundException(userId);
}
return userConverter.toResponse(replacement, true);
}
这里先检查用户存在,再检查号码。更新后的第二次结果检查仍然保留,因为记录可能在检查之后被其他请求删除。
5.补充全局异常处理
在 GlobalExceptionHandler 中添加:
/** 手机号被占用时返回业务冲突。 */
@ExceptionHandler(PhoneAlreadyUsedException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Result<Void> handlePhoneAlreadyUsed(
PhoneAlreadyUsedException exception) {
return new Result<>(409, exception.getMessage(), null);
}
HttpStatus.CONFLICT 表示 HTTP 409:请求与现有数据状态冲突。
还需要处理数据库唯一约束冲突。添加 import:
import org.springframework.dao.DuplicateKeyException;
再新增:
/** 将数据库唯一键冲突转换为明确响应,不暴露数据库错误详情。 */
@ExceptionHandler(DuplicateKeyException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Result<Void> handleDuplicateKey(
DuplicateKeyException exception) {
return new Result<>(409, "数据已存在,请检查唯一字段", null);
}
为什么 Service 已经检查,还需要数据库约束和这个异常处理?
两个请求可能同时发生:
请求 A:检查手机号,没有重复
请求 B:检查手机号,也没有重复
请求 A:插入成功
请求 B:插入时被唯一约束拒绝
提前查询无法独自保证并发情况下的唯一性,数据库约束负责最终保证。 第二个请求的数据库异常由全局处理类转成 409。
6.重启并测试
使用两个不同手机号创建用户 A 和用户 B,记下实际 ID。
测试 预期
新增一个没有使用过的手机号 成功
再次新增同一个手机号 409,手机号已被使用
修改 A,保留 A 原来的手机号 成功
修改 A,使用 B 的手机号 409
修改 A,使用一个新手机号 成功
查询修改失败的 A 原资料保持不变
普通重复请求预期返回:
{
"code": 409,
"message": "手机号已被使用",
"data": null
}
这一节要掌握:字段校验检查数据格式,Service 检查业务规则,数据库约束保证最终的数据一致性。
24.Service 单元测试
java
接下来学习 Service 单元测试:让代码自动验证业务规则,不用每次都在 Apifox 中手动测试。
我们先测试刚完成的手机号规则。这类测试不启动 Spring、不连接 MySQL,通过模拟 Repository,专门检查 Service 的判断是否正确。
1.理解 Mock 的作用
正常运行时:
UserService → UserRepository → MySQL
单元测试时:
UserService → 模拟的 UserRepository
例如,可以告诉模拟仓库:
查询这个手机号时,返回"已被使用"。
然后验证 Service 是否抛出正确异常、是否停止插入数据。
模拟对象叫 Mock。项目中的 spring-boot-starter-test 已包含 Mockito 测试工具,不需要再添加依赖。
2.创建测试类
在测试目录:
src/test/java/com/example/skyforgelearning
创建 service 包,再新建 UserServiceTest.java:
package com.example.skyforgelearning.service;
import com.example.skyforgelearning.converter.UserConverter;
import com.example.skyforgelearning.dto.CreateUserRequest;
import com.example.skyforgelearning.dto.UpdateUserRequest;
import com.example.skyforgelearning.dto.UserResponse;
import com.example.skyforgelearning.entity.User;
import com.example.skyforgelearning.exception.PhoneAlreadyUsedException;
import com.example.skyforgelearning.exception.UserNotFoundException;
import com.example.skyforgelearning.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/** 用户业务规则单元测试,不连接数据库。 */
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
/** 模拟数据仓库。 */
@Mock
private UserRepository userRepository;
/** 使用真实业务代码和转换器进行测试。 */
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService(
userRepository, new UserConverter());
}
/** 手机号可用时,应保存用户并返回生成的 ID。 */
@Test
void shouldCreateUserWhenPhoneIsAvailable() {
CreateUserRequest request = new CreateUserRequest();
request.setName("张三");
request.setPhone("13800138000");
request.setAge(20);
when(userRepository.existsByPhone("13800138000", null))
.thenReturn(false);
when(userRepository.insert(any(User.class)))
.thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(100L);
return user;
});
UserResponse response = userService.createUser(request);
assertEquals(Long.valueOf(100L), response.getId());
assertEquals("张三", response.getName());
assertEquals("13800138000", response.getPhone());
assertEquals(Integer.valueOf(20), response.getAge());
verify(userRepository).insert(any(User.class));
}
/** 手机号已被使用时,应拒绝新增,也不能执行插入。 */
@Test
void shouldRejectCreateWhenPhoneIsAlreadyUsed() {
CreateUserRequest request = new CreateUserRequest();
request.setName("李四");
request.setPhone("13800138000");
request.setAge(25);
when(userRepository.existsByPhone("13800138000", null))
.thenReturn(true);
assertThrows(
PhoneAlreadyUsedException.class,
() -> userService.createUser(request));
verify(userRepository, never()).insert(any(User.class));
}
/** 修改用户时,应允许保留自己的手机号。 */
@Test
void shouldAllowUpdateWithOwnPhone() {
User existingUser = new User();
existingUser.setId(100L);
existingUser.setName("张三");
existingUser.setPhone("13800138000");
existingUser.setAge(20);
UpdateUserRequest request = new UpdateUserRequest();
request.setName("张三丰");
request.setPhone("13800138000");
request.setAge(30);
when(userRepository.findById(100L))
.thenReturn(existingUser);
when(userRepository.existsByPhone("13800138000", 100L))
.thenReturn(false);
when(userRepository.update(any(User.class)))
.thenReturn(true);
UserResponse response =
userService.updateUser(100L, request);
assertEquals(Long.valueOf(100L), response.getId());
assertEquals("张三丰", response.getName());
assertEquals("13800138000", response.getPhone());
verify(userRepository)
.existsByPhone("13800138000", 100L);
verify(userRepository).update(any(User.class));
}
/** 用户不存在时,应拒绝修改。 */
@Test
void shouldRejectUpdateWhenUserDoesNotExist() {
UpdateUserRequest request = new UpdateUserRequest();
request.setName("张三");
request.setPhone("13800138000");
request.setAge(20);
when(userRepository.findById(999L))
.thenReturn(null);
assertThrows(
UserNotFoundException.class,
() -> userService.updateUser(999L, request));
verify(userRepository, never()).update(any(User.class));
}
}
这里的 Service 构造方法按之前的顺序传入:
UserService(UserRepository userRepository,
UserConverter userConverter)
如果你自行调整过构造方法参数顺序,测试中也要保持一致。
3.认识测试中的几个写法
写法 作用
@ExtendWith(MockitoExtension.class) 启用 Mockito,初始化模拟对象
@Mock 创建模拟 Repository
@BeforeEach 每个测试执行前,重新创建 Service
when(...).thenReturn(...) 指定模拟方法返回什么
assertEquals(...) 验证实际结果是否等于预期
assertThrows(...) 验证是否抛出指定异常
verify(...) 验证某个操作确实被调用
verify(..., never()) 验证某个操作没有被调用
例如:
when(userRepository.existsByPhone("13800138000", null))
.thenReturn(true);
表示:
测试期间,查询这个手机号时,模拟仓库返回"已被使用"。
而:
verify(userRepository, never()).insert(any(User.class));
表示:
不仅要报手机号重复,还要确认没有继续保存用户。
第一条测试中的 thenAnswer 则模拟数据库插入后回填 ID。这里的 100L 只是测试数据,不会写入真实数据库。
4.运行测试
点击 UserServiceTest 类旁边的绿色按钮,运行整组测试。
预期:
4 个测试通过
这次没有 @SpringBootTest,因此:
- 不需要启动应用或 Docker。
- 不需要设置数据库密码。
- 不会出现 Spring Boot 启动横幅。
如果直接运行全部测试,之前的 UserMapperTest 仍然需要真实数据库;先单独运行这一类。
5.做一个小练习
自己再补一个测试:
修改用户 A 时,手机号已被其他用户占用。
提示:
1. findById 返回用户 A。
2. existsByPhone(手机号, A的ID) 返回 true。
3. 验证抛出 PhoneAlreadyUsedException。
4. 验证 Repository 的 update 没有被调用。
这次要掌握的是:测试不仅检查正常结果,也检查失败时有没有阻止不该发生的数据操作。
25.Controller 测试
java
下一步学习 Controller 测试:自动检查请求地址、参数绑定、校验和 JSON 响应。
上一节测试 Service 的业务规则,这次测试 HTTP 入口:
模拟 HTTP 请求 → Controller → 模拟的 Service → 检查响应
我们使用 MockMvc。它不需要启动真实端口,也不连接 MySQL。
1.创建测试类
在测试目录的 com.example.skyforgelearning 下创建 controller 包,再新建 UserControllerTest.java。
先添加下面的代码:
package com.example.skyforgelearning.controller;
import com.example.skyforgelearning.dto.CreateUserRequest;
import com.example.skyforgelearning.dto.UserResponse;
import com.example.skyforgelearning.exception.GlobalExceptionHandler;
import com.example.skyforgelearning.service.UserService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** 用户接口测试,验证请求绑定、校验和响应。 */
@WebMvcTest(UserController.class)
@Import(GlobalExceptionHandler.class)
class UserControllerTest {
/** 模拟 HTTP 请求的测试工具。 */
@Autowired
private MockMvc mockMvc;
/** 替换 Controller 依赖的真实业务服务。 */
@MockitoBean
private UserService userService;
// 后续三个测试方法放在这里。
}
几个新注解:
注解 作用
@WebMvcTest(UserController.class) 加载指定 Controller 及 Web 测试所需组件
@Import(GlobalExceptionHandler.class) 明确加载我们写的全局异常处理类
@MockitoBean 用模拟对象替换 Spring 中的 UserService
这里的 @MockitoBean 与上一节的 @Mock 不同:它还负责把模拟对象放进 Spring 测试环境,供 Controller 注入。
你的 Spring Boot 3.4 项目可以使用这个注解。
2.测试路径参数和查询参数
把下面方法加入测试类:
/** 路径 ID 和手机号展示参数应正确传给 Service。 */
@Test
void shouldBindUserIdAndShowPhone() throws Exception {
UserResponse response = new UserResponse();
response.setId(100L);
response.setName("张三");
response.setPhone("13800138000");
response.setAge(20);
when(userService.getUser(100L, true))
.thenReturn(response);
mockMvc.perform(
get("/api/user/100")
.param("showPhone", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(100))
.andExpect(jsonPath("$.data.name").value("张三"))
.andExpect(jsonPath("$.data.phone")
.value("13800138000"));
verify(userService).getUser(100L, true);
}
逐句理解:
get("/api/user/100")
模拟 GET 请求。
.param("showPhone", "true")
提供查询参数。
.andExpect(status().isOk())
要求 HTTP 状态码为 200。
jsonPath("$.data.name")
定位响应 JSON 中 data 下的 name 字段。
最后的:
verify(userService).getUser(100L, true);
验证 Controller 确实把字符串路径和查询参数转换成了 Long、`boolean`,再传给 Service。
3.测试 JSON 请求体绑定
继续加入:
/** JSON 请求体应正确转换为新增用户请求对象。 */
@Test
void shouldBindCreateUserRequest() throws Exception {
UserResponse response = new UserResponse();
response.setId(101L);
response.setName("李四");
response.setPhone("13900139000");
response.setAge(25);
when(userService.createUser(any(CreateUserRequest.class)))
.thenReturn(response);
mockMvc.perform(
post("/api/user")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "李四",
"phone": "13900139000",
"age": 25
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(101));
ArgumentCaptor<CreateUserRequest> captor =
ArgumentCaptor.forClass(CreateUserRequest.class);
verify(userService).createUser(captor.capture());
CreateUserRequest actualRequest = captor.getValue();
assertEquals("李四", actualRequest.getName());
assertEquals("13900139000", actualRequest.getPhone());
assertEquals(Integer.valueOf(25), actualRequest.getAge());
}
这里有两个新知识:
- """ ... """ 是 Java 文本块,方便编写多行 JSON。
- ArgumentCaptor 用于捕获传给模拟方法的实际参数。
为什么不只检查返回结果?
因为返回对象是我们预先安排的。捕获请求对象,才能确认姓名、手机号和年龄确实从 JSON 正确绑定了进来。
4.测试校验失败时阻止业务执行
继续加入:
/** 姓名为空时应返回 400,并且不能调用新增业务。 */
@Test
void shouldRejectBlankNameBeforeCallingService() throws Exception {
mockMvc.perform(
post("/api/user")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "",
"phone": "13900139000",
"age": 25
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message")
.value("请求参数校验失败"))
.andExpect(jsonPath("$.data")
.isArray());
verify(userService, never())
.createUser(any(CreateUserRequest.class));
}
这个测试验证两件事:
1. 请求校验失败时,返回统一错误结构。
2. 错误请求不会进入新增用户业务。
5.运行测试
点击 UserControllerTest 类旁边的绿色按钮,运行整组测试。
预期 3 个测试通过。不需要启动 MySQL,也不需要填写数据库密码。
现在三种测试的分工是:
测试类 主要验证什么 是否使用真实 MySQL
UserControllerTest HTTP 参数、校验、响应 否
UserServiceTest 业务判断及失败分支 否
UserMapperTest SQL、表结构和数据库访问 是
先把这三个 Controller 测试跑通。完成后,你就能自动验证"请求有没有正确到达业务层",而不只依靠 Apifox 手动检查。
26.日志
java
日志:让你在控制台看清楚"谁调用了什么操作、是否成功、为什么失败"。
你现在主要通过 Apifox 看响应。加入日志后,就能同时观察后端执行过程。
1.在 UserService 中创建日志对象
添加 import:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
在 UserService 类内部、成员字段前添加:
/** 用户业务日志。 */
private static final Logger log =
LoggerFactory.getLogger(UserService.class);
不需要新增 Maven 依赖,当前 Spring Boot Starter 已包含日志支持。
这段代码表示:为 UserService 创建一个日志记录器。输出时会带上时间、日志级别、类名等信息。
2.记录新增成功
修改新增方法:
/** 新增用户,手机号不能重复。 */
public UserResponse createUser(CreateUserRequest request) {
ensurePhoneAvailable(request.getPhone(), null);
User user = userConverter.toUser(request);
User savedUser = userRepository.insert(user);
log.info("新增用户成功,userId={}", savedUser.getId());
return userConverter.toResponse(savedUser, true);
}
关键是:
log.info("新增用户成功,userId={}", savedUser.getId());
{} 是占位符,会被后面的值替换,例如:
新增用户成功,userId=12
日志放在插入成功之后,才能准确表示本次保存已经完成。
3.记录修改和删除成功
在 updateUser 方法中,确认更新成功后、返回结果前,添加:
log.info("修改用户成功,userId={}", userId);
也就是:
boolean updated = userRepository.update(replacement);
if (!updated) {
throw new UserNotFoundException(userId);
}
log.info("修改用户成功,userId={}", userId);
return userConverter.toResponse(replacement, true);
删除方法改为:
/** 删除用户,不存在时返回明确错误。 */
public void deleteUser(Long userId) {
boolean deleted = userRepository.deleteById(userId);
if (!deleted) {
throw new UserNotFoundException(userId);
}
log.info("删除用户成功,userId={}", userId);
}
这里用用户 ID 定位操作,不需要记录密码、完整手机号或整份请求体。
4.记录业务规则拒绝操作
在已有的手机号校验方法中添加日志:
/** 同一手机号只能属于一名用户。 */
private void ensurePhoneAvailable(
String phone, Long excludedUserId) {
if (userRepository.existsByPhone(phone, excludedUserId)) {
log.warn(
"用户保存被拒绝,手机号已被使用,excludedUserId={}",
excludedUserId);
throw new PhoneAlreadyUsedException();
}
}
新增时 excludedUserId 为 null;修改时是当前用户 ID。
目前只在这一处记录这个事件,避免在 Service 和全局异常处理类中重复记录同一个错误。
5.认识日志级别
级别 常见用途 例子
DEBUG 开发排查时的详细信息 查询条件、执行分支
INFO 正常的重要操作 新增用户成功
WARN 值得注意、但程序仍能继续处理的情况 本练习中的手机号冲突
ERROR 需要排查的运行故障 外部服务调用异常
不要看到红色就认为程序失败,仍然要结合日志级别、异常内容和接口结果判断。
6.配置日志级别
在 application.properties 中添加:
# 本项目代码输出 INFO 及以上级别的日志。
logging.level.com.example.skyforgelearning=INFO
如果以后想临时观察自己的 log.debug(...),可以改成:
logging.level.com.example.skyforgelearning=DEBUG
暂时保持 INFO 即可。
7.重启并观察
打开底部 Run → SkyForgeLearningApplication 控制台,然后通过 Apifox:
1. 新增一个手机号未被使用的用户,应出现新增成功日志。
2. 修改这个用户,应出现修改成功日志。
3. 尝试新增相同手机号,应返回 409,并出现 WARN 日志。
4. 删除测试用户,应出现删除成功日志。
日志与响应的用途不同:
接口响应:告诉调用方操作结果
后端日志:帮助开发者定位执行过程
这一节完成后,下一步学习 事务 @Transactional:当一次业务需要同时保存多张表时,如何保证其中一步失败后,前面的修改也撤销。
27.事务 @Transactional
java
这一节学习 事务 @Transactional:一次业务涉及多次数据库操作时,保证它们一起成功或一起撤销。
我们新增一个场景:
创建用户时,同时在另一张表中保存"创建用户"的操作记录。如果任何一步失败,两张表都不能留下本次数据。
这里的"操作记录"是数据库数据,与你上一节学习的控制台日志不同。
1.创建操作记录表
在数据库工具中执行:
CREATE TABLE sky_forge_learning.learning_user_operation (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID',
user_id BIGINT NOT NULL COMMENT '被操作用户ID',
operation VARCHAR(20) NOT NULL COMMENT '操作类型',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
COMMENT '操作时间',
PRIMARY KEY (id),
KEY idx_user_id (user_id)
) ENGINE = InnoDB COMMENT = '用户操作记录';
再确认用户表也是支持事务的 InnoDB:
SHOW TABLE STATUS
FROM sky_forge_learning
WHERE Name IN ('learning_user', 'learning_user_operation');
查看结果中的 Engine,两张表都应为 InnoDB。
操作记录保留用户 ID 用于追踪,即使用户以后被删除,记录也有保留价值;这一节暂不添加外键。
2.创建操作记录 Repository
在 repository 包下新建 UserOperationRepository.java:
package com.example.skyforgelearning.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
/** 用户操作记录的数据访问。 */
@Repository
public class UserOperationRepository {
/** Spring 提供的 SQL 执行工具。 */
private final JdbcTemplate jdbcTemplate;
public UserOperationRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/** 保存用户创建记录。 */
public void insertCreateRecord(Long userId) {
jdbcTemplate.update(
"""
INSERT INTO learning_user_operation
(user_id, operation)
VALUES (?, ?)
""",
userId,
"CREATE");
}
}
这里顺便认识 JdbcTemplate:Spring 提供的 SQL 执行工具,当前项目的 JDBC 依赖已包含它。
VALUES (?, ?)
中的 ? 是参数占位符,分别绑定后面的:
userId
"CREATE"
这不是手动拼接 SQL。
本例只写一条简单 INSERT,因此用 JdbcTemplate;也可以用 MyBatis-Plus 实现。当前单数据源配置下,它与 MyBatis-Plus 可以参与同一个 Spring 事务。
3.把新 Repository 注入 Service
在 UserService 中添加 import:
import com.example.skyforgelearning.repository.UserOperationRepository;
import org.springframework.transaction.annotation.Transactional;
增加字段:
/** 用户操作记录仓库。 */
private final UserOperationRepository userOperationRepository;
将原来的构造方法修改为:
public UserService(
UserRepository userRepository,
UserConverter userConverter,
UserOperationRepository userOperationRepository) {
this.userRepository = userRepository;
this.userConverter = userConverter;
this.userOperationRepository = userOperationRepository;
}
4.给新增方法添加事务
将新增用户方法改为:
/** 创建用户并保存操作记录,两项操作必须一起成功。 */
@Transactional
public UserResponse createUser(CreateUserRequest request) {
ensurePhoneAvailable(request.getPhone(), null);
User user = userConverter.toUser(request);
User savedUser = userRepository.insert(user);
userOperationRepository.insertCreateRecord(savedUser.getId());
return userConverter.toResponse(savedUser, true);
}
事务边界放在 Service,因为这里编排了两次数据库写入。
执行过程是:
开始事务
↓
插入用户
↓
插入操作记录
↓
方法正常返回
↓
提交事务
如果中间抛出需要回滚的异常:
插入用户
↓
保存操作记录失败
↓
回滚事务
↓
本次新增的用户也被撤销
暂时不在方法末尾打印"创建成功"日志:事务真正提交发生在方法返回之后,方法内部打印成功仍可能早于最终提交。
5.先验证正常情况
保存、重启应用,通过原来的 POST 接口创建一个使用新手机号的用户。
记住返回的 ID,然后查询:
SELECT id, name, phone, age
FROM sky_forge_learning.learning_user
WHERE id = 这里替换成实际ID;
SELECT id, user_id, operation, created_at
FROM sky_forge_learning.learning_user_operation
WHERE user_id = 这里替换成实际ID;
预期两张表各有对应记录,操作类型为 CREATE。
6.亲手观察回滚
只在本地学习项目中,临时将新增方法最后的:
return userConverter.toResponse(savedUser, true);
替换为:
throw new IllegalStateException("学习事务:模拟业务失败");
注意是替换,不是把 throw 插在 return 前面,否则后面的代码会变成不可达语句,导致编译错误。
重启后,使用一个未使用过的手机号新增用户。接口应失败,通常返回 HTTP 500,控制台能看到这个模拟异常。
随后用本次提交的手机号查询:
SELECT id, name, phone
FROM sky_forge_learning.learning_user
WHERE phone = '这里替换成本次提交的手机号';
结果应为空。再检查操作记录表:
SELECT id, user_id, operation, created_at
FROM sky_forge_learning.learning_user_operation
ORDER BY id DESC;
不应留下本次失败操作的记录。验证时可以对照操作前后的记录数和最新记录。
完成观察后,立即把 throw 恢复成原来的 return,保存并重启。
自增 ID 在回滚后可能出现跳号,这是正常现象;事务回滚不保证归还已经分配的 ID。
7.记住事务的三个条件
- 默认情况下,`RuntimeException` 和 Error 会触发回滚;普通受检异常默认不会,本节的 IllegalStateException 属于运行时异常。
- 不要捕获异常后直接返回"成功",否则 Spring 可能按正常结束提交事务。
- 本例必须通过 Controller 调用 Spring 管理的 Service。默认代理模式下,同一个类里用 this.createUser(...) 调用,不会触发该方法上的事务代理。Spring 官方说明
另外,你之前已创建 UserServiceTest,虽然暂时跳过测试学习,但 Service 构造方法新增了参数。若编译提示测试中的构造参数不足,需要在测试类增加:
@Mock
private UserOperationRepository userOperationRepository;
并把初始化改为:
userService = new UserService(
userRepository,
new UserConverter(),
userOperationRepository);
记得导入对应 Repository。
这一节完成标准是:正常新增时两张表都有记录,模拟失败时两张表都不留下本次数据。
28.数据库版本管理 Flyway
java
下一步学习 数据库版本管理 Flyway:以后加字段、建表时,把 SQL 保存到项目中,由应用按顺序执行。
例如:
V1:创建用户表和操作记录表
V2:给用户表增加创建时间
V3:以后新增的数据库变更
这样换电脑或部署新环境时,不必回忆"以前手动执行过哪些 SQL"。
1.先理解现有数据库怎么接入
你的学习库已经有表,不能再直接执行一次建表。
我们把当前结构定义为 版本 1:
现有数据库:确认已经符合 V1,登记起点,之后执行 V2
空数据库:执行 V1 建表,再执行 V2
这个"登记起点"叫 baseline(基线)。它只登记版本,不会检查现有表是否真的符合 V1,所以需要先确认结构。官方说明
2.先检查当前表结构
在数据库工具执行:
SHOW CREATE TABLE sky_forge_learning.learning_user;
SHOW CREATE TABLE sky_forge_learning.learning_user_operation;
确认当前有:
- 用户表:`id`、`name`、`phone`、`age`,以及手机号唯一约束。
- 操作记录表:`id`、`user_id`、`operation`、`created_at`。
- 两张表都是 InnoDB。
如果缺少某项,先补齐再接入下面的基线,不要直接把不完整的库标记成 V1。
3.添加 Flyway 依赖
在 pom.xml 的 <dependencies> 中新增:
<!-- 数据库版本迁移。 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Flyway 的 MySQL 支持。 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
版本由 Spring Boot 父项目管理。保存并重新加载 Maven,先不要启动应用,继续完成下面配置和脚本。
4.保存 V1 初始结构
在:
src/main/resources
下面创建目录:
db/migration
再创建文件:
V1__create_user_tables.sql
注意 V1 后面是 两个下划线。
内容如下:
CREATE TABLE learning_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
name VARCHAR(50) NOT NULL COMMENT '用户姓名',
phone VARCHAR(20) NOT NULL COMMENT '手机号',
age INT NOT NULL COMMENT '年龄',
PRIMARY KEY (id),
UNIQUE KEY uk_learning_user_phone (phone)
) ENGINE = InnoDB COMMENT = '学习用用户表';
CREATE TABLE learning_user_operation (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID',
user_id BIGINT NOT NULL COMMENT '被操作用户ID',
operation VARCHAR(20) NOT NULL COMMENT '操作类型',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
COMMENT '操作时间',
PRIMARY KEY (id),
KEY idx_user_id (user_id)
) ENGINE = InnoDB COMMENT = '用户操作记录';
这个文件是给从空数据库初始化使用的,不要手动在当前已有表的库中执行。
它只创建结构,不放张三、李四等练习数据。
5.添加 V2 数据库变更
在同一目录新建:
V2__add_user_created_at.sql
内容:
ALTER TABLE learning_user
ADD COLUMN created_at DATETIME NOT NULL
DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间';
这次给用户表增加 created_at。
执行前确认用户表尚未存在这个字段:
SHOW COLUMNS
FROM sky_forge_learning.learning_user
LIKE 'created_at';
应没有结果。注意:操作记录表已经有 created_at,这里修改的是用户表。
6.配置 Flyway
在 application.properties 中添加:
# 开启数据库迁移。
spring.flyway.enabled=true
# SQL 脚本位置。
spring.flyway.locations=classpath:db/migration
# 当前已有表,但还没有迁移历史,首次接入时登记基线。
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
# 禁止通过 Flyway clean 清空数据库。
spring.flyway.clean-disabled=true
# 不使用另一套 schema.sql 初始化机制。
spring.sql.init.mode=never
这里最关键的是:
spring.flyway.baseline-version=1
对于当前已有表、且没有 Flyway 历史表的学习库:
登记 V1 基线 → 跳过 V1 脚本 → 执行 V2
不要把基线版本设成 2,否则 V2 也会被跳过。
7.启动并验证
确认数据库地址仍指向:
sky_forge_learning
然后启动应用,观察 Run 控制台中的 Flyway 日志。
成功后,在数据库工具执行:
SELECT installed_rank, version, type, script, success
FROM sky_forge_learning.flyway_schema_history
ORDER BY installed_rank;
当前已有数据库的预期记录类似:
version type 含义
1 BASELINE 登记已有结构的版本起点
2 SQL 执行增加用户创建时间的脚本
再执行:
SHOW COLUMNS
FROM sky_forge_learning.learning_user
LIKE 'created_at';
应能看到新增字段。
最后重启一次应用,确认 V2 不会重复执行。现有 Java User 暂时不添加这个字段也可以,新增时数据库会使用默认值。
8.完成首次接入后关闭自动基线
确认迁移成功,将配置改为:
spring.flyway.baseline-on-migrate=false
以后这个库已有历史记录,不需要自动登记基线。
未来使用空的学习数据库时,Flyway 可以直接执行 V1、V2;遇到其他已有表但没有历史的数据库,则应先确认结构再决定接入方式。
以后遵循两个规则:
- 已经成功执行的迁移文件不再修改,新变更另建 V3__...sql。
- MySQL 建表、改表失败时,不能假设所有结构修改都像前一节的 INSERT 一样回滚;要先检查实际表结构和失败日志。
这一节完成标准:应用自动执行 V2,用户表出现 created_at,历史表记录成功,再次启动不重复改表。