第24篇-全局异常处理

【Kotlin + Spring Boot 4 从零到架构师】第 24 篇:全局异常处理

本系列定位:零基础入门,从 Kotlin 语法一路到 Spring Boot 4 高级架构(DDD + Modulith),适合 Java 开发者转型,也适合纯新手系统学习。


本篇你将学到

  • 为什么需要全局异常处理
  • @RestControllerAdvice + @ExceptionHandler 的使用
  • 自定义业务异常体系设计
  • 统一错误响应格式
  • 常见 Spring Boot 异常的处理策略

学完本篇,你将为 mini-shop 构建一套完整的异常处理体系,无论发生什么错误,前端都能收到结构一致的错误响应。


一、为什么需要全局异常处理

1.1 没有异常处理时的混乱

kotlin 复制代码
@RestController
class ProductController {

    @GetMapping("/{id}")
    fun getById(@PathVariable id: Long): Product {
        val product = productService.findById(id)
            ?: throw RuntimeException("商品不存在")     // 直接抛 RuntimeException
        return product
    }
}

当出错时,Spring Boot 默认返回:

json 复制代码
{
  "timestamp": "2025-07-29T12:00:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "商品不存在",
  "path": "/api/products/1"
}

问题:

  • 状态码不准确:商品不存在应该是 404,不是 500
  • 格式不统一:每个异常的响应格式可能不同
  • 前端处理困难:没有统一的错误码
  • 安全风险:内部异常细节暴露给客户端

1.2 理想的异常处理

复制代码
所有异常 → 统一格式响应
{
  "code": 404,                    ← 业务状态码
  "message": "商品不存在",          ← 人类可读的提示
  "timestamp": "...",             ← 时间戳
  "path": "/api/products/999"     ← 请求路径
}

下面是「没有异常处理」与「理想异常处理」的流程对比图:
#mermaid-svg-Ju9wTd4jsWvGCwvD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ju9wTd4jsWvGCwvD .error-icon{fill:#552222;}#mermaid-svg-Ju9wTd4jsWvGCwvD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ju9wTd4jsWvGCwvD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .marker.cross{stroke:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ju9wTd4jsWvGCwvD p{margin:0;}#mermaid-svg-Ju9wTd4jsWvGCwvD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster-label text{fill:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster-label span{color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster-label span p{background-color:transparent;}#mermaid-svg-Ju9wTd4jsWvGCwvD .label text,#mermaid-svg-Ju9wTd4jsWvGCwvD span{fill:#333;color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node rect,#mermaid-svg-Ju9wTd4jsWvGCwvD .node circle,#mermaid-svg-Ju9wTd4jsWvGCwvD .node ellipse,#mermaid-svg-Ju9wTd4jsWvGCwvD .node polygon,#mermaid-svg-Ju9wTd4jsWvGCwvD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .rough-node .label text,#mermaid-svg-Ju9wTd4jsWvGCwvD .node .label text,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .rough-node .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .node .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape .label{text-align:center;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node.clickable{cursor:pointer;}#mermaid-svg-Ju9wTd4jsWvGCwvD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .arrowheadPath{fill:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ju9wTd4jsWvGCwvD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster text{fill:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster span{color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ju9wTd4jsWvGCwvD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape p,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape .label rect,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ju9wTd4jsWvGCwvD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ju9wTd4jsWvGCwvD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ✅ 理想异常处理
Controller 抛出 BusinessException
GlobalExceptionHandler 捕获
统一 ErrorResponse 格式

code/message/timestamp/path
❌ 没有异常处理
Controller 抛出 RuntimeException
Spring Boot 默认返回 500
状态码不准确

格式不统一

前端处理困难

安全风险

二、自定义业务异常

2.1 异常体系设计

复制代码
RuntimeException
  └── BusinessException              ← 所有业务异常的基类
        ├── ResourceNotFoundException    ← 资源不存在
        ├── BusinessRuleException        ← 业务规则违反
        ├── AuthenticationException      ← 认证失败
        └── AuthorizationException       ← 授权失败

