Java 后端详解(二):注解、参数绑定、评论与用户认证

上一篇:# 啥? 前端也要会干Java?

上篇讲分层架构,本篇讲参数绑定、Repository、ApiResponse 泛型、用户认证与 @Transactional

一、评论 API 一览

bash 复制代码
GET    /api/articles/{articleId}/comments   评论列表
POST   /api/articles/{articleId}/comments   发表评论
DELETE /api/comments/{id}                   删除评论

核心接口:

java 复制代码
@PostMapping("/api/articles/{articleId}/comments")
public ApiResponse<Comment> create(@PathVariable Long articleId,
                                   @Valid @RequestBody CommentRequest request) {
    return ApiResponse.success("评论成功", commentService.create(articleId, request));
}

二、三个参数,三种来源

参数 注解 来源 作用
articleId @PathVariable URL 路径 评论属于哪篇文章
request @RequestBody JSON 请求体 评论内容、父评论 ID
request @Valid --- 触发 DTO 字段校验

RESTful 分工: URL 表示归属,Body 表示内容。昵称由后端从登录用户自动填充,不在 body 里传

http 复制代码
POST /api/articles/5/comments
Authorization: Bearer <token>

{ "content": "不错", "parentId": null }

三、CommentRequest:请求体 DTO

CommentRequest 专门接收前端 JSON,和数据库实体 Comment 分开,避免外部随意改 idcreatedAt 等字段。

java 复制代码
@Data
public class CommentRequest {

    @NotBlank(message = "评论内容不能为空")
    @Size(max = 2000, message = "评论内容不能超过2000字")
    private String content;

    private Long parentId;
}
字段 必填 校验 说明
content @NotBlank @Size(max=2000) 评论正文
parentId 回复时传父评论 ID;顶级评论传 null

注意:

  • 没有 articleId,文章 ID 从 URL 的 @PathVariable
  • 没有 author,昵称由 CommentService 从当前登录用户读取(发表评论需登录)

Comment 实体对比:

CommentRequest Comment 实体
用途 接收入参 映射数据库表
id 有(自增主键)
articleId 无(URL 来)
author 无(后端填充)
createdAt 无(后端生成)

四、CommentRequest@Valid 的关系

两者配合,但不是同一个东西

写在哪 作用
@NotBlank@Size CommentRequest 字段上 规则:定义验什么
@Valid Controller 的 request 参数上 开关:触发上面的规则
java 复制代码
// Controller
@Valid @RequestBody CommentRequest request   // @Valid 在这里
typescript 复制代码
前端 JSON
  → @RequestBody 转成 CommentRequest 对象
  → @Valid 检查 content 上的 @NotBlank、@Size
  → 通过 → 进入 commentService.create()
  → 失败 → 返回 400,如 "评论内容不能为空"

举例:

json 复制代码
{ "content": "" }

@RequestBody 先转成对象,然后 @Valid 发现 content 违反 @NotBlank,直接 400,不会进 Service

若去掉 Controller 上的 @ValidCommentRequest 里写了 @NotBlank不会自动校验

记忆:规则写在 DTO,开关写在 Controller。


