Browse Source

refactor: centralize business error HTTP status mapping

master
Zhang Shun 1 month ago
parent
commit
15bd98bd35
  1. 2
      docs/backend-development-roadmap.md
  2. 19
      docs/r01-b-error-status-mapping-instruction.md
  3. 46
      docs/r01-b-error-status-mapping-report.md
  4. 260
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  5. 108
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 |
| R01 结构与性能收束 | 已完成基线审计和权威设计,建立独立分支 `refactor/r01-structure-performance`;确认身份解析 41 个文件、requestId 私有方法 46 个文件及 O(n²)/N+1 热点 | 按 R01-A 至 R01-E 分批实施;第一批只迁移 8 个 Controller 的身份和 requestId,不与异常映射、评分或 SQL 优化混改 |
| R01 结构与性能收束 | R01-A 已提交本地检查点 `0b5b849`;R01-B 已完成异常状态映射收束和自动化验证,待独立复验 | HTTP 映射保持整数码契约与未知码 400 兜底;后续 R01-C/D/E 仍须独立实施、复验和提交 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

19
docs/r01-b-error-status-mapping-instruction.md

@ -0,0 +1,19 @@
# R01-B 业务异常 HTTP 状态映射收束实施指令
## 1. 目标
在不改变任何对外行为的前提下,将 `GlobalExceptionHandler.resolveHttpStatus` 的长 `if` 链收束为集中、不可变、可完整验证的整数业务码映射。
## 2. 硬边界
- 不修改 `ErrorCode` 的常量、数值或文案;
- 不新增接口、Flyway、表、索引、依赖;
- 不改变 HTTP 状态、响应业务码、message、requestId 或兜底语义;
- 必须继续按整数业务码解析,因为历史常量可能共享数值;
- 同一整数码若被登记为不同 HTTP 状态,应用初始化必须失败,不能静默覆盖;
- 未显式登记的整数码继续返回 HTTP 400;
- 不进入 R01-C/D/E,不提交、不推送。
## 3. 测试门槛
先建立全量特征测试,对 `ErrorCode.values()` 每个常量逐一断言 HTTP 状态、业务码、message、requestId,并单独验证未知整数码回退 400。随后运行异常关联测试、`mvn clean verify` 与静态 OpenAPI 校验,Failures/Errors/Skipped 必须全部为 0。

46
docs/r01-b-error-status-mapping-report.md

@ -0,0 +1,46 @@
# R01-B 业务异常 HTTP 状态映射收束实施报告
> 状态:R01-B 已完成实现和自动化验证,待独立代码复验。
## 1. 基线与范围
- 分支:`refactor/r01-structure-performance`
- 起点:`0b5b849 refactor: centralize request identity and request ID handling`
- 仅修改异常 HTTP 状态解析和对应特征测试;未进入其他 R01 子阶段。
- 开始前暂存区和已跟踪工作区为空;历史未跟踪产物完整保留。
## 2. 实现
`GlobalExceptionHandler` 使用不可变 `Map<Integer, HttpStatus>` 集中登记 403、404、409、429;未登记业务码继续回退 400。登记辅助方法使用 `putIfAbsent`,同一整数业务码若出现不同状态会抛固定初始化异常,避免静默覆盖。
映射键保持为整数而非 `ErrorCode`:仓库存在历史共享数值常量,生产处理入口收到的也是整数业务码。此次未修改或“修正”任何历史常量。
## 3. 特征测试
新增 `GlobalExceptionHandlerStatusContractTest`:
- 对全部现有 `ErrorCode` 常量逐项调用正式 `handleBusinessException`;
- 独立快照四类显式状态集合,其余断言为 400;
- 同时断言业务码、message、requestId 原样保持;
- 通过反射只读调用私有解析器,证明未知整数码仍回退 400,未为测试扩大生产 API。
第一次定向编译曾因测试错误地调用不存在的 `BusinessException(int, String)` 构造器失败;整改为反射验证私有兜底后通过。未修改生产 API,也未删除或放宽断言。
## 4. 量化与验证
| 项目 | 结果 |
|---|---|
| `GlobalExceptionHandler` 行数 | 328 → 232 |
| `errorCode == ErrorCode...` 比较 | 121 → 0 |
| 异常定向/关联集合 | 207/207,0/0/0 |
| `mvn clean verify` | 2082/2082,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS |
| OpenAPI | 176 paths / 214 operations,All checks passed |
## 5. 边界
- `ErrorCode` 未修改;
- V1–V43 未修改,无新增迁移;
- 无新增公网接口、依赖、表或索引;
- 未操作 Docker;
- 未暂存、未提交、未推送;
- 未删除历史未跟踪文件。

