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 BUSINESS_HTTP_STATUSES = buildBusinessHttpStatuses(); private final RequestIdAccessor requestIdAccessor; @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity> 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> 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 buildBusinessHttpStatuses() { Map 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 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> handleValidationException( MethodArgumentNotValidException ex, HttpServletRequest request) { Map 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> 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> 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> 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> 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> 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> 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> 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); } }