五、Long 与主键

  • Long:64 位整数包装类型,ID 常用它(对应数据库 BIGINT,可为 null
  • 主键判断:看 @Id,不看字段名
字段 是否主键 说明
id @Id 评论自己的主键
articleId 属于哪篇文章
parentId 回复哪条评论,顶级为 null

六、两层校验

位置 示例
格式校验 Controller @Valid 内容不能为空、超长
业务校验 Service 文章是否存在、父评论是否属于该文章

七、Repository:方法从哪来?

JpaRepository 自带(按主键 id

findAllfindByIdsavedeleteByIdexistsById

为什么 ArticleRepository 是空接口?

文章只需「查全部 + 按 id 增删改查」,JpaRepository 已够用。

为什么 CommentRepository 要自定义?

方法 用途
findByArticleIdOrderByCreatedAtAsc 查某篇文章的评论列表
deleteByArticleId 删文章时清空其评论
findByParentId 删评论时递归找子回复
deleteByParentId 按父 ID 批量删(当前未用)

这些方法没有实现类文件。 Spring 根据方法名 + 实体字段,运行时自动生成 SQL(方法名推导)。

sql 复制代码
findByArticleIdOrderByCreatedAtAsc
  → WHERE article_id = ? ORDER BY created_at ASC

findByParentId 为什么需要?

评论支持回复,删除父评论时要递归删子回复:

java 复制代码
private void deleteWithChildren(Long id) {
    List<Comment> children = commentRepository.findByParentId(id); // 找直接子回复
    for (Comment child : children) {
        deleteWithChildren(child.getId());  // 递归
    }
    commentRepository.deleteById(id);
}

八、一次请求流程

css 复制代码
POST /api/articles/5/comments + JSON body
  → @PathVariable  articleId = 5
  → @RequestBody   转成 CommentRequest
  → @Valid         校验 content
  → JWT 过滤器     解析 token,识别当前用户
  → Service        检查文章存在 → 用用户昵称写入 comment 表
  → 返回 { code: 200, data: {...} }

九、速查

java 复制代码
URL(@PathVariable)  +  Body(@RequestBody)  +  校验(@Valid + @NotBlank 等)
     资源归属                  资源内容                    数据是否合法

JpaRepository(主键 CRUD)  +  自定义方法名(按 articleId、parentId 等查删)

常见坑: 新增后端代码后必须重启后端,否则新接口 404。


十、ApiResponse 泛型:T 是什么?

ApiResponse 是一个泛型类 ,用 T 表示 data 字段的实际类型。完整源码如下:

java 复制代码
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {

    private int code;
    private String message;
    private T data;   // T 是占位符,不同接口里含义不同

    // 成功:默认 message 为 "success"
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "success", data);
    }

    // 成功:自定义 message,如 "评论成功"
    public static <T> ApiResponse<T> success(String message, T data) {
        return new ApiResponse<>(200, message, data);
    }

    // 失败:data 固定为 null
    public static <T> ApiResponse<T> error(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }
}
成员 说明
code 状态码,成功一般为 200,失败如 400
message 提示信息,如 "success""评论成功"、校验错误文案
data 业务数据,类型由泛型 T 决定
success(T data) 最常用,返回 { code: 200, message: "success", data: ... }
success(String, T) 带自定义成功提示,创建/删除接口常用
error(int, String) 返回错误,datanull

以评论列表接口为例

java 复制代码
@GetMapping("/api/articles/{articleId}/comments")
public ApiResponse<List<Comment>> list(@PathVariable Long articleId) {
    return ApiResponse.success(commentService.listByArticleId(articleId));
}

这里 T = List<Comment>,所以返回类型展开后是:

字段 类型
code int
message String
data List<Comment>

最终 JSON 大致如下:

json 复制代码
{
  "code": 200,
  "message": "success",
  "data": [
    {
      "id": 1,
      "articleId": 5,
      "parentId": null,
      "content": "写得不错",
      "author": "张三",
      "createdAt": "2026-06-22T10:30:00"
    }
  ]
}

同一个 ApiResponse<T>,不同接口 T 不同

接口 返回类型 T 是什么
评论列表 ApiResponse<List<Comment>> List<Comment>
创建评论 ApiResponse<Comment> Comment(单条)
删除评论 ApiResponse<Void> Voiddatanull

十一、为什么调用 success() 时不用写泛型?

java 复制代码
return ApiResponse.success(commentService.listByArticleId(articleId));

调用时没有写 <List<Comment>>,是因为 Java 会自动推断泛型类型。

推断过程

  1. commentService.listByArticleId(articleId) 返回 List<Comment>
  2. 传给 success(T data) 方法
  3. 编译器根据实参类型推断:T = List<Comment>
  4. 最终得到 ApiResponse<List<Comment>>

等价写法(通常不需要手写):

java 复制代码
return ApiResponse.<List<Comment>>success(commentService.listByArticleId(articleId));

success 方法签名

java 复制代码
public static <T> ApiResponse<T> success(T data) {
    return new ApiResponse<>(200, "success", data);
}

