轻松聘AI开发
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

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);
}
}