Spring Boot入门篇

Spring Boot 是一个基于 Spring 的框架,旨在简化 Spring 应用的配置和开发过程,通过自动配置和约定大于配置的原则,使开发者能够快速搭建独立、生产级别的应用程序。

必需环境准备

JDK 安装(18.0.2、window)

  • 下载地址https://www.oracle.com/java/technologies/javase/jdk18-archive-downloads.html,**Windows**选择 x64 Installer(.exe 文件)。
  • 运行安装程序:双击下载的 .exe 文件,一路点击 Next(也可以指定安装路径)。
  • 配置环境变量:右键点击 此电脑 → 属性 → 高级系统设置 → 环境变量,在 系统变量 区域,点击新建:变量名:JAVA_HOME,变量值:C:\Users\${whoami}\.jdks\corretto-18.0.2;再找到 Path 变量,双击打开,点击 新建,添加:%JAVA_HOME%\bin,点击所有窗口的 确定 保存。
# 查看用户名
whoami

# 查看 java 路径
where java
# C:\Users\${whoami}\.jdks\corretto-18.0.2\bin\java.exe

# 查看 java 版本
java -version

IntelliJ IDEA 安装

构建工具(Maven)

  • Spring Boot 默认支持,配置简单。

  • IDEA 内置 Maven,通常无需单独安装

创建 Spring Boot 项目

创建项目

打开 IDEA → New Project,配置:

  • 服务器URL:默认 https://start.spring.io,(也可用阿里云镜像加速,https://start.aliyun.com)
  • 名称:项目名
  • 位置:项目路径
  • 语言:Java
  • 类型:Maven
  • :com.example(公司域名倒写)
  • JDK:选择已安装的 JDK 版本
  • 打包:Jar
  • Java:匹配 JDK 版本

源码解读

src/main/java/com/lizhao/springboot/Application.java 源码:

package com.lizhao.springboot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

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

package

package com.lizhao.springboot;

声明这个类所在的包(package),类似于文件系统的文件夹。命名规则:通常是公司域名的倒写 + 项目名。

import

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

导入 Spring Boot 框架需要的两个核心类:SpringApplication,负责启动 Spring Boot 应用;SpringBootApplication:这是一个组合注解

@SpringBootApplication

这是最关键的注解,它包含了三个注解的功能:

注解 作用
@SpringBootConfiguration 标记一个配置类,相当于传统的 applicationContext.xml
@EnableAutoConfiguration 核心:让 Spring Boot 根据添加的依赖自动配置(比如,加了 spring-web 依赖,它自动配置 Tomcat 和 Spring MVC)
@ComponentScan 自动扫描当前包及其子包下的组件(如 @Controller@Service@Repository 等)

主入口

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

Application,常见的 Java 类命名。

public static void main(String[] args),Java 程序的标准入口方法,任何 Java 程序的起点。

SpringApplication.run(Application.class, args);启动 Spring Boot 的核心代码,包含:

  • 启动内嵌的 Web 服务器(默认 Tomcat,端口 8080)
  • 创建 IoC 容器(管理所有 Bean 对象)
  • 执行自动配置(根据依赖和注解装配组件)
  • 扫描并注册控制器、服务、仓库等组件

Application.class,告诉 Spring Boot 启动类是哪个(就是当前类)。

完整执行流程

  • 运行 main 方法;
  • @SpringBootApplication 被解析;
  • SpringApplication.run() 执行;
  • 启动内嵌 Tomcat(端口 8080);
  • 扫描 com.lizhao.springboot 包下的组件;
  • 根据 pom.xml 中的依赖自动配置(如 Web、数据库等);
  • 控制台打印 Spring Boot 启动横幅;
  • 应用启动完成,等待请求。

点击 IntelliJ IDEA 运行 按钮,启动程序。

一个简单 HTTP 请求示例

pom.xml 配置

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

源码解读

src/main/java/com/lizhao/springboot/controller/HelloController.java 源码:

package com.lizhao.springboot.controller;

import com.lizhao.springboot.common.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;

@RestController
@RequestMapping("/api/hello")
public class HelloController {

  @GetMapping("/helloWord")
  public ApiResponse<String> helloWord() {
    return ApiResponse.ok("Hello, World!");
  }

  @GetMapping("/{name}")
  public ApiResponse<String> sayHello(@PathVariable String name) {
    return ApiResponse.ok("Hello, " + name + "!");
  }
}

这段代码是一个典型的 Spring Boot REST API 控制器,用于处理 HTTP 请求并返回 JSON 数据。

@RestController@RequestMapping@GetMapping@PathVariable,是 Spring Web 提供的注解,用于定义 API 接口。

@RestController

组合注解,相当于 @Controller + @ResponseBody

注解 作用
@Controller 标记这是一个控制器,Spring 会扫描并管理它
@ResponseBody 将方法的返回值自动转为 JSON 写入 HTTP 响应体

没有 @ResponseBody 的话,Spring 会默认返回视图名称(JSP/Thymeleaf 页面),有了它就直接返回数据。

@RequestMapping

类级别路径映射,即这个控制器下所有方法的 URL 都以 /api/hello 开头。

@GetMapping

方法级别路径映射,处理 HTTP GET 请求,完整访问路径 = 类路径 + 方法路径。

常见的 HTTP 方法映射注解:

  • @GetMapping
  • @PostMapping
  • @PutMapping
  • @PatchMapping
  • @DeleteMapping

@PathVariable

URL 路径中提取变量值。

常见相关注解:

  • @PathVariable:从 URL 路径中提取变量值。
  • @RequestParam:从 URL 查询字符串表单数据中提取参数。
  • @RequestBody:从 HTTP 请求体中读取 JSON/XML 数据,并自动转换为 Java 对象。
  • @RequestHeader:从 HTTP 请求头中提取特定字段的值。

运行程序

点击 IntelliJ IDEA 运行 按钮,启动程序。

浏览器打开:http://localhost:8080/api/hello/helloWord,即可查看接口返回数据。

支持 HTTP/HTTPS

生成签名证书

cd src/main/resources
keytool -genkeypair -alias springboot -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore springboot-keystore.p12 -validity 3650 -storepass 123456 -dname "CN=localhost, OU=lizhao, O=lizhao, L=HZ, ST=ZJ, C=CN" -ext "SAN=dns:localhost,dns:*.izhao.com.cn,ip:127.0.0.1,ip:::1"

application.properties 配置

src/main/resources/application.properties

server.port=8443
server.ssl.enabled=true
server.ssl.key-store=classpath:springboot-keystore.p12
server.ssl.key-store-type=PKCS12
server.ssl.key-store-password=123456
server.ssl.key-alias=springboot
server.http.port=8080

HTTP 配置

src/main/java/com/lizhao/springboot/config/TomcatHttpConnectorConfig.java