下面是异常体系的类图关系:
#mermaid-svg-7ddX8MIXZoXw9Wea{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7ddX8MIXZoXw9Wea .error-icon{fill:#552222;}#mermaid-svg-7ddX8MIXZoXw9Wea .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7ddX8MIXZoXw9Wea .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7ddX8MIXZoXw9Wea .marker.cross{stroke:#333333;}#mermaid-svg-7ddX8MIXZoXw9Wea svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7ddX8MIXZoXw9Wea p{margin:0;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup text .title{font-weight:bolder;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster-label text{fill:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster-label span{color:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster-label span p{background-color:transparent;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster text{fill:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster span{color:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .nodeLabel,#mermaid-svg-7ddX8MIXZoXw9Wea .edgeLabel{color:#131300;}#mermaid-svg-7ddX8MIXZoXw9Wea .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-7ddX8MIXZoXw9Wea .label text{fill:#131300;}#mermaid-svg-7ddX8MIXZoXw9Wea .labelBkg{background:#ECECFF;}#mermaid-svg-7ddX8MIXZoXw9Wea .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-7ddX8MIXZoXw9Wea .classTitle{font-weight:bolder;}#mermaid-svg-7ddX8MIXZoXw9Wea .node rect,#mermaid-svg-7ddX8MIXZoXw9Wea .node circle,#mermaid-svg-7ddX8MIXZoXw9Wea .node ellipse,#mermaid-svg-7ddX8MIXZoXw9Wea .node polygon,#mermaid-svg-7ddX8MIXZoXw9Wea .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7ddX8MIXZoXw9Wea .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea g.clickable{cursor:pointer;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-7ddX8MIXZoXw9Wea .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-7ddX8MIXZoXw9Wea .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-7ddX8MIXZoXw9Wea .dashed-line{stroke-dasharray:3;}#mermaid-svg-7ddX8MIXZoXw9Wea .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-7ddX8MIXZoXw9Wea #compositionStart,#mermaid-svg-7ddX8MIXZoXw9Wea .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #compositionEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #dependencyStart,#mermaid-svg-7ddX8MIXZoXw9Wea .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #dependencyStart,#mermaid-svg-7ddX8MIXZoXw9Wea .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #extensionStart,#mermaid-svg-7ddX8MIXZoXw9Wea .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #extensionEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #aggregationStart,#mermaid-svg-7ddX8MIXZoXw9Wea .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #aggregationEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #lollipopStart,#mermaid-svg-7ddX8MIXZoXw9Wea .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #lollipopEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-7ddX8MIXZoXw9Wea .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7ddX8MIXZoXw9Wea .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7ddX8MIXZoXw9Wea :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} <<Java>>
RuntimeException
BusinessException
+Int code
+String message
ResourceNotFoundException
+ResourceNotFoundException(resource, id)
BusinessRuleException
+BusinessRuleException(message)
AuthenticationException
+AuthenticationException(message)
AuthorizationException
+AuthorizationException(message)

2.2 实现

kotlin 复制代码
package com.example.minishop.exception

/**
 * 业务异常基类
 *
 * @param code 业务状态码
 * @param message 错误信息
 */
open class BusinessException(
    val code: Int,
    override val message: String
) : RuntimeException(message)

/** 资源不存在异常(404) */
class ResourceNotFoundException(
    resource: String,
    id: Any
) : BusinessException(
    code = 404,
    message = "$resource 不存在:$id"
)

/** 业务规则违反(400) */
class BusinessRuleException(
    message: String
) : BusinessException(
    code = 400,
    message = message
)

/** 认证失败(401) */
class AuthenticationException(
    message: String = "认证失败,请登录"
) : BusinessException(
    code = 401,
    message = message
)

/** 权限不足(403) */
class AuthorizationException(
    message: String = "权限不足"
) : BusinessException(
    code = 403,
    message = message
)

2.3 在业务代码中使用

