| 问题 | 涉及文件 |
|---|---|
Service 里 throw new RuntimeException("用户名已存在") 之后发生了什么? | GlobalExceptionHandler.java |
@Valid 校验失败时,谁把 400 错误返回给前端? | GlobalExceptionHandler.java |
@Entity、@Column、@GeneratedValue 分别是什么意思? | entity/Article.java 等 |
Java 字段怎么变成 MySQL 的 CREATE TABLE? | application.yml + JPA |
GlobalExceptionHandler 全貌 复制代码@RestControllerAdvice
public class GlobalExceptionHandler { @ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) {
return ApiResponse.error(400, e.getMessage());
} @ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return ApiResponse.error(400, message);
}
}
它是整个后端的统一异常出口:任何地方抛出的特定异常,都会在这里被捕获,转成统一的 JSON 格式返回。
@RestControllerAdvice — 全局拦截器加在类上,表示:这个类专门处理 Controller 调用链中抛出的异常(包括 Service 里 throw 的)。
复制代码Controller 调用 Service → Service 抛出异常
↓
异常向上冒泡,Spring 按类型匹配 @ExceptionHandler
↓
返回统一 JSON(ApiResponse)
和普通 @ControllerAdvice 的区别:@RestControllerAdvice = @ControllerAdvice + @ResponseBody,返回值直接写成 JSON,不用再额外标注。
@ExceptionHandler(异常类型.class) — 指定处理哪种异常 复制代码@ExceptionHandler(RuntimeException.class)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) { ... }
含义:当任何地方抛出 RuntimeException(及其子类)时,执行这个方法。
本项目 Service 里的业务错误都用 RuntimeException:
| 位置 | 抛出的异常 |
|---|---|
AuthService.register | throw new RuntimeException("用户名已存在") |
AuthService.login | throw new RuntimeException("用户名或密码错误") |
CommentService.create | throw new RuntimeException("文章不存在") |
ArticleService.delete | throw new RuntimeException("无权操作该文章") |
UserService | throw new RuntimeException("未登录") |
@ResponseStatus(HttpStatus.BAD_REQUEST) — 设置 HTTP 状态码 复制代码@ResponseStatus(HttpStatus.BAD_REQUEST) // HTTP 400
告诉 Spring:这个方法返回时,HTTP 响应状态码设为 400 Bad Request。
最终前端收到的响应:
复制代码{
"code": 400,
"message": "用户名已存在",
"data": null
}
RuntimeException)以注册时用户名重复为例:
复制代码POST /api/auth/register { "username": "summer", ... }
↓
AuthController → AuthService.register()
↓
userRepository.existsByUsername("summer") == true
↓
throw new RuntimeException("用户名已存在")
↓
异常向上冒泡(Service → Controller,不会正常 return)
↓
Spring 扫描 @RestControllerAdvice,按异常类型匹配 @ExceptionHandler
↓
调用 handleRuntimeException(e) ← 不是 throw 直接调用的
↓
return ApiResponse.error(400, "用户名已存在")
↓
HTTP 400 + JSON
对应代码:
复制代码// AuthService.java
if (userRepository.existsByUsername(request.getUsername())) {
throw new RuntimeException("用户名已存在");
}// GlobalExceptionHandler.java
@ExceptionHandler(RuntimeException.class)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) {
return ApiResponse.error(400, e.getMessage()); // e.getMessage() = "用户名已存在"
}
e.getMessage() 就是 throw 时括号里的字符串。
throw 并不是直接调用 handleRuntimeException很多人看到 throw new RuntimeException("用户名已存在") 就会以为代码「自动跳」到了 handleRuntimeException,其实不是。
| 误解 | 实际情况 |
|---|---|
throw 会调用 handleRuntimeException | Service 里只负责抛异常,不会、也不能写 handleRuntimeException(...) |
| 方法名要对上才会处理 | Spring 看的是异常类型(RuntimeException.class),不是方法名 |
| Handler 和 Service 有直接引用关系 | 两者互不 import,完全由 Spring 在运行时串联 |
完整机制如下:
复制代码1. AuthService 里执行 throw new RuntimeException("用户名已存在")
2. 当前方法中断,异常沿调用栈向上抛:Service → Controller
3. Controller 没有 try-catch,异常继续向上
4. Spring MVC 拦截到这个未处理异常
5. 发现存在 @RestControllerAdvice 标注的 GlobalExceptionHandler
6. 在其中查找 @ExceptionHandler,匹配异常类型:
- 抛出的是 RuntimeException → 匹配 handleRuntimeException
- 若是 MethodArgumentNotValidException → 匹配更具体的 handleValidationException
7. Spring 调用匹配到的方法,把异常对象 e 传进去
8. 方法 return ApiResponse.error(400, e.getMessage()) → 写成 HTTP 响应
对应代码分工:
复制代码// AuthService — 只管「出错了」,不管怎么返回 JSON
throw new RuntimeException("用户名已存在");// GlobalExceptionHandler — 只管「怎么响应」,不用知道是谁抛的
@ExceptionHandler(RuntimeException.class)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) {
return ApiResponse.error(400, e.getMessage());
}
一句话:throw 负责中断业务;@RestControllerAdvice + @ExceptionHandler 负责按异常类型接住并转成统一 JSON。这是 Spring 的全局异常处理机制,不是 Java 语言本身的「自动跳转」。
上篇讲过 @Valid 触发 DTO 字段校验。校验失败时,Spring 不会进 Service,而是直接抛 MethodArgumentNotValidException。
复制代码POST /api/auth/register { "username": "", "password": "123" }
↓
@Valid 检查 RegisterRequest
↓
username 违反 @NotBlank → 校验失败
↓
抛出 MethodArgumentNotValidException(不会进入 AuthService)
↓
GlobalExceptionHandler.handleValidationException()
↓
收集所有字段错误信息,用 "; " 拼接
↓
HTTP 400 + JSON
处理代码:
复制代码String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage) // 取 @NotBlank(message="...") 里的 message
.collect(Collectors.joining("; ")); // 多个错误用分号连接
return ApiResponse.error(400, message);
示例响应:
复制代码{
"code": 400,
"message": "用户名不能为空; 昵称不能为空",
"data": null
}
| 业务异常 | 参数校验失败 | |
|---|---|---|
| 异常类型 | RuntimeException | MethodArgumentNotValidException |
| 触发位置 | Service 里 throw | Controller 入参 @Valid |
| 是否进入 Service | 是(抛异常前可能已执行部分逻辑) | 否 |
| message 来源 | e.getMessage() | DTO 注解里的 message = "..." |
| 处理方法 | handleRuntimeException | handleValidationException |
ApiResponse:统一响应格式异常处理和正常接口共用同一个响应结构:
复制代码public class ApiResponse<T> {
private int code; // 业务状态码
private String message;
private T data; public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
} public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}
| 场景 | code | message | data |
|---|---|---|---|
| 成功 | 200 | "success" 或自定义 | 实际数据 |
| 业务错误 | 400 | "用户名已存在" | null |
| 校验失败 | 400 | "评论内容不能为空" | null |
前端只需判断 code === 200 即成功,否则读 message 提示用户。
RuntimeException 而不是自定义异常?当前项目是简化写法:所有业务错误统一抛 RuntimeException,靠 message 字符串区分。
更规范的做法是自定义异常:
复制代码public class BusinessException extends RuntimeException {
private final int code;
// ...
}
对本项目而言,RuntimeException + 全局处理器已经足够。要点是:不要在 Controller 里 try-catch,让异常自然抛到 GlobalExceptionHandler。
复制代码@Entity 类(Java) MySQL 表
───────────────── ─────────────
Article.java → article 表
User.java → users 表
Comment.java → comment 表
JPA(Java Persistence API)负责:把 Java 对象映射成数据库表,把字段读写转换成 SQL。
配置文件 application.yml:
复制代码spring:
jpa:
hibernate:
ddl-auto: update # 启动时自动建表/更新表结构
show-sql: true # 控制台打印 SQL
ddl-auto: update 表示:实体类改了,启动时自动 ALTER TABLE,开发阶段很方便(生产环境建议改为 validate 或用手动迁移)。
以 Article 为例:

复制代码@Data
@Entity
@Table(name = "article")
public class Article { @Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; @Column(nullable = false, length = 200)
private String title; @Column(nullable = false, columnDefinition = "TEXT")
private String content; @Column(length = 500)
private String summary; @Column(nullable = false, length = 50)
private String author; private Long userId; @CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt; @UpdateTimestamp
@Column(nullable = false)
private LocalDateTime updatedAt;
}
| 注解 | 作用 | 示例 |
|---|---|---|
@Entity | 标记这是数据库实体,JPA 会管理它 | 必须有,否则不映射 |
@Table(name = "article") | 指定表名 | 不写则默认用类名 Article |
@Data | Lombok:自动生成 getter/setter | 不是 JPA 注解 |
User 表名用 users 而不是 user,因为 user 在 MySQL 中是保留字:
复制代码@Table(name = "users")
public class User { ... }
@Id 与 @GeneratedValue:主键怎么来 复制代码@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
| 注解 | 含义 |
|---|---|
@Id | 这是主键字段 |
@GeneratedValue | 主键值自动生成,不用自己设 |
strategy = GenerationType.IDENTITY | 策略:用数据库自增(MySQL 的 AUTO_INCREMENT) |
strategy 是什么意思?strategy 是 @GeneratedValue 的参数名,意思是「用哪种策略生成主键」。
复制代码@GeneratedValue(strategy = GenerationType.IDENTITY) // 数据库自增
@GeneratedValue(strategy = GenerationType.SEQUENCE) // 数据库序列(PostgreSQL)
@GeneratedValue(strategy = GenerationType.AUTO) // 让 JPA 自动选择
本项目用 MySQL,选 IDENTITY 最合适。
复制代码Article article = new Article();
article.setTitle("我的第一篇文章");
// 此时 article.getId() == null,不用管 idarticleRepository.save(article);
// 数据库执行:INSERT INTO article (title, ...) VALUES (...)
// 数据库自动生成 id = 1
// JPA 把 id 回填:article.getId() == 1
等价 SQL:
复制代码CREATE TABLE article (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- @Id + @GeneratedValue(IDENTITY)
...
);
| 策略 | 谁生成 id | 典型数据库 |
|---|---|---|
IDENTITY | 数据库插入时自增 | MySQL、SQL Server |
SEQUENCE | 数据库序列 nextval | PostgreSQL、Oracle |
AUTO | JPA 按数据库自动选 | 通用 |
TABLE | 单独一张表维护 id | 较少用 |
@Column:列的类型与约束@Column 描述普通字段对应数据库列的规则(主键一般用 @Id + @GeneratedValue,不必再加 @Column)。
复制代码@Column(nullable = false, length = 200)
private String title;
| 属性 | 含义 | 对应 SQL |
|---|---|---|
nullable = false | 不允许为空 | NOT NULL |
length = 200 | 字符串最大长度 | VARCHAR(200) |
unique = true | 值必须唯一 | UNIQUE |
columnDefinition = "TEXT" | 自定义列类型 | TEXT |
updatable = false | 插入后不允许更新 | 更新 SQL 不包含该列 |
复制代码// User.java — 登录名唯一、最长 50
@Column(nullable = false, unique = true, length = 50)
private String username;// Article.java — 正文用大文本类型
@Column(nullable = false, columnDefinition = "TEXT")
private String content;// Article.java — 创建时间:必填、不可更新
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column 时 复制代码private Long userId; // 无 @Column
JPA 使用默认规则:字段名 userId → 列名 user_id(驼峰转下划线),类型按 Java 类型推断。
@Column 与 @GeneratedValue 的区别@Column | @GeneratedValue | |
|---|---|---|
| 管什么 | 列的类型、长度、是否必填 | 主键 id 怎么自动生成 |
| 用在哪 | 普通字段(也可用于主键,但少见) | 仅 @Id 主键 |
| 类比 SQL | VARCHAR(200) NOT NULL | AUTO_INCREMENT |
@CreationTimestamp 与 @UpdateTimestamp 复制代码@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;@UpdateTimestamp
@Column(nullable = false)
private LocalDateTime updatedAt;
| 注解 | 作用 |
|---|---|
@CreationTimestamp | 插入时自动填入当前时间 |
@UpdateTimestamp | 每次更新时自动刷新为当前时间 |
代码里不用手动 setCreatedAt(new Date()),保存时 Hibernate 自动处理。
updatable = false 保证 createdAt 创建后不会被改掉。
users 表(User.java) 复制代码@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;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
article 表(Article.java)| 字段 | 类型 | 说明 |
|---|---|---|
id | Long 自增 | 主键 |
title | VARCHAR(200) NOT NULL | 标题 |
content | TEXT NOT NULL | 正文 |
summary | VARCHAR(500) | 摘要,可空 |
author | VARCHAR(50) NOT NULL | 作者昵称(冗余展示) |
userId | BIGINT | 作者用户 ID |
createdAt / updatedAt | DATETIME NOT NULL | 创建/更新时间 |
comment 表(Comment.java) 复制代码@Entity
@Table(name = "comment")
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long articleId; // 属于哪篇文章
private Long parentId; // 父评论 ID,顶级评论为 null
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Column(nullable = false, length = 50)
private String author;
private LocalDateTime createdAt;
}
上篇讲过 CommentRequest 和 Comment 实体的区别,这里从数据库角度补充:
| Entity(实体) | DTO(请求/响应对象) | |
|---|---|---|
| 包路径 | com.blog.entity | com.blog.dto |
| 注解 | @Entity @Column 等 JPA 注解 | @NotBlank @Size 等校验注解 |
| 用途 | 映射数据库表 | 接收/返回 API 数据 |
id | 有(数据库自增) | 一般没有 |
password | User 有 | UserResponse 没有(安全) |
原则:数据库结构归 Entity,API 入参归 DTO,不要混用。
复制代码public interface ArticleRepository extends JpaRepository<Article, Long> {
}
JpaRepository<Article, Long> 中:
Article:操作的实体类型Long:主键类型(对应 @Id private Long id)继承后自动拥有常用方法,无需写 SQL:
| 方法 | 作用 |
|---|---|
save(article) | 新增或更新 |
findById(id) | 按主键查询 |
findAll() | 查全部 |
deleteById(id) | 按主键删除 |
existsById(id) | 判断是否存在 |
UserRepository 还支持按方法名自动生成查询:
复制代码Optional<User> findByUsername(String username); // SELECT * FROM users WHERE username = ?
boolean existsByUsername(String username); // SELECT COUNT(*) > 0 ...
CommentRepository:
复制代码List<Comment> findByArticleIdOrderByCreatedAtAsc(Long articleId);
// SELECT * FROM comment WHERE article_id = ? ORDER BY created_at ASC
方法名即查询:findBy + 字段名 + OrderBy + 排序字段 + Asc/Desc。
把本篇和上篇串起来:
复制代码1. POST /api/articles/5/comments
Body: { "content": "不错" }
Header: Authorization: Bearer <token>2. JwtAuthenticationFilter 解析 token → 当前用户写入 SecurityContext3. Controller: @PathVariable articleId=5, @Valid @RequestBody CommentRequest
→ 若 content 为空 → MethodArgumentNotValidException → GlobalExceptionHandler → 4004. CommentService.create(5, request)
→ articleRepository.existsById(5) == false
→ throw new RuntimeException("文章不存在")
→ GlobalExceptionHandler → 400 "文章不存在"5. 校验通过:
Comment comment = new Comment();
comment.setArticleId(5);
comment.setContent("不错");
comment.setAuthor(当前用户昵称);
commentRepository.save(comment);
→ INSERT INTO comment (...) VALUES (...)
→ id 由数据库 AUTO_INCREMENT 生成
→ createdAt 由 @CreationTimestamp 自动填入6. 返回 ApiResponse.success("评论成功", comment)
throw new RuntimeException 和 return ApiResponse.error 有什么区别?throw:在 Service 里表示「出错了,中断执行」,由 GlobalExceptionHandler 统一处理return ApiResponse.error:在 Handler 里构造最终 JSONService 层只负责 throw,不要自己写 return error。
throw new RuntimeException 能触发 handleRuntimeException?不是 throw 直接调用了那个方法,而是 Spring 在异常向上冒泡时,根据 @ExceptionHandler(RuntimeException.class) 按类型匹配后自动调用。详见上文 「重要:throw 并不是直接调用 handleRuntimeException」 一节。
不会。@Valid 在 Controller 入参绑定阶段就失败了,直接走 handleValidationException。
strategy = GenerationType.IDENTITY 和 @Column 能写在同一个字段上吗?主键字段一般写:
复制代码@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
不需要再加 @Column。@GeneratedValue 管 id 怎么生成,@Column 管列约束,职责不同。
save 后 id 从 null 变成有值?IDENTITY 策略下,数据库执行 INSERT 后生成自增 id,JPA 会把这个值回填到 Java 对象的 id 字段。
ddl-auto: update 安全吗?开发阶段方便,但生产环境建议改为 validate(只校验不修改)或使用 Flyway/Liquibase 做版本化迁移,避免实体改动意外改表结构。
| 主题 | 关键知识点 |
|---|---|
| 全局异常 | @RestControllerAdvice + @ExceptionHandler + @ResponseStatus |
| 业务错误 | Service throw new RuntimeException("消息") → Handler 转 ApiResponse.error |
| 参数校验 | @Valid 失败 → MethodArgumentNotValidException → 拼接字段 message |
| 实体映射 | @Entity @Table 类对应表 |
| 主键 | @Id + @GeneratedValue(strategy = IDENTITY) = 数据库自增 |
| 列约束 | @Column(nullable, length, unique, columnDefinition) |
| 时间 | @CreationTimestamp / @UpdateTimestamp 自动维护 |
| 数据访问 | JpaRepository 继承即得 CRUD,方法名可自动生成查询 |