package com.lizhao.springboot.config;

import org.apache.catalina.connector.Connector;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TomcatHttpConnectorConfig {

  @Bean
  public WebServerFactoryCustomizer<TomcatServletWebServerFactory> additionalHttpConnector() {
    return (factory) -> {
      Connector connector = new Connector();
      connector.setScheme("http");
      connector.setPort(httpPort);
      connector.setSecure(false);
      factory.addAdditionalConnectors(connector);
    };
  }

  @Value("${server.http.port:8080}")
  private int httpPort;
}

允许跨域

src/main/java/com/lizhao/springboot/config/CorsConfig.java

package com.lizhao.springboot.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
        .allowedOrigins(
          "http://*.izhao.com.cn",
          "http://dev.izhao.com.cn:9000",
          "http://localhost:9000",
          "https://*.izhao.com.cn",
          "https://dev.izhao.com.cn:9000",
          "https://localhost:9000"
        )
        .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
        .allowedHeaders("*")
        .allowCredentials(true)
        .maxAge(3600);
  }
}

连接数据库

application.properties 配置

spring-boot\src\main\resources\application.properties

spring.datasource.url=jdbc:mysql://localhost:3306/yizhao?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=lizhao
spring.datasource.password=lizh1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.maximum-pool-size=10

spring.jpa.hibernate.ddl-auto=none
spring.jpa.open-in-view=false

pom.xml 配置

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

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

代码示例

UserEntity.java

src/main/java/com/lizhao/springboot/user/UserEntity.java

package com.lizhao.springboot.user;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import java.time.LocalDateTime;

@Entity
@Table(name = "`user`")
public class UserEntity {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, length = 36)
  private String uuid;

  @Column(nullable = false, length = 11)
  private String phone;

  @Column private String name;

  @Column private Integer sex;

  @Column private LocalDateTime birthday;

  @Column(nullable = false)
  private String alias;

  @Column private String nickname;

  @Column private String email;

  @Column(nullable = false)
  private Integer role;

  @Column(nullable = false)
  private String password;

  @Column private String captcha;

  @Column private String token;

  @Column(name = "created_at", insertable = false, updatable = false)
  private LocalDateTime createdAt;

  @Column(name = "updated_at", insertable = false, updatable = false)
  private LocalDateTime updatedAt;

  @Column(name = "is_deleted", nullable = false)
  private Integer isDeleted;

  @Transient
  public boolean isActive() {
    return isDeleted != null && isDeleted == 0;
  }

  public Long getId() {
    return id;
  }

  public String getUuid() {
    return uuid;
  }

  public String getPhone() {
    return phone;
  }

  public String getName() {
    return name;
  }

  public Integer getSex() {
    return sex;
  }

  public LocalDateTime getBirthday() {
    return birthday;
  }

  public String getAlias() {
    return alias;
  }

  public String getNickname() {
    return nickname;
  }

  public String getEmail() {
    return email;
  }

  public Integer getRole() {
    return role;
  }

  public String getPassword() {
    return password;
  }

  public void setPassword(String password) {
    this.password = password;
  }

  public String getCaptcha() {
    return captcha;
  }

  public String getToken() {
    return token;
  }

  public void setToken(String token) {
    this.token = token;
  }

  public LocalDateTime getCreatedAt() {
    return createdAt;
  }

  public LocalDateTime getUpdatedAt() {
    return updatedAt;
  }

  public Integer getIsDeleted() {
    return isDeleted;
  }
}

UserProfileDto.java

src/main/java/com/lizhao/springboot/user/UserProfileDto.java

package com.lizhao.springboot.user;

import java.time.LocalDateTime;

public record UserProfileDto(
    long id,
    String uuid,
    String phone,
    String name,
    Integer sex,
    LocalDateTime birthday,
    String alias,
    String nickname,
    String email,
    int role,
    LocalDateTime createdAt,
    LocalDateTime updatedAt
) {}

UserRepository.java

src/main/java/com/lizhao/springboot/user/UserRepository.java

package com.lizhao.springboot.user;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<UserEntity, Long> {
  Optional<UserEntity> findByIdAndIsDeleted(long id, Integer isDeleted);

  Optional<UserEntity> findByPhoneAndIsDeleted(String phone, Integer isDeleted);

  Optional<UserEntity> findByTokenAndIsDeleted(String token, Integer isDeleted);
}

UserController.java

src/main/java/com/lizhao/springboot/user/UserController.java

package com.lizhao.springboot.user;

import com.lizhao.springboot.common.ApiResponse;
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/user")
public class UserController {
  private final UserRepository userRepository;