这里的 <T>方法自己的泛型参数 ,和类上的 ApiResponse<T> 是同一套占位符机制。调用时只要传入的 data 类型明确,编译器就能反推 T

传入的 data 类型 推断出的 T 最终返回类型
List<Comment> List<Comment> ApiResponse<List<Comment>>
Comment Comment ApiResponse<Comment>
null 推断失败 需显式写泛型

什么时候才需要显式写泛型?

一般只有编译器推断不出来时才需要,例如:

java 复制代码
// data 是 null,编译器不知道 T 是什么
ApiResponse.<Void>success(null);

一句话总结

不用写泛型,是因为实参已经有明确类型(如 List<Comment>),Java 泛型推断会自动把 T 填上。 这不是省略了类型,而是编译器帮你完成了。


十二、用户认证 API 一览

bash 复制代码
POST   /api/auth/register          注册
POST   /api/auth/login             登录
GET    /api/users/me               获取当前用户信息(需登录)
PATCH  /api/users/me/nickname      修改昵称(需登录)
接口 是否需要 Token 返回 data 类型
注册 AuthResponse
登录 AuthResponse
获取当前用户 UserResponse
修改昵称 UserResponse

十三、User 实体:账号存在哪张表?

java 复制代码
@Data
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;      // 登录名,唯一

    @Column(nullable = false)
    private String password;      // BCrypt 加密后的密文,不是明文

    @Column(nullable = false, length = 50)
    private String nickname;      // 昵称,用于文章/评论展示

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;
}
字段 说明
username 登录账号,唯一,注册后不可通过 API 修改
password 数据库存 加密后 的密码,永远不返回给前端
nickname 显示名,可在「修改个人资料」中更新

表名用 users 而不是 user,因为 user 在 MySQL 中是保留字。


十四、注册 / 登录 DTO

RegisterRequest --- 注册入参

java 复制代码
@Data
public class RegisterRequest {

    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 50, message = "用户名长度为3-50个字符")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 50, message = "密码长度为6-50个字符")
    private String password;

    @NotBlank(message = "昵称不能为空")
    @Size(max = 50, message = "昵称长度不能超过50")
    private String nickname;
}
http 复制代码
POST /api/auth/register

{ "username": "summer", "password": "123456", "nickname": "夏天" }

LoginRequest --- 登录入参

java 复制代码
@Data
public class LoginRequest {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    private String password;
}
http 复制代码
POST /api/auth/login

{ "username": "summer", "password": "123456" }

两者都是 @Valid @RequestBody 接收,校验规则和评论 DTO 一样:规则写在 DTO,开关写在 Controller


十五、响应 DTO:为什么不直接返回 User 实体?

UserResponse --- 安全版用户信息(无密码)

java 复制代码
package com.blog.dto;

import com.blog.entity.User;
import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class UserResponse {

    private Long id;
    private String username;
    private String nickname;

    public static UserResponse from(User user) {
        return new UserResponse(user.getId(), user.getUsername(), user.getNickname());
    }
}

AuthResponse --- 登录/注册成功后返回

java 复制代码
@Data
@AllArgsConstructor
public class AuthResponse {

    private String token;       // JWT 令牌
    private UserResponse user;  // 用户基本信息
}

登录成功 JSON 示例:

json 复制代码
{
  "code": 200,
  "message": "登录成功",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9...",
    "user": {
      "id": 1,
      "username": "summer",
      "nickname": "夏天"
    }
  }
}
User 实体 UserResponse
password ❌ 绝不返回
用途 数据库映射 返回给前端
token AuthResponse 才有

UserResponse 的作用是什么?

UserResponse 专门负责:把数据库里的 User 转成可以返回给前端的 JSON,只挑安全字段,密码绝不外泄。

from(User user) 是转换方法------手动从实体里取出 idusernamenickname,构造 UserResponse

java 复制代码
public static UserResponse from(User user) {
    return new UserResponse(user.getId(), user.getUsername(), user.getNickname());
}

举例:如果直接返回 User 实体(错误做法)

java 复制代码
// ❌ 不要这样写
public ApiResponse<User> me() {
    return ApiResponse.success(userEntity);
}

