package com.mydreamjob.common.exception; import com.mydreamjob.common.api.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; 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 java.util.HashMap; import java.util.Map; /** * 全局异常处理器 */ @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @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) { // 权限 → 403 if (errorCode == ErrorCode.FORBIDDEN.getCode()) return HttpStatus.FORBIDDEN; // 面试访问/越权 → 403 if (errorCode == ErrorCode.INTERVIEW_ACCESS_DENIED.getCode() || errorCode == ErrorCode.APPLICATION_OWNER_MISMATCH.getCode()) return HttpStatus.FORBIDDEN; // 不存在 → 404 if (errorCode == ErrorCode.COMPANY_NOT_FOUND.getCode() || errorCode == ErrorCode.PROFILE_NOT_FOUND.getCode() || errorCode == ErrorCode.JOB_NOT_FOUND.getCode() || errorCode == ErrorCode.APPLICATION_NOT_FOUND.getCode() || errorCode == ErrorCode.OFFER_NOT_FOUND.getCode() || errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND.getCode() || errorCode == ErrorCode.RESUME_VERSION_NOT_FOUND.getCode() || errorCode == ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode() || errorCode == ErrorCode.USER_NOT_FOUND.getCode() || errorCode == ErrorCode.REVIEW_NOT_FOUND.getCode() || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode() || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.MESSAGE_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode() || errorCode == ErrorCode.REVIEW_REPLY_NOT_FOUND.getCode() || errorCode == ErrorCode.TALENT_INVITATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 冲突 → 409 if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() || errorCode == ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode() || errorCode == ErrorCode.INVALID_JOB_STATUS_TRANSITION.getCode() || errorCode == ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode() || errorCode == ErrorCode.DUPLICATE_REVIEW.getCode() || errorCode == ErrorCode.DUPLICATE_FEEDBACK.getCode() || errorCode == ErrorCode.DUPLICATE_OFFER.getCode() || errorCode == ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode() || errorCode == ErrorCode.OFFER_EXPIRED.getCode() || errorCode == ErrorCode.OFFER_NOT_EDITABLE.getCode() || errorCode == ErrorCode.OFFER_REMINDER_DUPLICATE.getCode() || errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXISTS.getCode() || errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode() || errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXPIRED.getCode() || errorCode == ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode() || errorCode == ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode() || errorCode == ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode() || errorCode == ErrorCode.RESUME_EXPORT_NOT_READY.getCode() || errorCode == ErrorCode.MATCH_CLOSED.getCode() || errorCode == ErrorCode.MATCH_SCORES_LOCKED.getCode() || errorCode == ErrorCode.APPLICATION_NOT_MATCHED.getCode() || errorCode == ErrorCode.REVIEW_ALREADY_DECIDED.getCode() // 面试冲突 → 409 || errorCode == ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode() || errorCode == ErrorCode.INTERVIEW_COMPLETE_NOT_READY.getCode() || errorCode == ErrorCode.INTERVIEW_ALREADY_RESPONDED.getCode() || errorCode == ErrorCode.REVIEW_REPLY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.REVIEW_REPLY_INVALID_STATE.getCode() || errorCode == ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT.getCode() || errorCode == ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED.getCode() || errorCode == ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE.getCode() || errorCode == ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE.getCode() || errorCode == ErrorCode.TALENT_INVITATION_INVALID_STATE.getCode() || errorCode == ErrorCode.TALENT_INVITATION_EXPIRED.getCode() || errorCode == ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; // 越权 → 403(新增:企业-求职者建会话需匹配) if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode()) return HttpStatus.FORBIDDEN; // Offer 比较越权(包含非本人 Offer) → 403 if (errorCode == ErrorCode.OFFER_COMPARE_NOT_OWNER.getCode()) return HttpStatus.FORBIDDEN; // 认证不存在 → 404 if (errorCode == ErrorCode.VERIFICATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 认证冲突 → 409 if (errorCode == ErrorCode.VERIFICATION_ALREADY_PENDING.getCode() || errorCode == ErrorCode.VERIFICATION_ALREADY_APPROVED.getCode() || errorCode == ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION.getCode()) return HttpStatus.CONFLICT; // 权重并发冲突 → 409 if (errorCode == ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode()) return HttpStatus.CONFLICT; // 隐私设置并发冲突 → 409 if (errorCode == ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE.getCode()) return HttpStatus.CONFLICT; // 账号注销状态冲突 → 409(9301-9306) if (errorCode == ErrorCode.INVALID_DELETION_STATE.getCode() || errorCode == ErrorCode.ACCOUNT_DELETION_PENDING.getCode() || errorCode == ErrorCode.ACCOUNT_DELETED.getCode() || errorCode == ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED.getCode() || errorCode == ErrorCode.LAST_ADMIN_CANNOT_DELETE.getCode()) return HttpStatus.CONFLICT; // 评分治理冲突 → 409 if (errorCode == ErrorCode.APPEAL_DUPLICATE.getCode() || errorCode == ErrorCode.APPEAL_ALREADY_DECIDED.getCode() || errorCode == ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED.getCode() || errorCode == ErrorCode.APPEAL_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; // 个人数据导出 → 409(9402-9406,除 9401 已映射 404) if (errorCode == ErrorCode.DATA_EXPORT_DUPLICATE.getCode() || errorCode == ErrorCode.DATA_EXPORT_NOT_READY.getCode() || errorCode == ErrorCode.DATA_EXPORT_EXPIRED.getCode() || errorCode == ErrorCode.DATA_EXPORT_FAILED.getCode() || errorCode == ErrorCode.DATA_EXPORT_FILE_NOT_FOUND.getCode()) return HttpStatus.CONFLICT; // 结构化面试评分 → 404(9504 评分不存在) if (errorCode == ErrorCode.INTERVIEW_SCORE_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 结构化面试评分 → 409(9501 并发 / 9502 未完成 / 9503 已提交) if (errorCode == ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.INTERVIEW_NOT_COMPLETED.getCode() || errorCode == ErrorCode.INTERVIEW_SCORE_SUBMITTED.getCode()) return HttpStatus.CONFLICT; // 举报与屏蔽:不存在 → 404 if (errorCode == ErrorCode.REPORT_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 举报与屏蔽:冲突/重复/非法流转 → 409 if (errorCode == ErrorCode.REPORT_DUPLICATE.getCode() || errorCode == ErrorCode.REPORT_ALREADY_DECIDED.getCode() || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) return HttpStatus.TOO_MANY_REQUESTS; // RIASEC 测评:不存在 → 404 if (errorCode == ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode() || errorCode == ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // RIASEC 测评:状态/并发/幂等冲突 → 409 if (errorCode == ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE.getCode() || errorCode == ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode() || errorCode == ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; // RIASEC 测评:答案/未完成 → 400 if (errorCode == ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode() || errorCode == ErrorCode.ASSESSMENT_INCOMPLETE.getCode()) return HttpStatus.BAD_REQUEST; // 四维能力测评:不存在 → 404 if (errorCode == ErrorCode.ABILITY_TYPE_NOT_FOUND.getCode() || errorCode == ErrorCode.ABILITY_ATTEMPT_NOT_FOUND.getCode() || errorCode == ErrorCode.ABILITY_RESULT_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 四维能力测评:状态/并发/幂等/冷却冲突 → 409 if (errorCode == ErrorCode.ABILITY_ATTEMPT_INVALID_STATE.getCode() || errorCode == ErrorCode.ABILITY_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.ABILITY_DUPLICATE_ACTIVE.getCode() || errorCode == ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT.getCode() || errorCode == ErrorCode.ABILITY_RETAKE_COOLDOWN.getCode()) return HttpStatus.CONFLICT; // 四维能力测评:答案/未完成 → 400 if (errorCode == ErrorCode.ABILITY_ANSWER_INVALID.getCode() || errorCode == ErrorCode.ABILITY_INCOMPLETE.getCode()) return HttpStatus.BAD_REQUEST; // 四维能力测评:重测次数限制 → 429 if (errorCode == ErrorCode.ABILITY_RETAKE_LIMIT.getCode()) return HttpStatus.TOO_MANY_REQUESTS; // Offer 进阶契约 → 400(不可撤销、不可还价、比较数量超限) if (errorCode == ErrorCode.OFFER_NOT_WITHDRAWABLE.getCode() || errorCode == ErrorCode.OFFER_NOT_NEGOTIABLE.getCode() || errorCode == ErrorCode.OFFER_COMPARE_TOO_MANY.getCode()) return HttpStatus.BAD_REQUEST; // 风险概览与运营规则配置:不存在 → 404 if (errorCode == ErrorCode.RISK_RULE_NOT_FOUND.getCode() || errorCode == ErrorCode.RISK_EVENT_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 风险概览与运营规则配置:并发/非法流转 → 409 if (errorCode == ErrorCode.RISK_RULE_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.RISK_EVENT_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.RISK_INVALID_ACTION.getCode()) return HttpStatus.CONFLICT; // 风险概览与运营规则配置:非法筛选 → 400(默认即 400,显式声明保证 9906) if (errorCode == ErrorCode.RISK_INVALID_FILTER.getCode()) return HttpStatus.BAD_REQUEST; if (errorCode == ErrorCode.TALENT_INVITATION_RATE_LIMITED.getCode()) return HttpStatus.TOO_MANY_REQUESTS; return HttpStatus.BAD_REQUEST; } @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(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 request.getHeader("X-Request-Id"); } }