151 从0开始搭建后端项目

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,历史表记录成功,再次启动不重复改表。
相关推荐
wangruofeng8 分钟前
手机和电脑传文件,除了微信还有 LocalSend
前端
excel1 小时前
基于 GPT 的自动发布文章接口方案:从一句话或一张图片生成并发布内容
前端·javascript·后端
其实防守也摸鱼2 小时前
个人学习 Pwn 思路:从基础到漏洞利用的进阶路径
大数据·前端·人工智能·chrome·算法·安全·网络安全
朱涛的自习室3 小时前
212 个需求、22 万行代码:Agent 接管开源项目的 19 天
android·前端·人工智能
aZhe的全栈知识分享3 小时前
AI 还原 Figma 效果总达不到预期?先别急着让它写代码
前端·后端·面试
review445433 小时前
Codex Hooks 入门实战:生命周期事件、matcher 与5分钟跑通一个钩子
前端·agent·vibecoding
函数小陈3 小时前
阿里云部署DSH与RAM完成运维
前端·后端
用户499335025073 小时前
JS 作用域劫持的死穴:动态 this 穿透,以及 AST 怎么救
前端
IT_陈寒4 小时前
React的setState真是个演员,你以为它同步其实在装睡
前端·人工智能·后端