前端会收到:

json 复制代码
{
  "id": 1,
  "username": "summer",
  "password": "$2a$10$xxxxxxxx...",
  "nickname": "夏天",
  "createdAt": "2026-06-23T10:00:00"
}

password 泄露是严重安全问题。

正确做法:返回 UserResponse

java 复制代码
@GetMapping("/me")
public ApiResponse<UserResponse> me() {
    return ApiResponse.success(userService.getCurrentUser());
}

前端只收到:

json 复制代码
{
  "id": 1,
  "username": "summer",
  "nickname": "夏天"
}

对比:为什么 ArticleController 不需要 ArticleResponse

文章详情接口直接返回 Article 实体:

java 复制代码
@GetMapping("/{id}")
public ApiResponse<Article> detail(@PathVariable Long id) {
    return ApiResponse.success(articleService.getById(id));
}
对比项 User Article
有敏感字段? ✅ 有 password ❌ 没有
字段是否都要展示? 否,密码不能展示 是,titlecontent 等正是详情页要的
是否需要 Response DTO? 必须UserResponse 当前项目不必(可直接返回实体)

Article 实体的字段基本都可以给前端看:

字段 能否返回前端
titlecontentsummary ✅ 文章就是要展示
author
userId ✅ 用于判断是否本人文章
createdAtupdatedAt

所以没有「必须藏起来」的字段,直接返回 Article 在这个项目里是可以接受的。

什么时候文章也需要 ArticleResponse

场景 做法
实体含密码、token 等敏感信息 必须用 Response DTO 过滤(如 UserResponse
实体字段全部可公开 可直接返回实体(小项目常见)
返回字段和实体不一致 用 Response DTO(如列表只返回标题、摘要,不返回正文)

记忆:实体面向数据库,DTO 面向接口。 密码只进不出。有敏感字段就必须用 Response DTO;没有敏感字段、字段都要展示时,可以直接返回实体。


十六、AuthControllerAuthService

Controller

java 复制代码
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/register")
    public ApiResponse<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
        return ApiResponse.success("注册成功", authService.register(request));
    }

    @PostMapping("/login")
    public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
        return ApiResponse.success("登录成功", authService.login(request));
    }
}

这里 T = AuthResponse,所以 data 里是 { token, user }

Service 核心逻辑

java 复制代码
@Transactional
public AuthResponse register(RegisterRequest request) {
    if (userRepository.existsByUsername(request.getUsername())) {
        throw new RuntimeException("用户名已存在");   // 业务校验
    }
    User user = new User();
    user.setUsername(request.getUsername());
    user.setPassword(passwordEncoder.encode(request.getPassword())); // 加密存库
    user.setNickname(request.getNickname());
    userRepository.save(user);
    return buildAuthResponse(user);
}

public AuthResponse login(LoginRequest request) {
    User user = userRepository.findByUsername(request.getUsername())
            .orElseThrow(() -> new RuntimeException("用户名或密码错误"));
    if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
        throw new RuntimeException("用户名或密码错误");  // 明文 vs 密文比对
    }
    return buildAuthResponse(user);
}

private AuthResponse buildAuthResponse(User user) {
    String token = jwtUtil.generateToken(user.getId(), user.getUsername());
    return new AuthResponse(token, UserResponse.from(user));
}
步骤 注册 登录
格式校验 @Valid 检查字段 同左
业务校验 用户名是否已存在 用户名是否存在、密码是否正确
密码处理 BCrypt 加密后存库 matches() 验证明文密码
返回 生成 JWT + 用户信息 同左

十七、JWT 是什么?Token 里装了什么?

JWT(JSON Web Token)是一种无状态登录凭证:服务端不保存 session,只验证 token 签名是否合法。

配置从哪来?(application.ymlJwtProperties

yaml 复制代码
app:
  jwt:
    secret: blog-jwt-secret-key-change-in-production-min-32-chars
    expiration-ms: 604800000   # 7 天(毫秒)

Spring 通过 JwtProperties 读取上述配置:

java 复制代码
@Configuration
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
    private String secret;
    private long expirationMs;
}
配置项 作用
secret 签名密钥字符串,用于生成和校验 token
expiration-ms token 有效期(毫秒),604800000 = 7 天