kotlin 复制代码
@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    fun findById(id: Long): Product {
        return productRepository.findById(id)
            .orElseThrow { ResourceNotFoundException("商品", id) }    // ← 抛业务异常
    }

    fun decreaseStock(productId: Long, quantity: Int) {
        val product = findById(productId)
        if (product.stock < quantity) {
            throw BusinessRuleException("库存不足,当前库存:${product.stock}")
        }
        product.stock -= quantity
        productRepository.save(product)
    }
}

三、统一错误响应格式

kotlin 复制代码
package com.example.minishop.dto

import java.time.LocalDateTime

/**
 * 统一错误响应
 *
 * @param code 业务状态码
 * @param message 错误信息
 * @param path 请求路径
 * @param timestamp 时间戳
 * @param details 字段级错误详情(校验失败时使用)
 */
data class ErrorResponse(
    val code: Int,
    val message: String,
    val path: String,
    val timestamp: LocalDateTime = LocalDateTime.now(),
    val details: Map<String, String>? = null        // 字段校验错误详情
)

四、@RestControllerAdvice 全局异常处理

4.1 核心注解

注解 作用
@RestControllerAdvice 全局异常处理器(= @ControllerAdvice + @ResponseBody
@ExceptionHandler(XxxException::class) 处理指定类型的异常

4.2 完整实现

kotlin 复制代码
package com.example.minishop.exception

import com.example.minishop.dto.ErrorResponse
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.servlet.NoHandlerFoundException

@RestControllerAdvice
class GlobalExceptionHandler {

    private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)

    /**
     * 资源不存在异常 → 404
     */
    @ExceptionHandler(ResourceNotFoundException::class)
    fun handleNotFound(
        ex: ResourceNotFoundException,
        request: HttpServletRequest
    ): ResponseEntity<ErrorResponse> {
        log.warn("资源不存在:${ex.message}")
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse(
                code = ex.code,
                message = ex.message,
                path = request.requestURI
            ))
    }

    /**
     * 业务规则违反 → 400
     */
    @ExceptionHandler(BusinessRuleException::class)
    fun handleBusinessRule(
        ex: BusinessRuleException,
        request: HttpServletRequest
    ): ResponseEntity<ErrorResponse> {
        log.warn("业务规则违反:${ex.message}")
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse(
                code = ex.code,
                message = ex.message,
                path = request.requestURI
            ))
    }

    /**
     * 统一处理所有 BusinessException
     */
    @ExceptionHandler(BusinessException::class)
    fun handleBusinessException(
        ex: BusinessException,
        request: HttpServletRequest
    ): ResponseEntity<ErrorResponse> {
        val httpStatus = when (ex.code) {
            401 -> HttpStatus.UNAUTHORIZED
            403 -> HttpStatus.FORBIDDEN
            404 -> HttpStatus.NOT_FOUND
            else -> HttpStatus.BAD_REQUEST
        }
        log.warn("业务异常 [${ex.code}]:${ex.message}")
        return ResponseEntity.status(httpStatus)
            .body(ErrorResponse(
                code = ex.code,
                message = ex.message,
                path = request.requestURI
            ))
    }

    /**
     * Bean Validation 校验失败 → 400 + 字段详情
     */
    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(
        ex: MethodArgumentNotValidException,
        request: HttpServletRequest
    ): ResponseEntity<ErrorResponse> {
        val details = ex.bindingResult.fieldErrors.associate { error: FieldError ->
            error.field to (error.defaultMessage ?: "校验失败")
        }
        log.warn("参数校验失败:$details")
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse(
                code = 400,
                message = "请求参数校验失败",
                path = request.requestURI,
                details = details
            ))
    }

    /**
     * 参数类型不匹配(如 /api/products/abc 中 abc 不是数字)→ 400
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException::class)
    fun handleTypeMismatch(
        ex: MethodArgumentTypeMismatchException,
        request: HttpServletRequest
    ): ResponseEntity<ErrorResponse> {
        log.warn("参数类型不匹配:${ex.message}")
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse(
                code = 400,
                message = "参数 '${ex.name}' 的值 '${ex.value}' 无法转换为 ${ex.requiredType?.simpleName}",
                path = request.requestURI
            ))
    }

    /**
     * 请求路径不存在 → 404
     */
    @ExceptionHandler(NoHandlerFoundException::class)
    fun handleNotFound(
        ex: NoHandlerFoundException,
        request: HttpServletRequest
    ): ResponseEntity<ErrorResponse> {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse(
                code = 404,
                message = "请求路径不存在",
                path = request.requestURI
            ))
    }

    /**
     * 兜底:所有未被上面捕获的异常 → 500
     */
    @ExceptionHandler(Exception::class)
    fun handleUnexpected(
        ex: Exception,
        request: HttpServletRequest
    ): ResponseEntity<ErrorResponse> {
        log.error("未处理的异常", ex)          // 记录完整堆栈
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse(
                code = 500,
                message = "服务器内部错误,请稍后重试",
                path = request.requestURI
            ))
    }
}

