跳至主要内容

統一錯誤處理模組

Package: io.leandev.appfuse.error.*, io.leandev.appfuse.exception.*

AppFuse Server 提供統一的錯誤處理機制,將所有異常轉換為 RFC 7807 ProblemDetail 標準格式。

核心特色

1. RFC 7807 ProblemDetail 格式

RFC 7807 定義了 HTTP API 的標準錯誤回應格式:

{
"type": "urn:appfuse:error:not-found",
"title": "Not Found",
"status": 404,
"detail": "User with ID 123 does not exist",
"instance": "/api/users/123",
"violations": [],
"format": "User with ID ${0} does not exist",
"params": ["123"]
}
欄位說明
type問題類型 URI(識別錯誤種類)
titleHTTP 狀態描述
statusHTTP 狀態碼
detail問題詳細描述
instance發生問題的請求路徑
violations驗證錯誤詳情(僅驗證錯誤)
format訊息模板(用於國際化)
params訊息參數

2. 架構圖

3. 工具類別

類別用途
StandardRestExceptionHandler統一異常處理器基類
ExceptionMapper異常映射策略接口
ExceptionMappingRegistry異常映射器註冊中心
ProblemDetailFactoryProblemDetail 建立工廠
ApplicationException應用程式異常基類
Violation驗證違規記錄

基本用法

啟用統一錯誤處理

只需繼承 StandardRestExceptionHandler 並加上 @ControllerAdvice

import io.leandev.appfuse.error.StandardRestExceptionHandler;
import org.springframework.web.bind.annotation.ControllerAdvice;

@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {
}

這樣就完成了!所有異常都會自動轉換為 RFC 7807 格式。

預設處理的異常

StandardRestExceptionHandler 預設處理以下異常:

異常類型HTTP 狀態說明
MissingServletRequestParameterException400缺少必要參數
MissingPathVariableException400缺少路徑變數
MethodArgumentNotValidException400@Valid 驗證失敗
ValidationException400Bean Validation 失敗
HttpMessageConversionException400JSON 解析錯誤
TransactionSystemException400/500事務驗證失敗
DataAccessException500資料庫操作錯誤
ApplicationException依子類應用程式自定義異常

ApplicationException 異常體系

異常繼承結構

內建異常類型

異常HTTP 狀態用途
NotFoundException404資源不存在
ConflictException409資源衝突
DuplicateException409重複資料
InvalidDataException400無效資料
ConstraintException400驗證失敗(含詳細違規)
LockoutException401帳號鎖定
VerificationException400驗證碼錯誤

拋出異常

import io.leandev.appfuse.exception.*;

@Service
public class UserService {

public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User with ID ${0} does not exist", id));
}

public User create(UserRequest request) {
// 檢查重複
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateException("Email ${0} already exists", request.getEmail());
}

// 業務規則驗證
if (request.getAge() < 18) {
throw new InvalidDataException("User must be at least 18 years old");
}

return userRepository.save(toEntity(request));
}

public void updateStatus(Long id, String newStatus, String currentStatus) {
// 狀態衝突檢查
if (!isValidTransition(currentStatus, newStatus)) {
throw new ConflictException(
"Cannot change status from ${0} to ${1}",
currentStatus, newStatus
);
}
}
}

訊息格式化

ApplicationException 支援兩種格式化方式:

// 位置參數格式(推薦)
throw new NotFoundException("User ${0} in department ${1} not found", userId, deptId);
// 結果:User 123 in department IT not found

// 傳統 String.format 格式
throw new NotFoundException("User %s not found", userId);
// 結果:User 123 not found

Violation 驗證違規

基本用法

import io.leandev.appfuse.exception.Violation;
import io.leandev.appfuse.exception.ConstraintException;

// 單一欄位違規
Violation violation = new Violation("email", "Invalid email format");

// 帶參數的違規(符合 Bean Validation 標準)
Map<String, Object> params = Map.of("min", 8, "max", 20);
Violation violation = new Violation(
"password",
"Password length must be between ${min} and ${max}",
params
);

// 巢狀屬性違規
Violation violation = new Violation(
new String[]{"address", "zipCode"},
"Invalid zip code format",
Map.of()
);

// 拋出帶違規的異常
Set<Violation> violations = Set.of(
new Violation("email", "Invalid email format"),
new Violation("age", "Must be at least ${value}", Map.of("value", 18))
);
throw new ConstraintException("Validation failed", violations);

API 回應範例

{
"type": "urn:appfuse:error:validation-error",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/users",
"violations": [
{
"props": ["email"],
"message": "Invalid email format",
"params": {}
},
{
"props": ["age"],
"message": "Must be at least ${value}",
"params": {"value": 18}
}
]
}