JwtUtil 构造函数:启动时初始化密钥和过期时间

JwtUtil 是 Spring 管理的组件(@Component),应用启动时只执行一次构造函数,把配置里的值转成后续要用的字段:

java 复制代码
@Component
public class JwtUtil {

    private final SecretKey key;       // 签名密钥
    private final long expirationMs;   // 过期时间(毫秒)

    public JwtUtil(JwtProperties properties) {
        this.key = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
        this.expirationMs = properties.getExpirationMs();
    }
}
代码 作用
JwtProperties properties Spring 自动注入,对应 app.jwt 配置
Keys.hmacShaKeyFor(...) secret 字符串转成 SecretKey,供 JWT 库签名/验签
this.expirationMs = ... 保存 token 有效期,生成 token 时用来算过期时间

为什么写在构造函数里?

  • keyexpirationMs 在整个应用生命周期内不变
  • 启动时读一次配置即可,generateToken() / parseToken() 直接复用
  • application.yml 里的 secretexpiration-ms,重启后端即生效,不用改 Java 代码

配置加载链路:

scss 复制代码
application.yml (app.jwt.secret / expiration-ms)
        ↓
JwtProperties 读取
        ↓
JwtUtil 构造函数:secret → key,expiration-ms → expirationMs
        ↓
generateToken() / parseToken() 使用

生成 Token(generateToken

java 复制代码
public String generateToken(Long userId, String username) {
    Date now = new Date();
    return Jwts.builder()
            .subject(username)              // 用户名
            .claim("userId", userId)        // 自定义字段:用户 ID
            .issuedAt(now)                  // 签发时间
            .expiration(new Date(now.getTime() + expirationMs))  // 用过期时间配置
            .signWith(key)                  // 用构造函数里生成的密钥签名
            .compact();
}

解析 Token(parseToken

java 复制代码
public Claims parseToken(String token) {
    return Jwts.parser()
            .verifyWith(key)                // 用同一把密钥验证签名
            .build()
            .parseSignedClaims(token)
            .getPayload();
}

签发和验证必须用同一把 key (来自同一个 secret),否则 token 会被判定为无效。

记忆:构造函数负责「读配置、准备好 key 和过期时间」;generateToken / parseToken 负责「用准备好的值干活」。

前端如何使用?

登录成功后保存 token,之后每次请求在 Header 带上:

http 复制代码
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

前端 request.js 拦截器自动附加:

javascript 复制代码
const token = localStorage.getItem('blog_token')
if (token) {
  config.headers.Authorization = `Bearer ${token}`
}

十八、JwtAuthenticationFilter:每个请求如何识别用户?

它解决什么问题?

登录成功后,前端会在请求头带上:

http 复制代码
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

后端需要有人去读这个 token,并告诉 Spring Security:当前登录用户是 userId=1、username=summer

JwtAuthenticationFilter 就是干这件事的------JWT 登录的「身份识别器」

完整源码

java 复制代码
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);   // 去掉 "Bearer " 前缀
            try {
                Claims claims = jwtUtil.parseToken(token);
                Long userId = claims.get("userId", Long.class);
                String username = claims.getSubject();
                UserPrincipal principal = new UserPrincipal(userId, username, "");
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (Exception ignored) {
                SecurityContextHolder.clearContext();
            }
        }
        filterChain.doFilter(request, response);
    }
}

逐步解析

步骤 代码 作用
1 getHeader("Authorization") 读取请求头
2 header.startsWith("Bearer ") 判断是否是 Bearer token 格式
3 token = header.substring(7) 截取真正的 token 字符串
4 jwtUtil.parseToken(token) 验证签名、解析 payload
5 取出 userIdusername 从 token 里拿用户身份
6 SecurityContextHolder.setAuthentication(...) 告诉 Spring Security「当前是谁」
7 filterChain.doFilter(...) 继续往下走,不拦截请求本身

几个关键点