4.3 异常处理优先级

Spring 选择 @ExceptionHandler 时遵循最精确匹配原则:

复制代码
抛出 ResourceNotFoundException
  → 匹配 @ExceptionHandler(ResourceNotFoundException::class)   ✅ 优先
  → 而非 @ExceptionHandler(BusinessException::class)            ← 父类,次选
  → 而非 @ExceptionHandler(Exception::class)                    ← 最不精确

把具体的异常处理器写在前面,兜底的 Exception::class 放最后。


下面是异常处理器的匹配优先级流程图:
#mermaid-svg-ycWQgfqcRt4yL54d{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ycWQgfqcRt4yL54d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ycWQgfqcRt4yL54d .error-icon{fill:#552222;}#mermaid-svg-ycWQgfqcRt4yL54d .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ycWQgfqcRt4yL54d .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d .marker.cross{stroke:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ycWQgfqcRt4yL54d p{margin:0;}#mermaid-svg-ycWQgfqcRt4yL54d .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster-label text{fill:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster-label span{color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster-label span p{background-color:transparent;}#mermaid-svg-ycWQgfqcRt4yL54d .label text,#mermaid-svg-ycWQgfqcRt4yL54d span{fill:#333;color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .node rect,#mermaid-svg-ycWQgfqcRt4yL54d .node circle,#mermaid-svg-ycWQgfqcRt4yL54d .node ellipse,#mermaid-svg-ycWQgfqcRt4yL54d .node polygon,#mermaid-svg-ycWQgfqcRt4yL54d .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .rough-node .label text,#mermaid-svg-ycWQgfqcRt4yL54d .node .label text,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape .label,#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape .label{text-anchor:middle;}#mermaid-svg-ycWQgfqcRt4yL54d .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .rough-node .label,#mermaid-svg-ycWQgfqcRt4yL54d .node .label,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape .label,#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape .label{text-align:center;}#mermaid-svg-ycWQgfqcRt4yL54d .node.clickable{cursor:pointer;}#mermaid-svg-ycWQgfqcRt4yL54d .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d .arrowheadPath{fill:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ycWQgfqcRt4yL54d .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ycWQgfqcRt4yL54d .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ycWQgfqcRt4yL54d .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ycWQgfqcRt4yL54d .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ycWQgfqcRt4yL54d .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ycWQgfqcRt4yL54d .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster text{fill:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster span{color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ycWQgfqcRt4yL54d .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ycWQgfqcRt4yL54d rect.text{fill:none;stroke-width:0;}#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape p,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape .label rect,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ycWQgfqcRt4yL54d .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ycWQgfqcRt4yL54d .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ycWQgfqcRt4yL54d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是





抛出异常

如 ResourceNotFoundException
是否有

@ExceptionHandler

精确匹配?
执行精确匹配的处理器

@ExceptionHandler(ResourceNotFoundException::class)
是否有

@ExceptionHandler

匹配父类?
执行父类处理器

@ExceptionHandler(BusinessException::class)
是否有

@ExceptionHandler

匹配根类?
执行根类处理器

@ExceptionHandler(Exception::class)
返回默认 500 错误

五、需要额外配置的异常