260
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -21,6 +21,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@ -31,6 +32,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private static final Map<Integer, HttpStatus> BUSINESS_HTTP_STATUSES = buildBusinessHttpStatuses();
private final RequestIdAccessor requestIdAccessor;
@ExceptionHandler(ConstraintViolationException.class)
@ -62,184 +65,85 @@ public class GlobalExceptionHandler {
* - 其余 → 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;
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)

108
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

@ -0,0 +1,108 @@
package com.mydreamjob.common.exception;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.logging.RequestIdAccessor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import java.lang.reflect.Method;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/** Locks the pre-R01-B public HTTP contract for every existing business error code. */
class GlobalExceptionHandlerStatusContractTest {
private static final Set<String> FORBIDDEN = Set.of(
"FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH",
"MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER");
private static final Set<String> NOT_FOUND = Set.of(
"COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND",
"OFFER_NOT_FOUND", "OFFER_COUNTER_OFFER_NOT_FOUND", "RESUME_VERSION_NOT_FOUND",
"RESUME_EXPORT_TASK_NOT_FOUND", "USER_NOT_FOUND", "REVIEW_NOT_FOUND",
"FEEDBACK_NOT_FOUND", "CONVERSATION_NOT_FOUND", "MESSAGE_NOT_FOUND",
"APPEAL_NOT_FOUND", "DATA_EXPORT_TASK_NOT_FOUND", "REVIEW_REPLY_NOT_FOUND",
"TALENT_INVITATION_NOT_FOUND", "VERIFICATION_NOT_FOUND", "INTERVIEW_SCORE_NOT_FOUND",
"REPORT_NOT_FOUND", "ASSESSMENT_TYPE_NOT_FOUND", "ASSESSMENT_ATTEMPT_NOT_FOUND",
"ABILITY_TYPE_NOT_FOUND", "ABILITY_ATTEMPT_NOT_FOUND", "ABILITY_RESULT_NOT_FOUND",
"RISK_RULE_NOT_FOUND", "RISK_EVENT_NOT_FOUND");
private static final Set<String> CONFLICT = Set.of(
"COMPANY_ALREADY_EXISTS", "APPLICATION_DUPLICATE", "INVALID_APPLICATION_STATUS_TRANSITION",
"INVALID_JOB_STATUS_TRANSITION", "DUPLICATE_REVIEW", "DUPLICATE_FEEDBACK",
"DUPLICATE_OFFER", "INVALID_OFFER_STATUS_TRANSITION", "OFFER_EXPIRED",
"OFFER_NOT_EDITABLE", "OFFER_REMINDER_DUPLICATE", "OFFER_COUNTER_OFFER_EXISTS",
"OFFER_COUNTER_OFFER_NOT_PENDING", "OFFER_COUNTER_OFFER_EXPIRED",
"DEFAULT_RESUME_DELETE_NOT_ALLOWED", "RESUME_EXPORT_IN_PROGRESS",
"DUPLICATE_DEFAULT_RESUME", "RESUME_EXPORT_NOT_READY", "MATCH_CLOSED",
"MATCH_SCORES_LOCKED", "APPLICATION_NOT_MATCHED", "REVIEW_ALREADY_DECIDED",
"INVALID_INTERVIEW_STATUS_TRANSITION", "INTERVIEW_COMPLETE_NOT_READY",
"INTERVIEW_ALREADY_RESPONDED", "REVIEW_REPLY_ALREADY_EXISTS",
"REVIEW_REPLY_INVALID_STATE", "REVIEW_REPLY_CONCURRENT_UPDATE",
"REVIEW_REPLY_IDEMPOTENCY_CONFLICT", "MESSAGE_RECALL_WINDOW_EXPIRED",
"MESSAGE_RECALL_CONCURRENT_UPDATE", "TALENT_INVITATION_CANDIDATE_UNAVAILABLE",
"TALENT_INVITATION_ALREADY_ACTIVE", "TALENT_INVITATION_INVALID_STATE",
"TALENT_INVITATION_EXPIRED", "TALENT_INVITATION_CONCURRENT_UPDATE",
"TALENT_INVITATION_IDEMPOTENCY_CONFLICT", "VERIFICATION_ALREADY_PENDING",
"VERIFICATION_ALREADY_APPROVED", "INVALID_VERIFICATION_STATUS_TRANSITION",
"REVIEW_WEIGHT_CONCURRENT_UPDATE", "PRIVACY_SETTINGS_CONCURRENT_UPDATE",
"INVALID_DELETION_STATE", "ACCOUNT_DELETION_PENDING", "ACCOUNT_DELETED",
"ACCOUNT_DELETION_CONCURRENT_UPDATE", "ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED",
"LAST_ADMIN_CANNOT_DELETE", "APPEAL_DUPLICATE", "APPEAL_ALREADY_DECIDED",
"APPEAL_ROLLBACK_LIMIT_EXCEEDED", "APPEAL_INVALID_TRANSITION",
"APPEAL_IDEMPOTENCY_CONFLICT", "DATA_EXPORT_DUPLICATE", "DATA_EXPORT_NOT_READY",
"DATA_EXPORT_EXPIRED", "DATA_EXPORT_FAILED", "DATA_EXPORT_FILE_NOT_FOUND",
"INTERVIEW_SCORE_CONCURRENT_UPDATE", "INTERVIEW_NOT_COMPLETED",
"INTERVIEW_SCORE_SUBMITTED", "REPORT_DUPLICATE", "REPORT_ALREADY_DECIDED",
"REPORT_INVALID_TRANSITION", "REPORT_IDEMPOTENCY_CONFLICT",
"ASSESSMENT_ATTEMPT_INVALID_STATE", "ASSESSMENT_CONCURRENT_UPDATE",
"ASSESSMENT_DUPLICATE_ACTIVE", "ASSESSMENT_IDEMPOTENCY_CONFLICT",
"ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE",
"ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN",
"RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION");
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED");
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor());
@ParameterizedTest(name = "{0}")
@EnumSource(ErrorCode.class)
void everyExistingErrorCodeKeepsItsHttpStatusAndResponseFields(ErrorCode errorCode) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/contract");
request.setAttribute("requestId", "r01b-contract");
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(errorCode), request);
assertEquals(expectedStatus(errorCode), response.getStatusCode(), errorCode.name());
assertNotNull(response.getBody());
assertEquals(errorCode.getCode(), response.getBody().getCode());
assertEquals(errorCode.getMessage(), response.getBody().getMessage());
assertEquals("r01b-contract", response.getBody().getRequestId());
}
@Test
void unknownBusinessCodeKeepsBadRequestFallback() throws Exception {
Method resolver = GlobalExceptionHandler.class.getDeclaredMethod("resolveHttpStatus", int.class);
resolver.setAccessible(true);
assertEquals(HttpStatus.BAD_REQUEST, resolver.invoke(handler, 123456789));
}
private HttpStatus expectedStatus(ErrorCode errorCode) {
String name = errorCode.name();
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
return HttpStatus.BAD_REQUEST;
}
}
Loading…
Cancel
Save