ProblemDetailFactory

建立常用錯誤

import io.leandev.appfuse.error.ProblemDetailFactory;

// 400 Bad Request
ProblemDetail problem = ProblemDetailFactory.badRequest("Invalid input");

// 401 Unauthorized
ProblemDetail problem = ProblemDetailFactory.unauthorized("Authentication required");

// 403 Forbidden
ProblemDetail problem = ProblemDetailFactory.forbidden("Access denied");

// 404 Not Found
ProblemDetail problem = ProblemDetailFactory.notFound("Resource not found");

// 409 Conflict
ProblemDetail problem = ProblemDetailFactory.conflict("Resource already exists");

// 422 Unprocessable Entity(業務規則違反)
ProblemDetail problem = ProblemDetailFactory.unprocessableEntity("Business rule violated");

// 500 Internal Server Error
ProblemDetail problem = ProblemDetailFactory.internalError("Unexpected error");

認證相關錯誤

// 帳號鎖定
ProblemDetail problem = ProblemDetailFactory.accountLocked("Account locked due to too many failed attempts");

// 帳號停用
ProblemDetail problem = ProblemDetailFactory.accountDisabled("Account has been disabled");

// 帳號過期
ProblemDetail problem = ProblemDetailFactory.accountExpired("Account has expired");

// 憑證過期
ProblemDetail problem = ProblemDetailFactory.credentialsExpired("Password has expired");

// 無效憑證
ProblemDetail problem = ProblemDetailFactory.badCredentials("Invalid username or password");

驗證錯誤

Set<Violation> violations = Set.of(
new Violation("email", "Invalid email format"),
new Violation("password", "Password too weak")
);

ProblemDetail problem = ProblemDetailFactory.validationError(
"Validation failed. Please check your input.",
violations
);

擴充屬性

ProblemDetail problem = ProblemDetailFactory.notFound("User not found");

// 加入錯誤代碼
ProblemDetailFactory.withErrorCode(problem, "USER_NOT_FOUND");

// 加入國際化參數
ProblemDetailFactory.withI18n(problem, "error.user.notFound", new Object[]{"123"});

自定義 ExceptionMapper

實作 ExceptionMapper 接口

import io.leandev.appfuse.error.ExceptionMapper;
import io.leandev.appfuse.error.ProblemDetailFactory;
import org.springframework.http.ProblemDetail;

import java.net.URI;

public class PaymentExceptionMapper implements ExceptionMapper<PaymentException> {

@Override
public boolean supports(Exception exception) {
return exception instanceof PaymentException;
}

@Override
public ProblemDetail map(PaymentException ex) {
ProblemDetail problem;

if (ex instanceof InsufficientFundsException) {
problem = ProblemDetailFactory.unprocessableEntity(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:insufficient-funds"));
problem.setTitle("Insufficient Funds");
} else if (ex instanceof PaymentDeclinedException) {
problem = ProblemDetailFactory.unprocessableEntity(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:payment-declined"));
problem.setTitle("Payment Declined");
} else {
problem = ProblemDetailFactory.internalError(ex.getMessage());
}

// 加入自訂屬性
problem.setProperty("transactionId", ex.getTransactionId());

return problem;
}
}

註冊自定義 Mapper

@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {

static {
// 在靜態初始化區塊註冊自定義 Mapper
getRegistry().register(new PaymentExceptionMapper());
getRegistry().register(new ThirdPartyServiceExceptionMapper());
}
}

Mapper 優先順序

ExceptionMappingRegistry 按註冊順序匹配,先註冊的優先:

// 預設順序(越具體越優先)
1. LockoutExceptionMapper // 登入鎖定(特殊處理)
2. ApplicationExceptionMapper // AppFuse 自定義異常
3. ValidationExceptionMapper // Bean Validation
4. TransactionExceptionMapper // 事務異常
5. DataAccessExceptionMapper // 資料庫異常
6. HttpMessageConversionExceptionMapper // HTTP 訊息轉換

自定義 Mapper 會加到列表末尾,如需優先處理特定異常,確保 supports() 方法精確匹配。

Spring Boot 整合

配置類別

@Configuration
public class ErrorHandlingConfig {

@Bean
@ControllerAdvice
public StandardRestExceptionHandler exceptionHandler() {
return new StandardRestExceptionHandler() {
// 可覆寫方法自定義行為
};
}
}

自定義日誌等級

@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {

@Override
protected ResponseEntity<Object> handleProblemDetail(
ProblemDetail problem,
Throwable cause,
WebRequest request) {

// 自定義日誌邏輯
if (cause instanceof SecurityException) {
log.warn("Security violation: {}", problem.getDetail(), cause);
}

return super.handleProblemDetail(problem, cause, request);
}
}

與 Security 整合

@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException ex) throws IOException {

ProblemDetail problem = ProblemDetailFactory.unauthorized(ex.getMessage());
problem.setInstance(URI.create(request.getRequestURI()));

response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);

ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), problem);
}
}