5.1 启用 404 异常

Spring Boot 默认不抛 NoHandlerFoundException,需要在配置中启用:

yaml 复制代码
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  web:
    resources:
      add-mappings: false          # 禁用静态资源映射(可选)

5.2 处理 JPA 空结果异常

Spring Data JPA 的 findById 返回 Optional,不会抛异常。但如果你用了 getOne()orElseThrow()

kotlin 复制代码
// 方式一:orElseThrow 抛业务异常(推荐)
val product = productRepository.findById(id)
    .orElseThrow { ResourceNotFoundException("商品", id) }

// 方式二:让 JPA 的 EntityNotFoundException 被全局处理器捕获
@ExceptionHandler(EntityNotFoundException::class)
fun handleEntityNotFound(ex: EntityNotFoundException, request: HttpServletRequest)
    : ResponseEntity<ErrorResponse> { ... }

六、效果验证

场景一:查询不存在的商品

bash 复制代码
curl http://localhost:8080/api/products/999
json 复制代码
{
  "code": 404,
  "message": "商品 不存在:999",
  "path": "/api/products/999",
  "timestamp": "2025-07-29T15:30:00"
}

场景二:参数校验失败

bash 复制代码
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"", "price": -1}'
json 复制代码
{
  "code": 400,
  "message": "请求参数校验失败",
  "path": "/api/products",
  "timestamp": "2025-07-29T15:31:00",
  "details": {
    "name": "商品名称不能为空",
    "price": "价格必须大于 0"
  }
}

场景三:路径参数类型错误

bash 复制代码
curl http://localhost:8080/api/products/abc
json 复制代码
{
  "code": 400,
  "message": "参数 'id' 的值 'abc' 无法转换为 Long",
  "path": "/api/products/abc",
  "timestamp": "2025-07-29T15:32:00"
}

场景四:未知服务器错误

bash 复制代码
curl http://localhost:8080/api/products
# 如果数据库连接断了
json 复制代码
{
  "code": 500,
  "message": "服务器内部错误,请稍后重试",
  "path": "/api/products",
  "timestamp": "2025-07-29T15:33:00"
}

本篇小结

知识点 核心内容
@RestControllerAdvice 全局异常处理器
@ExceptionHandler 按异常类型分别处理
精确匹配 具体异常优先于父类异常
BusinessException 业务异常基类
ResourceNotFoundException 404 资源不存在
BusinessRuleException 400 业务规则违反
ErrorResponse 统一错误响应格式
兜底处理 Exception::class 捕获所有未处理异常
校验异常处理 MethodArgumentNotValidException → 字段级详情
日志策略 业务异常 warn,未知异常 error(记录堆栈)

下篇预告

第 25 篇:统一响应格式设计

成功返回数据、失败返回错误------两种响应的格式如何统一?下一篇设计 mini-shop 的统一 ApiResponse<T>,让所有接口的响应格式一致。


如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

相关推荐
我命由我123455 小时前
Jetpack Compose - MaterialExpressiveTheme 与 MaterialTheme、ColorScheme
android·java·开发语言·java-ee·kotlin·android jetpack·android runtime
深海呐6 小时前
仓颉语言是ArkTs的上层语言吗?就像kotlin和Java
java·开发语言·kotlin·仓颉
YDS82910 小时前
大营销平台 —— 架构解析和抽奖流程串联
java·springboot·ddd
Kapaseker10 小时前
Boolean 变量到底该怎么命名?
android·kotlin
刘名喜11 小时前
第21篇-N+1问题与fetch-join-EntityGraph
kotlin·springboot
我命由我1234512 小时前
Kotlin 面向对象 - Kotlin 类变量与类方法
java·服务器·后端·java-ee·kotlin·android jetpack·android runtime
plainGeekDev12 小时前
Robolectric → 分层测试:测试策略重构
android·java·kotlin
plainGeekDev12 小时前
Instrumentation → Compose Testing
android·java·kotlin
刘名喜1 天前
第17篇-PostgreSQL-Docker环境搭建
kotlin·springboot