上篇讲分层架构,本篇讲参数绑定、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 会自动推断泛型类型。
推断过程
commentService.listByArticleId(articleId)返回List<Comment>- 传给
success(T data)方法 - 编译器根据实参类型推断:
T = List<Comment> - 最终得到
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管「写库稳不稳」。