完整範例

訂單服務錯誤處理

// 自定義異常
public class OrderException extends ApplicationException {
public OrderException(String message, Object... params) {
super(message, params);
}
}

public class OrderNotFoundException extends OrderException {
public OrderNotFoundException(Long orderId) {
super("Order ${0} not found", orderId);
}
}

public class OrderCancelledException extends OrderException {
public OrderCancelledException(Long orderId) {
super("Order ${0} has already been cancelled", orderId);
}
}

// 自定義 Mapper
public class OrderExceptionMapper implements ExceptionMapper<OrderException> {

@Override
public boolean supports(Exception exception) {
return exception instanceof OrderException;
}

@Override
public ProblemDetail map(OrderException ex) {
ProblemDetail problem;

if (ex instanceof OrderNotFoundException) {
problem = ProblemDetailFactory.notFound(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:order-not-found"));
} else if (ex instanceof OrderCancelledException) {
problem = ProblemDetailFactory.conflict(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:order-cancelled"));
} else {
problem = ProblemDetailFactory.internalError(ex.getMessage());
}

ProblemDetailFactory.withI18n(problem, ex.getFormat(), ex.getParams());
return problem;
}
}

// 服務層
@Service
public class OrderService {

public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}

public void cancel(Long id) {
Order order = findById(id);

if (order.isCancelled()) {
throw new OrderCancelledException(id);
}

order.cancel();
orderRepository.save(order);
}
}

// 全域異常處理器
@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {

static {
getRegistry().register(new OrderExceptionMapper());
}
}

前端錯誤處理

interface ProblemDetail {
type: string;
title: string;
status: number;
detail: string;
instance: string;
violations?: Violation[];
format?: string;
params?: any[];
}

interface Violation {
props: string[];
message: string;
params: Record<string, any>;
}

// API 錯誤處理
async function handleApiError(response: Response): Promise<never> {
const problem: ProblemDetail = await response.json();

if (problem.violations?.length) {
// 驗證錯誤:顯示欄位錯誤
const fieldErrors = problem.violations.reduce((acc, v) => {
const field = v.props.join('.');
acc[field] = formatMessage(v.message, v.params);
return acc;
}, {} as Record<string, string>);

throw new ValidationError(problem.detail, fieldErrors);
}

// 其他錯誤
throw new ApiError(problem.status, problem.detail, problem.type);
}

function formatMessage(template: string, params: Record<string, any>): string {
return Object.entries(params).reduce(
(msg, [key, value]) => msg.replace(`\${${key}}`, String(value)),
template
);
}

最佳實踐

1. 使用語義化異常

// 推薦:使用語義化異常
throw new NotFoundException("User ${0} not found", userId);
throw new ConflictException("Order ${0} already confirmed", orderId);

// 避免:使用通用異常
throw new RuntimeException("User not found");
throw new IllegalStateException("Order already confirmed");

2. 提供國際化支援

// 保留 format 和 params,讓前端可以國際化
throw new NotFoundException("error.user.notFound", userId);
// 前端根據 format 查詢翻譯:「找不到使用者 {0}」

3. 驗證錯誤提供詳細資訊

// 推薦:提供詳細的驗證違規
Set<Violation> violations = new HashSet<>();
if (StringUtils.isBlank(request.getEmail())) {
violations.add(new Violation("email", "Email is required"));
}
if (request.getAge() < 18) {
violations.add(new Violation("age", "Must be at least ${value}", Map.of("value", 18)));
}
if (!violations.isEmpty()) {
throw new ConstraintException("Validation failed", violations);
}

4. 區分錯誤等級

// 4xx 錯誤:用戶可修正
throw new NotFoundException(...); // 404 - 資源不存在
throw new InvalidDataException(...); // 400 - 輸入錯誤
throw new ConflictException(...); // 409 - 狀態衝突

// 5xx 錯誤:系統問題
throw new ApplicationException(...); // 500 - 內部錯誤

5. 安全性考量

// 生產環境不暴露敏感資訊
if (ex instanceof DataAccessException) {
// 不要返回 SQL 錯誤詳情
return ProblemDetailFactory.internalError("Database error occurred");
}

// 認證錯誤使用模糊訊息
return ProblemDetailFactory.badCredentials("Invalid username or password");
// 不要說「使用者不存在」或「密碼錯誤」

下一步