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.
311 lines
18 KiB
311 lines
18 KiB
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<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) {
|
|
// 权限 → 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<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(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 request.getHeader("X-Request-Id");
|
|
}
|
|
}
|
|
|