forked from baskbuddy/zhangshun
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
232 lines
13 KiB
232 lines
13 KiB
package com.mydreamjob.common.exception;
|
|
|
|
import com.mydreamjob.common.api.ApiResponse;
|
|
import com.mydreamjob.common.logging.RequestIdAccessor;
|
|
import jakarta.servlet.http.HttpServletRequest;
|
|
import jakarta.validation.ConstraintViolationException;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.http.ResponseEntity;
|
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
|
import org.springframework.security.access.AccessDeniedException;
|
|
import org.springframework.security.authentication.BadCredentialsException;
|
|
import org.springframework.security.core.AuthenticationException;
|
|
import org.springframework.validation.FieldError;
|
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
|
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.resource.NoResourceFoundException;
|
|
|
|
import java.util.HashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* 全局异常处理器
|
|
*/
|
|
@Slf4j
|
|
@RestControllerAdvice
|
|
@RequiredArgsConstructor
|
|
public class GlobalExceptionHandler {
|
|
|
|
private static final Map<Integer, HttpStatus> BUSINESS_HTTP_STATUSES = buildBusinessHttpStatuses();
|
|
|
|
private final RequestIdAccessor requestIdAccessor;
|
|
|
|
@ExceptionHandler(ConstraintViolationException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
|
|
ConstraintViolationException ex, HttpServletRequest request) {
|
|
log.warn("Constraint violation: path={}, message={}", request.getRequestURI(), ex.getMessage());
|
|
return ResponseEntity
|
|
.status(HttpStatus.BAD_REQUEST)
|
|
.body(ApiResponse.badRequest(ex.getMessage(), getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(BusinessException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleBusinessException(
|
|
BusinessException e, HttpServletRequest request) {
|
|
int errorCode = e.getErrorCode();
|
|
log.warn("BusinessException: code={}, message={}, path={}",
|
|
errorCode, e.getMessage(), request.getRequestURI());
|
|
HttpStatus status = resolveHttpStatus(errorCode);
|
|
return ResponseEntity
|
|
.status(status)
|
|
.body(ApiResponse.error(errorCode, e.getMessage(), getRequestId(request)));
|
|
}
|
|
|
|
/**
|
|
* 按业务错误码段映射 HTTP 状态码(与 API v0.2 草案一致):
|
|
* - FORBIDDEN(2002) → 403
|
|
* - 不存在类(5001/6001/7001/8002/4001/9102) → 404
|
|
* - 冲突/非法流转类(5003/7003/8001/8003/9101) → 409
|
|
* - 其余 → 400
|
|
*/
|
|
private HttpStatus resolveHttpStatus(int errorCode) {
|
|
return BUSINESS_HTTP_STATUSES.getOrDefault(errorCode, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
private static Map<Integer, HttpStatus> buildBusinessHttpStatuses() {
|
|
Map<Integer, HttpStatus> statuses = new LinkedHashMap<>();
|
|
register(statuses, HttpStatus.FORBIDDEN,
|
|
ErrorCode.FORBIDDEN, ErrorCode.INTERVIEW_ACCESS_DENIED,
|
|
ErrorCode.APPLICATION_OWNER_MISMATCH, ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
|
|
ErrorCode.OFFER_COMPARE_NOT_OWNER);
|
|
register(statuses, HttpStatus.NOT_FOUND,
|
|
ErrorCode.COMPANY_NOT_FOUND, ErrorCode.PROFILE_NOT_FOUND, ErrorCode.JOB_NOT_FOUND,
|
|
ErrorCode.APPLICATION_NOT_FOUND, ErrorCode.OFFER_NOT_FOUND,
|
|
ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND, ErrorCode.RESUME_VERSION_NOT_FOUND,
|
|
ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND, ErrorCode.USER_NOT_FOUND,
|
|
ErrorCode.REVIEW_NOT_FOUND, ErrorCode.FEEDBACK_NOT_FOUND,
|
|
ErrorCode.CONVERSATION_NOT_FOUND, ErrorCode.MESSAGE_NOT_FOUND,
|
|
ErrorCode.APPEAL_NOT_FOUND, ErrorCode.DATA_EXPORT_TASK_NOT_FOUND,
|
|
ErrorCode.REVIEW_REPLY_NOT_FOUND, ErrorCode.TALENT_INVITATION_NOT_FOUND,
|
|
ErrorCode.VERIFICATION_NOT_FOUND, ErrorCode.INTERVIEW_SCORE_NOT_FOUND,
|
|
ErrorCode.REPORT_NOT_FOUND, ErrorCode.ASSESSMENT_TYPE_NOT_FOUND,
|
|
ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND, ErrorCode.ABILITY_TYPE_NOT_FOUND,
|
|
ErrorCode.ABILITY_ATTEMPT_NOT_FOUND, ErrorCode.ABILITY_RESULT_NOT_FOUND,
|
|
ErrorCode.RISK_RULE_NOT_FOUND, ErrorCode.RISK_EVENT_NOT_FOUND);
|
|
register(statuses, HttpStatus.CONFLICT,
|
|
ErrorCode.COMPANY_ALREADY_EXISTS, ErrorCode.APPLICATION_DUPLICATE,
|
|
ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION, ErrorCode.INVALID_JOB_STATUS_TRANSITION,
|
|
ErrorCode.DUPLICATE_REVIEW, ErrorCode.DUPLICATE_FEEDBACK, ErrorCode.DUPLICATE_OFFER,
|
|
ErrorCode.INVALID_OFFER_STATUS_TRANSITION, ErrorCode.OFFER_EXPIRED,
|
|
ErrorCode.OFFER_NOT_EDITABLE, ErrorCode.OFFER_REMINDER_DUPLICATE,
|
|
ErrorCode.OFFER_COUNTER_OFFER_EXISTS, ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING,
|
|
ErrorCode.OFFER_COUNTER_OFFER_EXPIRED, ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED,
|
|
ErrorCode.RESUME_EXPORT_IN_PROGRESS, ErrorCode.DUPLICATE_DEFAULT_RESUME,
|
|
ErrorCode.RESUME_EXPORT_NOT_READY, ErrorCode.MATCH_CLOSED,
|
|
ErrorCode.MATCH_SCORES_LOCKED, ErrorCode.APPLICATION_NOT_MATCHED,
|
|
ErrorCode.REVIEW_ALREADY_DECIDED, ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION,
|
|
ErrorCode.INTERVIEW_COMPLETE_NOT_READY, ErrorCode.INTERVIEW_ALREADY_RESPONDED,
|
|
ErrorCode.REVIEW_REPLY_ALREADY_EXISTS, ErrorCode.REVIEW_REPLY_INVALID_STATE,
|
|
ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE, ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT,
|
|
ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED, ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE,
|
|
ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE,
|
|
ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE, ErrorCode.TALENT_INVITATION_INVALID_STATE,
|
|
ErrorCode.TALENT_INVITATION_EXPIRED, ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE,
|
|
ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT,
|
|
ErrorCode.VERIFICATION_ALREADY_PENDING, ErrorCode.VERIFICATION_ALREADY_APPROVED,
|
|
ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION,
|
|
ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE, ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE,
|
|
ErrorCode.INVALID_DELETION_STATE, ErrorCode.ACCOUNT_DELETION_PENDING,
|
|
ErrorCode.ACCOUNT_DELETED, ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE,
|
|
ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED, ErrorCode.LAST_ADMIN_CANNOT_DELETE,
|
|
ErrorCode.APPEAL_DUPLICATE, ErrorCode.APPEAL_ALREADY_DECIDED,
|
|
ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED, ErrorCode.APPEAL_INVALID_TRANSITION,
|
|
ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT, ErrorCode.DATA_EXPORT_DUPLICATE,
|
|
ErrorCode.DATA_EXPORT_NOT_READY, ErrorCode.DATA_EXPORT_EXPIRED,
|
|
ErrorCode.DATA_EXPORT_FAILED, ErrorCode.DATA_EXPORT_FILE_NOT_FOUND,
|
|
ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE, ErrorCode.INTERVIEW_NOT_COMPLETED,
|
|
ErrorCode.INTERVIEW_SCORE_SUBMITTED, ErrorCode.REPORT_DUPLICATE,
|
|
ErrorCode.REPORT_ALREADY_DECIDED, ErrorCode.REPORT_INVALID_TRANSITION,
|
|
ErrorCode.REPORT_IDEMPOTENCY_CONFLICT, ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE,
|
|
ErrorCode.ASSESSMENT_CONCURRENT_UPDATE, ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE,
|
|
ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
|
|
ErrorCode.ABILITY_CONCURRENT_UPDATE, ErrorCode.ABILITY_DUPLICATE_ACTIVE,
|
|
ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT, ErrorCode.ABILITY_RETAKE_COOLDOWN,
|
|
ErrorCode.RISK_RULE_CONCURRENT_UPDATE, ErrorCode.RISK_EVENT_CONCURRENT_UPDATE,
|
|
ErrorCode.RISK_INVALID_ACTION);
|
|
register(statuses, HttpStatus.TOO_MANY_REQUESTS,
|
|
ErrorCode.REPORT_RATE_LIMITED, ErrorCode.ABILITY_RETAKE_LIMIT,
|
|
ErrorCode.TALENT_INVITATION_RATE_LIMITED);
|
|
return Map.copyOf(statuses);
|
|
}
|
|
|
|
private static void register(Map<Integer, HttpStatus> statuses, HttpStatus status,
|
|
ErrorCode... errorCodes) {
|
|
for (ErrorCode errorCode : errorCodes) {
|
|
HttpStatus previous = statuses.putIfAbsent(errorCode.getCode(), status);
|
|
if (previous != null && previous != status) {
|
|
throw new IllegalStateException("conflicting HTTP status mapping for error code "
|
|
+ errorCode.getCode());
|
|
}
|
|
}
|
|
}
|
|
|
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleValidationException(
|
|
MethodArgumentNotValidException ex, HttpServletRequest request) {
|
|
Map<String, String> errors = new HashMap<>();
|
|
ex.getBindingResult().getAllErrors().forEach((error) -> {
|
|
String fieldName = ((FieldError) error).getField();
|
|
String errorMessage = error.getDefaultMessage();
|
|
errors.put(fieldName, errorMessage);
|
|
});
|
|
log.warn("Validation failed: path={}, errors={}", request.getRequestURI(), errors);
|
|
return ResponseEntity
|
|
.status(HttpStatus.BAD_REQUEST)
|
|
.body(ApiResponse.badRequest("validation failed: " + errors, getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleMessageNotReadable(
|
|
HttpMessageNotReadableException ex, HttpServletRequest request) {
|
|
log.warn("Message not readable: path={}", request.getRequestURI(), ex);
|
|
return ResponseEntity
|
|
.status(HttpStatus.BAD_REQUEST)
|
|
.body(ApiResponse.badRequest("request body is invalid", getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(MissingServletRequestParameterException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleMissingParam(
|
|
MissingServletRequestParameterException ex, HttpServletRequest request) {
|
|
log.warn("Missing parameter: {} path={}", ex.getParameterName(), request.getRequestURI());
|
|
return ResponseEntity
|
|
.status(HttpStatus.BAD_REQUEST)
|
|
.body(ApiResponse.badRequest("missing parameter: " + ex.getParameterName(), getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(
|
|
MethodArgumentTypeMismatchException ex, HttpServletRequest request) {
|
|
log.warn("Type mismatch: {} path={}", ex.getName(), request.getRequestURI());
|
|
return ResponseEntity
|
|
.status(HttpStatus.BAD_REQUEST)
|
|
.body(ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), "bad request", getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(AuthenticationException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleAuthentication(
|
|
AuthenticationException ex, HttpServletRequest request) {
|
|
log.warn("Authentication failed: path={}, message={}", request.getRequestURI(), ex.getMessage());
|
|
return ResponseEntity
|
|
.status(HttpStatus.UNAUTHORIZED)
|
|
.body(ApiResponse.unauthorized(ex.getMessage(), getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(AccessDeniedException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(
|
|
AccessDeniedException ex, HttpServletRequest request) {
|
|
log.warn("Access denied: path={}", request.getRequestURI());
|
|
return ResponseEntity
|
|
.status(HttpStatus.FORBIDDEN)
|
|
.body(ApiResponse.forbidden("access denied", getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(NoResourceFoundException.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
|
|
NoResourceFoundException ex, HttpServletRequest request) {
|
|
// S03-C 整改:业务端口 /actuator/** 等不存在资源统一返回 404。
|
|
// 只记录固定文案与归一化 requestId,不回显异常 message、资源路径或堆栈。
|
|
log.warn("No resource found: requestId={}", getRequestId(request));
|
|
return ResponseEntity
|
|
.status(HttpStatus.NOT_FOUND)
|
|
.body(ApiResponse.error(404, "not found", getRequestId(request)));
|
|
}
|
|
|
|
@ExceptionHandler(Exception.class)
|
|
public ResponseEntity<ApiResponse<Void>> handleGenericException(
|
|
Exception ex, HttpServletRequest request) {
|
|
log.error("Unexpected error: path={}", request.getRequestURI(), ex);
|
|
return ResponseEntity
|
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
.body(ApiResponse.internalError(getRequestId(request)));
|
|
}
|
|
|
|
private String getRequestId(HttpServletRequest request) {
|
|
return requestIdAccessor.get(request);
|
|
}
|
|
}
|
|
|