说明
过滤器 比 Controller 更早执行,每个请求都会经过
OncePerRequestFilter 保证每个请求只执行一次
不拦截请求 解析完仍调用 filterChain.doFilter(),请求继续往下走
token 无效时 清空上下文,不抛异常;若访问需登录接口,由 SecurityConfig 返回 401
公开接口 没带 token 也能访问,如 GET /api/articles

工作流程图

markdown 复制代码
请求进入
  → JwtAuthenticationFilter
      → 有 Authorization 头?
          → 有:解析 token → 验证签名 → 取出 userId、username
          → 存入 SecurityContextHolder(当前用户)
          → 无 / token 无效:不设置用户
  → SecurityConfig 判断该接口要不要登录
  → Controller / Service
      → UserService.getCurrentUser()
      → ArticleService 判断是不是文章作者
      → CommentService 取当前用户昵称

JwtUtil 的分工

职责
JwtUtil 工具:生成 token、解析 token
JwtAuthenticationFilter 门卫:每个请求自动读 token,把用户身份交给 Spring Security

SecurityConfig 里怎么挂上?

java 复制代码
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

在 Spring Security 默认登录过滤器之前插入 JWT 过滤器,优先用 token 识别用户。

记忆:Filter 负责「认人」;SecurityConfig 负责「认完人之后,这个接口让不让进」。


十九、SecurityConfig:安全规则总配置

SecurityConfig 是 Spring Security 的总配置文件:定义哪些接口要登录、JWT 过滤器怎么接入、密码怎么加密。

类上的注解与依赖

java 复制代码
@Configuration          // 这是一个配置类
@EnableWebSecurity      // 开启 Spring Security
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
依赖 作用
JwtAuthenticationFilter 每个请求解析 token,识别当前用户
JwtAuthenticationEntryPoint 未登录访问受保护接口时,返回 401 JSON

完整配置源码

java 复制代码
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> {})
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/auth/**").permitAll()
                    .requestMatchers(HttpMethod.GET, "/api/articles/**").permitAll()
                    .requestMatchers(HttpMethod.POST, "/api/articles/**").authenticated()
                    .requestMatchers(HttpMethod.PUT, "/api/articles/**").authenticated()
                    .requestMatchers(HttpMethod.DELETE, "/api/articles/**").authenticated()
                    .requestMatchers(HttpMethod.GET, "/api/articles/*/comments").permitAll()
                    .requestMatchers(HttpMethod.POST, "/api/articles/*/comments").authenticated()
                    .requestMatchers(HttpMethod.DELETE, "/api/comments/**").permitAll()
                    .requestMatchers("/api/users/**").authenticated()
                    .anyRequest().permitAll()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

逐段解析

1. 关闭 CSRF

java 复制代码
.csrf(AbstractHttpConfigurer::disable)

前后端分离 + JWT 无 session,不需要 CSRF 防护,关掉避免 POST 被拦截。

2. 开启 CORS

java 复制代码
.cors(cors -> {})

允许前端(如 localhost:5173)跨域访问后端 API,具体规则在 CorsConfig 里。

3. 无 Session(JWT 模式)

java 复制代码
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

STATELESS = 服务端不保存登录 session,每次靠 Header 里的 token 识别用户。

4. 未登录时的错误处理

java 复制代码
.exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))

访问需要登录的接口但没 token / token 无效 → 返回:

json 复制代码
{ "code": 401, "message": "未登录或登录已过期", "data": null }

5. 接口权限规则(最重要)

