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 分开,避免外部随意改 id、createdAt 等字段。

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 上的 @Valid,CommentRequest 里写了 @NotBlank 也不会自动校验。

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


五、Long 与主键

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

六、两层校验

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

七、Repository:方法从哪来?

JpaRepository 自带(按主键 id)

findAll、findById、save、deleteById、existsById

为什么 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) 返回错误,data 为 null

以评论列表接口为例

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> Void(data 为 null)

十一、为什么调用 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) 是转换方法------手动从实体里取出 id、username、nickname,构造 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 ❌ 没有
字段是否都要展示? 否,密码不能展示 是,title、content 等正是详情页要的
是否需要 Response DTO? 必须 (UserResponse) 当前项目不必(可直接返回实体)

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

字段 能否返回前端
title、content、summary ✅ 文章就是要展示
author ✅
userId ✅ 用于判断是否本人文章
createdAt、updatedAt ✅

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

什么时候文章也需要 ArticleResponse?

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

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


十六、AuthController 与 AuthService

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.yml → JwtProperties)

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 时用来算过期时间

为什么写在构造函数里?

  • key 和 expirationMs 在整个应用生命周期内不变
  • 启动时读一次配置即可,generateToken() / parseToken() 直接复用
  • 改 application.yml 里的 secret 或 expiration-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 取出 userId、username 从 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 密码加密。


二十、UserController 与 UserService:当前用户从哪来?

获取当前用户

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 是对上述逻辑的封装,供 ArticleService、CommentService 等复用。


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

注册

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 自动回滚,评论的删除也撤销
→ 数据保持原样

什么时候需要加?

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

本项目加了 @Transactional 的方法:

Service 方法
AuthService register
UserService updateNickname
ArticleService create、update、delete
CommentService create、delete、deleteByArticleId

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 管「写库稳不稳」。


相关推荐
金銀銅鐵2 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路3 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x3 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn5 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈6 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780516 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123456 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒7 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记7 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598787 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端