  public UserController(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @GetMapping("/{id}")
  public ApiResponse<UserProfileDto> getUserById(@PathVariable long id) {
    return userRepository
        .findByIdAndIsDeleted(id, 0)
        .map(UserController::toProfileDto)
        .map(ApiResponse::ok)
        .orElseGet(() -> ApiResponse.notFound("用户不存在"));
  }

  private static UserProfileDto toProfileDto(UserEntity u) {
    return new UserProfileDto(
        u.getId(),
        u.getUuid(),
        u.getPhone(),
        u.getName(),
        u.getSex(),
        u.getBirthday(),
        u.getAlias(),
        u.getNickname(),
        u.getEmail(),
        u.getRole() == null ? 0 : u.getRole(),
        u.getCreatedAt(),
        u.getUpdatedAt());
  }
}

登录鉴权(Cookie token)

application.properties 配置

src/main/resources/application.properties

app.security.whitelist=/api/hello/**,/api/login
app.security.token-cookie-name=token
app.security.token-days=7

pom.xml 配置

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

代码示例

TokenGenerator.java

src/main/java/com/lizhao/springboot/common/util/TokenGenerator.java

package com.lizhao.springboot.common.util;

import java.security.SecureRandom;
import java.util.Base64;

public final class TokenGenerator {
  private static final SecureRandom RNG = new SecureRandom();

  private TokenGenerator() {}

  public static String newToken() {
    byte[] bytes = new byte[32];
    RNG.nextBytes(bytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
  }
}

TokenCookie.java

src/main/java/com/lizhao/springboot\auth\TokenCookie.java

package com.lizhao.springboot.auth;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Duration;
import org.springframework.http.ResponseCookie;

public final class TokenCookie {
  private TokenCookie() {}

  public static String genCookie(String cookieName, String token, int days) {
    ResponseCookie cookie = ResponseCookie.from(cookieName, token)
      .httpOnly(true)
      .secure(true)
      .path("/")
      .sameSite("None")
      .maxAge(Duration.ofDays(days))
      .build();
    return cookie.toString();
  }

  public static String clearCookie(String cookieName) {
    ResponseCookie cookie = ResponseCookie.from(cookieName, "")
      .httpOnly(true)
      .secure(true)
      .path("/")
      .sameSite("None")
      .maxAge(Duration.ZERO)
      .build();
    return cookie.toString();
  }

  public static String readCookie(HttpServletRequest request, String name) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null || name == null) {
      return null;
    }
    for (Cookie c : cookies) {
      if (c != null && name.equals(c.getName())) {
        return c.getValue();
      }
    }
    return null;
  }
}

AuthProperties.java

src/main/java/com/lizhao/springboot/auth/AuthProperties.java

src/main/resources/application.properties 读取配置,有值则会覆盖其中的默认值。

package com.lizhao.springboot.auth;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.security")
public class AuthProperties {
  private String whitelist = "/api/hello/**,/api/login";

  private String tokenCookieName = "token";

  private int tokenDays = 7;

  public List<String> whitelistAsList() {
    if (whitelist == null || whitelist.isBlank()) {
      return List.of();
    }
    List<String> out = new ArrayList<>();
    Arrays.stream(whitelist.split(","))
        .map(String::trim)
        .filter(s -> !s.isBlank())
        .forEach(out::add);
    return out;
  }

  public String getWhitelist() {
    return whitelist;
  }

  public void setWhitelist(String whitelist) {
    this.whitelist = whitelist;
  }

  public String getTokenCookieName() {
    return tokenCookieName;
  }

  public void setTokenCookieName(String tokenCookieName) {
    this.tokenCookieName = tokenCookieName;
  }

  public int getTokenDays() {
    return tokenDays;
  }

  public void setTokenDays(int tokenDays) {
    this.tokenDays = tokenDays;
  }
}

SecurityConfig.java

配置哪些接口需要认证、使用什么过滤器、如何处理异常。

src/main/java/com/lizhao/springboot/auth/SecurityConfig.java

package com.lizhao.springboot.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.lizhao.springboot.common.ApiResponse;
import java.nio.charset.StandardCharsets;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableConfigurationProperties(AuthProperties.class)
public class SecurityConfig {
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http, TokenAuthFilter tokenAuthFilter, AuthProperties props) throws Exception {
    var whitelist = props.whitelistAsList().toArray(String[]::new);
    // 禁用 CSRF(因为使用无状态的 Token 认证)
    http.csrf(csrf -> csrf.disable());
    // 启用 CORS(允许跨域)
    http.cors(Customizer.withDefaults());
    // 设置为无状态会话(不使用 Session)
    http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    // 配置授权规则
    http.authorizeHttpRequests(auth -> auth.requestMatchers(whitelist).permitAll().anyRequest().authenticated());
    // 添加自定义过滤器
    http.addFilterBefore(tokenAuthFilter, UsernamePasswordAuthenticationFilter.class);
    // 自定义未授权响应(401)
    http.exceptionHandling(eh -> eh.authenticationEntryPoint((req, res, ex) -> {
      res.setStatus(401);
      res.setCharacterEncoding(StandardCharsets.UTF_8.name());
      res.setContentType(MediaType.APPLICATION_JSON_VALUE);
      ObjectMapper om = new ObjectMapper();
      res.getWriter().write(om.writeValueAsString(ApiResponse.error(401, "未授权")));
    }));
    return http.build();
  }
}

TokenAuthFilter.java

从 Cookie 中读取 Token 并验证用户身份。

src/main/java/com/lizhao/springboot/auth/TokenAuthFilter.java

package com.lizhao.springboot.auth;

import com.lizhao.springboot.user.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class TokenAuthFilter extends OncePerRequestFilter {
  private final UserRepository userRepository;
  private final AuthProperties props;
  private final AntPathMatcher ant = new AntPathMatcher();

  public TokenAuthFilter(UserRepository userRepository, AuthProperties props) {
    this.userRepository = userRepository;
    this.props = props;
  }

  @Override
  protected boolean shouldNotFilter(HttpServletRequest request) {
    String path = request.getRequestURI();
    for (String pattern : props.whitelistAsList()) {
      if (ant.match(pattern, path)) {
        return true;
      }
    }
    return false;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    if (SecurityContextHolder.getContext().getAuthentication() == null) {
      String token = TokenCookie.readCookie(request, props.getTokenCookieName());
      if (token != null && !token.isBlank()) {
        userRepository
          .findActiveByToken(token)
          .ifPresent(u -> {
            // 创建一个 Spring Security 标准的认证对象,并将其放入安全上下文中。即,告诉 Spring Security "当前请求已经通过认证,用户ID是 xxx"。
            var auth = new UsernamePasswordAuthenticationToken(u.getId(), null, java.util.List.of());
            SecurityContextHolder.getContext().setAuthentication(auth);
          });
      }
    }
    filterChain.doFilter(request, response);
  }
}
  • shouldNotFilter():白名单跳过。某些路径不需要认证(如登录页、注册接口),直接放行。
  • doFilterInternal():认证逻辑。

AuthService.java

src/main/java/com/lizhao/springboot/auth/AuthService.java

package com.lizhao.springboot.auth;

import com.lizhao.springboot.auth.dto.ChangePasswordRequest;
import com.lizhao.springboot.auth.dto.LoginRequest;
import com.lizhao.springboot.common.util.TokenGenerator;
import com.lizhao.springboot.user.UserEntity;
import com.lizhao.springboot.user.UserRepository;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuthService {
  private final UserRepository userRepository;

  public AuthService(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Transactional
  public Optional<String> login(LoginRequest req) {
    if (req == null || req.phone() == null || req.password() == null) {
      return Optional.empty();
    }
    return userRepository
      .findActiveByPhone(req.phone())
      .filter(u -> matchesPlain(req.password(), u.getPassword()))
      .map(u -> {
        String token = TokenGenerator.newToken();
        u.setToken(token);
        userRepository.save(u);
        return token;
      });
  }

  @Transactional
  public void logout(long userId) {
    userRepository
      .findByIdAndIsDeleted(userId, 0)
      .ifPresent(u -> {
        u.setToken(null);
        userRepository.save(u);
      });
  }

  @Transactional
  public boolean changePassword(long userId, ChangePasswordRequest req) {
    if (req == null || req.oldPassword() == null || req.newPassword() == null) {
      return false;
    }
    if (req.newPassword().length() < 6) {
      return false;
    }
    Optional<UserEntity> userOpt = userRepository.findByIdAndIsDeleted(userId, 0);
    if (userOpt.isEmpty()) {
      return false;
    }
    UserEntity u = userOpt.get();
    if (!matchesPlain(req.oldPassword(), u.getPassword())) {
      return false;
    }
    u.setPassword(req.newPassword());
    userRepository.save(u);
    return true;
  }

  private static boolean matchesPlain(String rawPassword, String storedPassword) {
    if (storedPassword == null) {
      return false;
    }
    return rawPassword.equals(storedPassword);
  }
}

AuthController.java

src/main/java/com/lizhao/springboot/auth/AuthController.java

package com.lizhao.springboot.auth;

import com.lizhao.springboot.auth.dto.ChangePasswordRequest;
import com.lizhao.springboot.auth.dto.LoginRequest;
import com.lizhao.springboot.common.ApiResponse;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
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;

@RestController
@RequestMapping("/api")
public class AuthController {
  private final AuthService authService;
  private final AuthProperties props;

  public AuthController(AuthService authService, AuthProperties props) {
    this.authService = authService;
    this.props = props;
  }

  @GetMapping("/login")
  public ResponseEntity<ApiResponse<Void>> login(@RequestParam String phone, @RequestParam String password) {
    LoginRequest req = new LoginRequest(phone, password);
    Optional<String> tokenOpt = authService.login(req);
    if (tokenOpt.isEmpty()) {
      return ResponseEntity.status(401).body(ApiResponse.error(401, "账号或密码错误"));
    }
    String setCookie = TokenCookie.genCookie(props.getTokenCookieName(), tokenOpt.get(), props.getTokenDays());
    return ResponseEntity.ok()
      .header(HttpHeaders.SET_COOKIE, setCookie)
      .body(ApiResponse.ok(null));
  }

  @GetMapping("/logout")
  public ResponseEntity<ApiResponse<Void>> logout(Authentication auth) {
    if (auth == null || auth.getPrincipal() == null) {
      return ResponseEntity.status(401).body(ApiResponse.error(401, "未授权"));
    }
    long userId = (long) auth.getPrincipal();
    authService.logout(userId);
    String clearCookie = TokenCookie.clearCookie(props.getTokenCookieName());
    return ResponseEntity.ok()
      .header(HttpHeaders.SET_COOKIE, clearCookie)
      .body(ApiResponse.ok(null));
  }

  @GetMapping("/user/password")
  public ResponseEntity<ApiResponse<Void>> changePassword(
      Authentication auth,
      @RequestParam String oldPassword,
      @RequestParam String newPassword) {
    if (auth == null || auth.getPrincipal() == null) {
      return ResponseEntity.status(401).body(ApiResponse.error(401, "未授权"));
    }
    long userId = (long) auth.getPrincipal();
    ChangePasswordRequest req = new ChangePasswordRequest(oldPassword, newPassword);
    boolean ok = authService.changePassword(userId, req);
    if (!ok) {
      return ResponseEntity.badRequest().body(ApiResponse.error(400, "修改密码失败"));
    }
    return ResponseEntity.ok(ApiResponse.ok(null));
  }
}

执行流程

  • 请求到达 TokenAuthFilter
  • 检查不是白名单路径
  • 从 Cookie 读取 auth_token=abc123...
  • 查询数据库验证 token 有效
  • 设置 SecurityContextHolder
  • 执行业务方法(可通过 @AuthenticationPrincipal 获取用户ID)

统一异常处理

全局异常处理器

全局异常处理器 GlobalExceptionHandler,把常见异常统一映射成现有的返回体 ApiResponse.error(code, message),并且让 HTTP 状态码与 ApiResponse.code 保持一致(400/403/404/405/415/500)。

src/main/java/com/lizhao/springboot/common/GlobalExceptionHandler.java

package com.lizhao.springboot.common;

import java.util.Objects;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

@RestControllerAdvice
public class GlobalExceptionHandler {
  @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
  public ResponseEntity<ApiResponse<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
    String method = Objects.toString(e.getMethod(), "");
    String msg = method.isBlank() ? "请求方式不支持" : ("请求方式不支持: " + method);
    return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(ApiResponse.error(405, msg));
  }

  @ExceptionHandler({
    MissingServletRequestParameterException.class,
    MethodArgumentTypeMismatchException.class,
    HttpMessageNotReadableException.class,
    BindException.class,
    MethodArgumentNotValidException.class
  })
  public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception e) {
    String msg = "请求参数异常";
    if (e instanceof MissingServletRequestParameterException ex) {
      msg = "缺少参数: " + ex.getParameterName();
    } else if (e instanceof MethodArgumentTypeMismatchException ex) {
      msg = "参数类型错误: " + ex.getName();
    } else if (e instanceof HttpMessageNotReadableException ex) {
      msg = "请求体解析失败";
    } else if (e instanceof BindException ex) {
      msg = firstFieldErrorMessage(ex.getFieldError(), msg);
    } else if (e instanceof MethodArgumentNotValidException ex) {
      msg = firstFieldErrorMessage(ex.getBindingResult().getFieldError(), msg);
    }
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(400, msg));
  }

  @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
  public ResponseEntity<ApiResponse<Void>> handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) {
    return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(ApiResponse.error(415, "Content-Type 不支持"));
  }

  @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class})
  public ResponseEntity<ApiResponse<Void>> handleNotFound(Exception e) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(404, "接口不存在"));
  }

  @ExceptionHandler(AccessDeniedException.class)
  public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
    return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(403, "无权限"));
  }

  @ExceptionHandler(Exception.class)
  public ResponseEntity<ApiResponse<Void>> handleServerError(Exception e) {
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error(500, "服务器异常"));
  }

  private static String firstFieldErrorMessage(FieldError fe, String fallback) {
    if (fe == null) {
      return fallback;
    }
    String field = Objects.toString(fe.getField(), "");
    String defaultMessage = Objects.toString(fe.getDefaultMessage(), "");
    if (!field.isBlank() && !defaultMessage.isBlank()) {
      return field + ": " + defaultMessage;
    }
    if (!defaultMessage.isBlank()) {
      return defaultMessage;
    }
    if (!field.isBlank()) {
      return "参数错误: " + field;
    }
    return fallback;
  }
}

自定义异常处理器

package com.lizhao.springboot.common;

import org.springframework.http.HttpStatus;

public class MyCustomException extends RuntimeException {
  private final int code;
  private final HttpStatus httpStatus;

  public MyCustomException(int code, String message) {
    this(code, message, HttpStatus.BAD_REQUEST);
  }

  public MyCustomException(int code, String message, HttpStatus httpStatus) {
    super(message);
    this.code = code;
    this.httpStatus = httpStatus == null ? HttpStatus.BAD_REQUEST : httpStatus;
  }

  public int getCode() {
    return code;
  }

  public HttpStatus getHttpStatus() {
    return httpStatus;
  }
}
@RestControllerAdvice
public class GlobalExceptionHandler {
  @ExceptionHandler(MyCustomException.class)
  public ResponseEntity<ApiResponse<Void>> handleMyCustomException(MyCustomException e) {
    return ResponseEntity.status(e.getHttpStatus()).body(ApiResponse.error(e.getCode(), e.getMessage()));
  }
}
package com.lizhao.springboot.user;

import com.lizhao.springboot.common.MyCustomException;
import org.springframework.http.HttpStatus;
public class UserController {
  @GetMapping("/{id}")
  public ApiResponse<UserProfileDto> getUserById(@PathVariable @Min(value = 1, message = "id 必须大于 0") long id) {
    log.warn("请求用户信息1: {}", id);
    return userRepository
        .findByIdAndIsDeleted(id, 0)
        .map(UserController::toProfileDto)
        .map(ApiResponse::ok)
        .orElseThrow(() -> new MyCustomException(404, "用户不存在", HttpStatus.NOT_FOUND));
  }
}

请求参数校验

pom.xml 配置

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

统一校验错误返回

src/main/java/com/lizhao/springboot/common/GlobalExceptionHandler.java

package com.lizhao.springboot.common;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.util.Set;

@RestControllerAdvice
public class GlobalExceptionHandler {
  @ExceptionHandler({
    MissingServletRequestParameterException.class,
    MethodArgumentTypeMismatchException.class,
    HttpMessageNotReadableException.class,
    BindException.class,
    MethodArgumentNotValidException.class,
    ConstraintViolationException.class
  })
  public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception e) {
    String msg = "请求参数异常";
    if (e instanceof MissingServletRequestParameterException ex) {
      msg = "缺少参数: " + ex.getParameterName();
    } else if (e instanceof MethodArgumentTypeMismatchException ex) {
      msg = "参数类型错误: " + ex.getName();
    } else if (e instanceof HttpMessageNotReadableException ex) {
      msg = "请求体解析失败";
    } else if (e instanceof BindException ex) {
      msg = firstFieldErrorMessage(ex.getFieldError(), msg);
    } else if (e instanceof MethodArgumentNotValidException ex) {
      msg = firstFieldErrorMessage(ex.getBindingResult().getFieldError(), msg);
    } else if (e instanceof ConstraintViolationException ex) {
      msg = firstConstraintViolationMessage(ex.getConstraintViolations(), msg);
    }
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(400, msg));
  }
}

代码示例

src/main/java/com/lizhao/springboot/auth\AuthController.java

package com.lizhao.springboot.auth;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.validation.annotation.Validated;

@RestController
@RequestMapping("/api")
@Validated
public class AuthController {
  @GetMapping("/login")
  public ResponseEntity<ApiResponse<Void>> login(
      @RequestParam @NotBlank(message = "手机号不能为空") @Size(min = 5, max = 32, message = "手机号长度不合法") String phone,
      @RequestParam @NotBlank(message = "密码不能为空") @Size(min = 6, max = 64, message = "密码长度不合法") String password) {
    LoginRequest req = new LoginRequest(phone, password);
    Optional<String> tokenOpt = authService.login(req);
    if (tokenOpt.isEmpty()) {
      return ResponseEntity.status(401).body(ApiResponse.error(401, "账号或密码错误"));
    }
    String setCookie = TokenCookie.genCookie(props.getTokenCookieName(), tokenOpt.get(), props.getTokenDays());
    return ResponseEntity.ok()
      .header(HttpHeaders.SET_COOKIE, setCookie)
      .body(ApiResponse.ok(null));
  }
}

日志记录

日志就是程序运行过程中输出的记录信息,用来告诉你程序在做什么、有没有出错。

日志级别从低到高,代表问题的严重程度:

  • TRACE:追踪程序执行细节。
  • DEBUG:调试信息。比如:变量值、查询参数、中间结果。
  • INFO:关键业务流程。
  • WARN:警告,可恢复问题,比如:参数异常、重试、降级、配置缺失。
  • ERROR:错误,需人工介入。比如:数据库连不上、第三方接口超时。
  • OFF:关闭日志。

application.properties 配置

开启日志,只要符合日志级别条件,就会输出到文件。

src/main/resources/application.properties

logging.file.name=logs/spring-boot.log
logging.level.root=WARN
logging.level.com.lizhao=WARN
logging.logback.rollingpolicy.file-name-pattern=logs/spring-boot.%d{yyyy-MM-dd}.%i.log
logging.logback.rollingpolicy.max-file-size=50MB
logging.logback.rollingpolicy.max-history=30
logging.logback.rollingpolicy.total-size-cap=2GB

代码示例

Spring Boot 默认选型:SLF4J。

src/main/java/com/lizhao/springboot/user/UserController.java

package com.lizhao.springboot.user;


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@RestController
@RequestMapping("/api/user")
@Validated
public class UserController {
  @GetMapping("/{id}")
  public ApiResponse<UserProfileDto> getUserById(@PathVariable @Min(value = 1, message = "id 必须大于 0") long id) {
    logger.warn("请求用户信息: {}", id);
    return userRepository
        .findByIdAndIsDeleted(id, 0)
        .map(UserController::toProfileDto)
        .map(ApiResponse::ok)
        .orElseGet(() -> ApiResponse.notFound("用户不存在"));
  }
}

Lombok(可选)

pom.xml

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
package com.lizhao.springboot.user;

import lombok.extern.slf4j.Slf4j;

@RestController
@RequestMapping("/api/user")
@Validated
@Slf4j
public class UserController {
  @GetMapping("/{id}")
  public ApiResponse<UserProfileDto> getUserById(@PathVariable @Min(value = 1, message = "id 必须大于 0") long id) {
    log.warn("请求用户信息1: {}", id);
    return userRepository
        .findByIdAndIsDeleted(id, 0)
        .map(UserController::toProfileDto)
        .map(ApiResponse::ok)
        .orElseGet(() -> ApiResponse.notFound("用户不存在"));
  }
}

测试

用 JUnit5 + MockMvc 写接口层测试。

整体结构

测试类
  ├── @ExtendWith(SpringExtension.class)  // 集成 Spring 测试框架
  ├── @WebAppConfiguration                // 模拟 Web 环境
  ├── @ContextConfiguration               // 指定配置类
  ├── 内部静态类 TestConfig               // 提供 Mock Bean
  └── 测试方法                            // 测试各个接口

测试流程图

测试方法开始
    ↓
Mock 依赖行为(when().thenReturn())
    ↓
MockMvc 发送模拟 HTTP 请求
    ↓
请求经过 Controller(真实对象)
    ↓
Controller 调用 Mock 的 Service/Repository
    ↓
Mock 返回预设值
    ↓
验证响应(状态码、JSON、Header)
    ↓
测试通过/失败

pom.xml 配置

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>

ApplicationTests.java

src/test/java/com/lizhao/springboot/ApplicationTests.java

package com.lizhao.springboot;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

@Disabled("避免启动完整 SpringBoot 上下文(会依赖本地 MySQL/SSL 配置),接口层测试已覆盖核心行为")
class ApplicationTests {
  @Test
  void contextLoads() {
  }
}

代码示例

这是两个 Controller 的单元测试,使用 MockMvc 模拟 HTTP 请求,不启动完整服务器。

src/test/java/com/lizhao/springboot/auth/AuthControllerTest.java

package com.lizhao.springboot.auth;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import com.lizhao.springboot.user.UserRepository;

@WebMvcTest(controllers = AuthController.class)
@AutoConfigureMockMvc(addFilters = false)
@Import(com.lizhao.springboot.common.GlobalExceptionHandler.class)
class AuthControllerTest {
  @Autowired private MockMvc mockMvc;

  @MockitoBean private AuthService authService;
  @MockitoBean private AuthProperties props;
  @MockitoBean private UserRepository userRepository;

  @Test
  void login_whenCredentialsInvalid_returns401() throws Exception {
    when(authService.login(any())).thenReturn(Optional.empty());

    mockMvc
        .perform(get("/api/login").param("phone", "13800138000").param("password", "wrongpass"))
        .andExpect(status().isUnauthorized())
        .andExpect(jsonPath("$.success").value(false))
        .andExpect(jsonPath("$.code").value(401))
        .andExpect(jsonPath("$.message").value("账号或密码错误"));
  }

  @Test
  void login_whenOk_setsCookie_andReturnsOk() throws Exception {
    when(authService.login(any())).thenReturn(Optional.of("token-abc"));
    when(props.getTokenCookieName()).thenReturn("token");
    when(props.getTokenDays()).thenReturn(30);

    mockMvc
        .perform(get("/api/login").param("phone", "13800138000").param("password", "goodpass"))
        .andExpect(status().isOk())
        .andExpect(header().exists("Set-Cookie"))
        .andExpect(header().string("Set-Cookie", org.hamcrest.Matchers.containsString("token=token-abc")))
        .andExpect(jsonPath("$.success").value(true))
        .andExpect(jsonPath("$.code").value(200));
  }

  @Test
  void logout_whenNoAuth_returns401() throws Exception {
    mockMvc
        .perform(get("/api/logout"))
        .andExpect(status().isUnauthorized())
        .andExpect(jsonPath("$.success").value(false))
        .andExpect(jsonPath("$.code").value(401))
        .andExpect(jsonPath("$.message").value("未授权"));
  }

  @Test
  void logout_whenAuth_present_clearsCookie_andReturnsOk() throws Exception {
    when(props.getTokenCookieName()).thenReturn("token");
    doNothing().when(authService).logout(1L);

    var auth = new UsernamePasswordAuthenticationToken(1L, null, java.util.List.of());

    mockMvc
        .perform(get("/api/logout").principal(auth))
        .andExpect(status().isOk())
        .andExpect(header().exists("Set-Cookie"))
        .andExpect(header().string("Set-Cookie", org.hamcrest.Matchers.containsString("Max-Age=0")))
        .andExpect(jsonPath("$.success").value(true))
        .andExpect(jsonPath("$.code").value(200));
  }
}

src/test/java/com/lizhao/springboot/auth/UserControllerTest.java

package com.lizhao.springboot.user;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import com.lizhao.springboot.auth.AuthProperties;

@Import(com.lizhao.springboot.common.GlobalExceptionHandler.class)
@WebMvcTest(controllers = UserController.class)
@AutoConfigureMockMvc(addFilters = false)
class UserControllerTest {
  @Autowired private MockMvc mockMvc;
  @MockitoBean private UserRepository userRepository;
  @MockitoBean private AuthProperties props;

  @Test
  void getUserById_whenFound_returnsProfile() throws Exception {
    UserEntity u = new UserEntity();
    // 仅设置 controller 会读取到的字段(getter)
    setField(u, "id", 1L);
    setField(u, "uuid", "uuid-1");
    setField(u, "phone", "13800138000");
    setField(u, "name", "张三");
    setField(u, "sex", 1);
    setField(u, "birthday", LocalDateTime.of(2000, 1, 1, 0, 0));
    setField(u, "alias", "alias1");
    setField(u, "nickname", "nick1");
    setField(u, "email", "a@b.com");
    setField(u, "role", 2);
    setField(u, "createdAt", LocalDateTime.of(2024, 1, 1, 0, 0));
    setField(u, "updatedAt", LocalDateTime.of(2024, 1, 2, 0, 0));

    when(userRepository.findByIdAndIsDeleted(1L, 0)).thenReturn(Optional.of(u));

    mockMvc
        .perform(get("/api/user/1"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.success").value(true))
        .andExpect(jsonPath("$.code").value(200))
        .andExpect(jsonPath("$.data.id").value(1))
        .andExpect(jsonPath("$.data.uuid").value("uuid-1"))
        .andExpect(jsonPath("$.data.phone").value("13800138000"))
        .andExpect(jsonPath("$.data.role").value(2));
  }

  @Test
  void getUserById_whenNotFound_returns404() throws Exception {
    when(userRepository.findByIdAndIsDeleted(99L, 0)).thenReturn(Optional.empty());

    mockMvc
        .perform(get("/api/user/99"))
        .andExpect(status().isNotFound())
        .andExpect(jsonPath("$.success").value(false))
        .andExpect(jsonPath("$.code").value(404))
        .andExpect(jsonPath("$.message").value("用户不存在"));
  }

  @Test
  void getUserById_whenIdInvalid_returns400() throws Exception {
    mockMvc
        .perform(get("/api/user/0"))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.success").value(false))
        .andExpect(jsonPath("$.code").value(400))
        .andExpect(jsonPath("$.message").value("id 必须大于 0"));
  }

  private static void setField(Object target, String fieldName, Object value) {
    try {
      var f = target.getClass().getDeclaredField(fieldName);
      f.setAccessible(true);
      f.set(target, value);
    } catch (ReflectiveOperationException e) {
      throw new RuntimeException(e);
    }
  }
}
// 模拟:调用 login 方法返回空(登录失败)
when(authService.login(any())).thenReturn(Optional.empty());

// 模拟:登录成功,返回 token
when(authService.login(any())).thenReturn(Optional.of("token-abc"));

// 模拟已认证用户(用户ID=1)
var auth = new UsernamePasswordAuthenticationToken(1L, null, List.of());

运行测试

Maven 面板,点击 生存期 -> test 命令。

接口文档

生成可访问的接口文档(本地直接打开 Swagger UI 页面),并且能导出 OpenAPI JSON 方便前端/测试使用。

pom.xml 配置

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>3.0.3</version>
</dependency>

application.properties 配置

src/main/resources/application.properties

app.security.whitelist=/api/hello/**,/api/login,/docs/**

springdoc.api-docs.path=/docs/v3/api-docs
springdoc.swagger-ui.path=/docs/swagger-ui.html
springdoc.swagger-ui.display-request-duration=true

OpenApiConfig.java

src/main/java/com/lizhao/springboot/common/OpenApiConfig.java

package com.lizhao.springboot.common;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {
  @Bean
  public OpenAPI openAPI() {
    return new OpenAPI()
      .info(new Info()
        .title("接口文档")
        .version("v1"));
  }
}

启动

IntelliJ IDEA,点击 “run” 命令启动项目,在浏览器访问:

https://localhost:8443/docs/swagger-ui/index.html

https://localhost:8443/docs/v3/api-docs

部署与运行

多环境配置

用 Spring Boot Profile(dev/test/prod)+ application-.properties(或 yml)实现。

src/main/resources/ 下准备这些文件:

  • application.properties:通用配置(所有环境共享)。
  • application-dev.properties:开发环境。
  • application-test.properties:测试环境。
  • application-prod.properties:生产环境。

命令行参数(推荐,最直观):

java -jar xxx.jar --spring.profiles.active=dev
java -jar xxx.jar --spring.profiles.active=test
java -jar xxx.jar --spring.profiles.active=prod

注意:也可以在 application.properties 里写 spring.profiles.active=dev,运行 java -jar xxx.jar不推荐用于生产

src/main/resources/application-prod.properties

server.port=9443
server.http.port=9090

配置外置

把配置文件放到 jar 同级目录的 ./config/ 或直接放 jar 同级目录。启动时用 --spring.config.location / --spring.config.additional-location 或环境变量覆盖。

目录结构建议:

  • target/xxx.jar
  • target/config/application-location.properties

Spring Boot 默认会从 ./config/ 读取(优先级也更高),通常无需额外参数。

也可以显式指定(更稳):

java -jar xxx.jar --spring.profiles.active=prod --spring.config.additional-location=./config/application-location.properties

java -jar xxx.jar --spring.profiles.active=prod --spring.config.location="D:/conf/myapp/"
java -jar xxx.jar --spring.profiles.active=prod --spring.config.location="D:/conf/myapp/application-location.properties"

打包 jar

Maven 面板,点击 生存期 -> clean生存期 -> package命令。

启动

java -jar xxx.jar --spring.profiles.active=prod

用命令行直接覆盖单个配置项:

java -jar xxx.jar --spring.profiles.active=prod --server.port=8081

入门知识清单

目前已涉及(巩固阶段)

  • Spring Boot 基础:启动类、自动配置、application.yml 配置文件。
  • Spring MVC 基础@RestController、路由映射、请求参数/请求体、JSON 序列化。
  • 数据库操作:数据库连接、Entity、Repository、DTO 基本使用。
  • 跨域配置(CORS)
  • 统一异常处理@ControllerAdvice + @ExceptionHandler,自定义业务异常,统一返回格式。
  • 认证/鉴权雏形:Spring Security 配置 + Cookie 认证。
  • 参数校验:使用 @Valid / @Validated 校验请求参数,统一校验错误返回。
  • 日志记录:使用 @Slf4j 记录关键操作日志,掌握日志级别配置。
  • 单元测试与集成测试:使用 JUnit5、Mockito、MockMvc 编写测试。
  • 接口文档:集成 SpringDoc/OpenAPI 自动生成接口文档。
  • 部署与运行:打包 jar、多环境配置(dev/test/prod)、配置外置、启动参数。

下一步建议(按优先级排序)

  • Service 分层:Controller 变薄,业务逻辑放到 Service 层,规范 DTO/VO 使用。DTO(Data Transfer Object)接收请求参数,Entity数据库映射,VO(View Object)返回响应数据。
  • 事务管理:使用 @Transactional 管理数据库事务,理解回滚规则。
  • Spring Security 体系化:使用 BCrypt 加密密码,实现方法级鉴权 @PreAuthorize。
  • AOP 与拦截链:理解 Filter / Interceptor / AOP 的区别与适用场景。
  • 缓存:集成 Redis 做 Token 存储或热点数据缓存。
  • 健康检查:使用 Actuator 提供健康检查端点。
  • 数据库优化:索引、N+1 问题、分页、连接池(HikariCP)。
  • 限流与熔断:了解限流熔断概念,保护系统稳定性。
  • 可观测性:实现 traceId 链路追踪,方便排查问题。

其他

  • Maven 管理:理解生命周期、依赖管理、多模块构建。
  • Docker 容器化:编写 Dockerfile,容器化部署 Spring Boot 应用。

@PreAuthorize(方法级鉴权)

在方法执行前检查权限,有权限才执行,无权限抛出 AccessDeniedException

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // 开启 @PreAuthorize 支持
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // ... 其他配置
        return http.build();
    }
}
@RestController
@RequestMapping("/api/admin")
public class AdminController {

    // 只有 ADMIN 角色可以访问
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/users")
    public ApiResponse<List<UserVO>> listAllUsers() {
        return ApiResponse.success(userService.findAll());
    }

    // 只有 ADMIN 或 MANAGER 可以访问
    @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
    @DeleteMapping("/user/{id}")
    public ApiResponse<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ApiResponse.ok();
    }

    // 只有用户本人或 ADMIN 可以访问
    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal")
    @GetMapping("/user/{userId}/profile")
    public ApiResponse<UserVO> getUserProfile(@PathVariable Long userId) {
        return ApiResponse.success(userService.findById(userId));
    }
}
// TokenAuthFilter 中设置认证信息时添加权限
userRepository.findActiveByToken(token).ifPresent(u -> {
    // 根据角色构建权限列表
    List<GrantedAuthority> authorities = new ArrayList<>();

    if (u.getRole() == 2) {
        authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
    } else if (u.getRole() == 3) {
        authorities.add(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"));
    }
    authorities.add(new SimpleGrantedAuthority("ROLE_USER"));

    // 也可以添加细粒度权限
    if (u.getRole() >= 2) {
        authorities.add(new SimpleGrantedAuthority("user:delete"));
        authorities.add(new SimpleGrantedAuthority("user:update"));
    }

    var auth = new UsernamePasswordAuthenticationToken(
        u.getId(), 
        null, 
        authorities  // 添加权限
    );
    SecurityContextHolder.getContext().setAuthentication(auth);
});

常见问题

IntelliJ IDEA 新建项目时报错

'https://start.spring.io' 的初始化失败
请检查 URL、网络和代理设置。

错误消息:
Cannot download 'https://start.spring.io': Connection refused: getsockopt

原因:IntelliJ IDEA 无法连接到该网站,通常由代理防火墙引起。

解决方案:打开 IntelliJ IDEA,找到:文件 → 设置,在设置窗口搜索 proxy,在 http 代理界面,选择 自动检测代理设置,点击 确定 后,重启 IntelliJ IDEA。

什么是 RESTful API?

RESTRepresentational State Transfer 的缩写,直译为"表述性状态转移"RESTful 是指 "符合 REST 架构风格的"

RESTful API 是一种API 设计规范(不是协议或标准),它基于 HTTP 协议,用简洁、规范的方式让不同系统之间交换数据。

简单理解:RESTful API 就是用 HTTP 方法(GET、POST、PUT、DELETE 等)来操作资源(数据)的一种约定俗成的好习惯

资源(核心概念)

REST 把一切数据都看作 "资源",每个资源都有一个唯一的地址(URL)。

资源类型 URL 示例 说明
用户 /users 所有用户的集合
单个用户 /users/123 ID 为 123 的特定用户
用户的订单 /users/123/orders 用户 123 的所有订单
文章 /articles 所有文章
文章评论 /articles/456/comments 文章 456 的评论

用 HTTP 方法表示操作(核心原则)

HTTP 方法 作用 URL 示例 说明
GET 获取资源 GET /users/123 查询用户 123
POST 创建资源 POST /users 新增一个用户
PUT 完整更新 PUT /users/123 完全替换用户 123 的数据
PATCH 部分更新 PATCH /users/123 只修改用户 123 的某些字段
DELETE 删除资源 DELETE /users/123 删除用户 123
@GetMapping("/users/{id}")    // GET  - 查询
@PostMapping("/users")        // POST - 创建
@PutMapping("/users/{id}")    // PUT  - 完整修改
@PatchMapping("/users/{id}")  // PATCH - 部分修改
@DeleteMapping("/users/{id}") // DELETE - 删除

classpath: 是什么路径?

classpath: 是 Java 中用来引用类路径资源的协议前缀,指向编译后所有类文件和资源文件所在的根目录。

src/main/resources/ 下的所有文件,编译后都在 classpath 根目录,classpath: 在不同场景下的实际路径:

  • IDE 中运行:项目根目录/target/classes/。
  • JAR 包运行:JAR 包内部的根目录。
  • Maven 测试:项目根目录/target/test-classes/。

@Transactional 是什么

@Transactional 是 Spring 提供的声明式事务注解,标记在方法或类上,表示该方法/类的所有数据库操作要在同一个事务中执行。

事务是一组数据库操作,要么全部成功,要么全部失败

@Transactional(
    propagation = Propagation.REQUIRED,  // 事务传播行为
    isolation = Isolation.DEFAULT,       // 隔离级别
    timeout = 30,                        // 超时时间(秒)
    rollbackFor = {RuntimeException.class},  // 遇到什么异常回滚
    noRollbackFor = {}                   // 遇到什么异常不回滚
)
public void someMethod() {}

拦截链

Filter / Interceptor / AOP:

  • Filter:Servlet 容器层的拦截器,最早执行。
  • Interceptor:Spring MVC 层的拦截器,在 Filter 之后、Controller 之前。
  • AOP:面向切面编程,把重复代码抽出来统一处理。

执行层次不同,Filter > Interceptor > AOP,静态资源用 Filter,请求用 Interceptor,方法用 AOP。

Filter

Java Servlet 规范中的接口,在请求到达 Servlet 之前处理。

适用场景:字符编码设置(CharacterEncodingFilter)、跨域处理(CorsFilter)、XSS 防护、请求压缩、静态资源访问控制。

@Component
@WebFilter(urlPatterns = "/*")
public class LoggingFilter implements Filter {

    private static final Logger log = LoggerFactory.getLogger(LoggingFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;

        log.info("Filter - 请求开始:{} {}", req.getMethod(), req.getRequestURI());

        long start = System.currentTimeMillis();
        chain.doFilter(request, response);  // 继续执行
        long end = System.currentTimeMillis();

        log.info("Filter - 请求结束:耗时 {}ms", end - start);
    }
}

Interceptor

Spring MVC 提供的拦截器,在请求进入 Controller 前后处理。

适用场景:登录检查、权限验证(需要 Controller 参数的场景)、请求日志记录、性能监控(记录 Controller 执行时间)、多租户识别。

@Component
public class AuthInterceptor implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class);

    // Controller 执行前调用
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("Interceptor - preHandle: {}", request.getRequestURI());

        // 检查是否登录
        String token = TokenCookie.readCookie(request, "auth_token");
        if (token == null) {
            response.setStatus(401);
            response.getWriter().write("未授权");
            return false;  // 拦截,不继续执行
        }

        return true;  // 放行
    }

    // Controller 执行后,视图渲染前调用
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("Interceptor - postHandle");
    }

    // 视图渲染后调用(无论成功或异常)
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("Interceptor - afterCompletion");
    }
}

// 配置拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
            .addPathPatterns("/api/**")
            .excludePathPatterns("/api/login");
    }
}

AOP

AOP(Aspect Oriented Programming),即面向切面编程。Spring 提供的面向切面编程,可以拦截任意 Spring Bean 的方法。

适用场景:事务管理(@Transactional)、日志记录、性能监控(方法耗时)、缓存管理(@Cacheable)、权限检查(@PreAuthorize)、防重复提交、数据脱敏。

// 1. 定义注解
package com.lizhao.annotation;

import java.lang.annotation.*;

@Target(ElementType.METHOD)           // 只能用在方法上
@Retention(RetentionPolicy.RUNTIME)   // 运行时保留
public @interface TrackTime {
}

// 2.定义 AOP
package com.lizhao.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimeAspect {

    private static final Logger log = LoggerFactory.getLogger(TimeAspect.class);

    // 拦截所有带有 @TrackTime 注解的方法
    @Around("@annotation(com.lizhao.annotation.TrackTime)")
    public Object trackTime(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 方法执行前
        String methodName = joinPoint.getSignature().getName();
        long start = System.currentTimeMillis();

        // 2. 执行原方法
        Object result = joinPoint.proceed();

        // 3. 方法执行后
        long end = System.currentTimeMillis();
        log.info("{} 耗时:{}ms", methodName, end - start);

        return result;
    }
}

// 3.使用 AOP
package com.lizhao.service;

import com.lizhao.annotation.TrackTime;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @TrackTime  // 添加注解,这个方法会被拦截
    public User findById(Long id) {
        // 模拟耗时操作
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return new User(id, "张三");
    }

    public void save(User user) {
        // 没有 @TrackTime,不会被拦截
        System.out.println("保存用户:" + user.getName());
    }
}

也可以不使用注解的写法(按包名拦截):

@Aspect
@Component
public class TimeAspect {

    // 拦截 service 包下所有类的所有方法
    @Around("execution(* com.lizhao.service.*.*(..))")
    public Object trackTime(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        long start = System.currentTimeMillis();

        Object result = joinPoint.proceed();

        long end = System.currentTimeMillis();
        System.out.println(methodName + " 耗时:" + (end - start) + "ms");

        return result;
    }
}

参考文档

Spring Boot 最全面的入门教程

Spring Boot 中文文档

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-04-20 21:05:00

results matching ""

    No results matching ""