接口 是否公开
POST /api/auth/register/login ✅ 公开
GET /api/articles、文章详情 ✅ 公开
POST/PUT/DELETE /api/articles 🔒 需登录
GET 评论列表 ✅ 公开
POST 发表评论 🔒 需登录
GET/PATCH /api/users/** 🔒 需登录

规则从上到下匹配,先匹配到的先生效。

6. 挂上 JWT 过滤器

java 复制代码
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
markdown 复制代码
请求 → JwtAuthenticationFilter(解析 token)
     → Security 权限检查(authenticated / permitAll)
     → Controller

7. 密码加密器

java 复制代码
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

注册时用 encode() 加密密码存库;登录时用 matches() 验证明文与密文。AuthService 注入的就是这个。

一次请求的完整链路

已登录发评论:

markdown 复制代码
POST /api/articles/5/comments
Header: Authorization: Bearer xxx

1. JwtAuthenticationFilter  解析 token → userId=1 放入 SecurityContext
2. SecurityConfig 规则       POST 评论 → 需要 authenticated → 已登录,放行
3. CommentController        进入业务
4. CommentService           从 SecurityContext 取当前用户昵称

未登录发评论:

markdown 复制代码
1. JwtAuthenticationFilter  无 token,不设置用户
2. SecurityConfig           POST 评论 → 需要 authenticated → 未登录
3. JwtAuthenticationEntryPoint  返回 401

记忆:SecurityConfig 是安全规则中心------关掉 session、配置哪些 API 要登录、挂 JWT 过滤器、定义 401 响应、提供 BCrypt 密码加密。


二十、UserControllerUserService:当前用户从哪来?

获取当前用户

java 复制代码
@GetMapping("/me")
public ApiResponse<UserResponse> me() {
    return ApiResponse.success(userService.getCurrentUser());
}

修改昵称

java 复制代码
@PatchMapping("/me/nickname")
public ApiResponse<UserResponse> updateNickname(@Valid @RequestBody NicknameUpdateRequest request) {
    return ApiResponse.success("昵称修改成功", userService.updateNickname(request));
}

NicknameUpdateRequest 只有一个字段 nickname,带 @NotBlank @Size 校验。

Service 如何知道「当前是谁」?

java 复制代码
private UserPrincipal getCurrentPrincipal() {
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    if (!(principal instanceof UserPrincipal userPrincipal)) {
        throw new RuntimeException("未登录");
    }
    return userPrincipal;
}

链路:

sql 复制代码
JWT 过滤器解析 token
  → 存入 SecurityContextHolder
  → UserService 读取 UserPrincipal
  → 用 userId 查数据库得到 User 实体

SecurityUtils 是对上述逻辑的封装,供 ArticleServiceCommentService 等复用。


二十一、注册 / 登录完整流程

注册

bash 复制代码
POST /api/auth/register  { username, password, nickname }
  → @Valid 校验字段格式
  → AuthService 检查用户名是否重复
  → BCrypt 加密密码 → 写入 users 表
  → JwtUtil 生成 token
  → 返回 { token, user: { id, username, nickname } }

登录

bash 复制代码
POST /api/auth/login  { username, password }
  → @Valid 校验
  → 按 username 查用户
  → passwordEncoder.matches(明文, 密文) 验证密码
  → 生成 token 并返回

之后访问受保护接口

css 复制代码
GET /api/users/me
  Header: Authorization: Bearer <token>
  → JwtAuthenticationFilter 解析 token → userId=1
  → SecurityConfig 检查已认证 → 放行
  → UserService.getCurrentUser() 返回用户信息

二十二、用户模块文件结构速查

bash 复制代码
entity/User.java              用户表映射
repository/UserRepository.java   findByUsername / existsByUsername
dto/RegisterRequest.java      注册入参
dto/LoginRequest.java         登录入参
dto/UserResponse.java         返回给前端(无密码)
dto/AuthResponse.java         token + user
dto/NicknameUpdateRequest.java   修改昵称入参
service/AuthService.java      注册、登录
service/UserService.java      获取当前用户、改昵称
controller/AuthController.java   /api/auth/**
controller/UserController.java   /api/users/**
security/JwtUtil.java         生成/解析 token
security/JwtAuthenticationFilter.java   请求过滤器
security/UserPrincipal.java   当前登录用户身份
security/SecurityUtils.java   获取当前用户的工具类
config/SecurityConfig.java    接口权限配置
config/JwtProperties.java     jwt.secret / expiration-ms

二十三、@Transactional:数据库事务

作用是什么?

@Transactional 把方法里的数据库操作包成一个事务

  • 全部成功 → 一起提交(COMMIT)
  • 中间出错 → 全部撤销(ROLLBACK)

记忆:要么全做,要么全不做。

通俗例子:银行转账

css 复制代码
A 账户扣 100 元
B 账户加 100 元

这两步必须一起成功或一起失败。如果只扣了 A 没加到 B,数据就乱了。事务就是保证这种「整体性」。


项目里的例子

例子 1:注册(AuthService.register

java 复制代码
@Transactional
public AuthResponse register(RegisterRequest request) {
    if (userRepository.existsByUsername(request.getUsername())) {
        throw new RuntimeException("用户名已存在");
    }
    User user = new User();
    user.setUsername(request.getUsername());
    user.setPassword(passwordEncoder.encode(request.getPassword()));
    user.setNickname(request.getNickname());
    userRepository.save(user);   // 写数据库
    return buildAuthResponse(user);
}

如果 save(user) 之后、return 之前抛异常 → 这次插入会回滚,数据库里不会留下半条用户记录。

例子 2:删文章(ArticleService.delete)------更典型

java 复制代码
@Transactional
public void delete(Long id) {
    Article article = getById(id);
    assertOwner(article);
    commentService.deleteByArticleId(id);  // 第 1 步:删评论
    articleRepository.deleteById(id);      // 第 2 步:删文章
}
步骤 操作
1 删除该文章下所有评论
2 删除文章本身

没有 @Transactional 的风险:

复制代码
评论删成功了 ✅
删文章时失败了 ❌
→ 评论没了,文章还在(数据不一致)

@Transactional

复制代码
评论删成功了
删文章失败了 → 抛异常
→ Spring 自动回滚,评论的删除也撤销
→ 数据保持原样

什么时候需要加?

场景 是否需要
只读查询(findAllgetById 一般不需要
单条 save / delete 可加可不加
多步写操作(删评论 + 删文章) 需要
写操作中途可能抛异常 建议加

本项目加了 @Transactional 的方法:

Service 方法
AuthService register
UserService updateNickname
ArticleService createupdatedelete
CommentService createdeletedeleteByArticleId

login 没有加,因为只读数据库、不写库,不需要事务。


底层发生了什么?

sql 复制代码
方法开始
  → Spring 开启事务(BEGIN)
  → 执行方法里的 save / delete ...
  → 正常结束 → 提交(COMMIT)
  → 抛 RuntimeException → 回滚(ROLLBACK)

不需要手写 BEGIN / COMMIT / ROLLBACK,Spring 通过 AOP 在方法前后自动处理。


注意点

说明
加在 Service,不是 Controller 事务是业务层概念
默认对 RuntimeException 回滚 方法里 throw new RuntimeException(...) 会触发回滚
通过代理生效 同类内部 this.xxx() 调用不会触发事务;要从外部调用带注解的 public 方法
配合 JPA 使用 本项目 spring-boot-starter-data-jpa 已支持,无需额外配置

和「两层校验」的关系

回顾第六节:

位置 示例
格式校验 Controller @Valid 内容不能为空
业务校验 Service 文章是否存在、是否本人
事务 Service @Transactional 多步写操作要么全成功要么全回滚

记忆:@Valid 管「数据对不对」,@Transactional 管「写库稳不稳」。


相关推荐
用户762352425913 小时前
深入理解AQS之独占锁ReentrantLock
后端
用户762352425913 小时前
理解 CAS & Atomic 原子操作类
后端
SimonKing3 小时前
铁子,IntelliJ IDEA 2026.1.3来了,升不升?
java·后端·程序员
铁皮饭盒3 小时前
@kognitivedev/rag, 用js做AI Agent开发
javascript·后端
IT_陈寒4 小时前
JavaScript的默认参数挖坑实录,我掉进去了
前端·人工智能·后端
陈明勇5 小时前
Go 1.26 新特性回顾:语言增强、工具升级与 Green Tea GC 默认启用
后端·go
咖啡八杯14 小时前
GoF设计模式——策略模式
java·后端·spring·设计模式
lizhongxuan15 小时前
AI Agent 上下文压缩利器 Headroom
后端
Csvn17 小时前
SSH 远程管理与安全加固 — 运维的守门之道
后端