From 15bd98bd35f14b83e0b2bf95ccb5ed1c3f8d2b07 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Tue, 15 Sep 2026 17:02:02 +0800 Subject: [PATCH] refactor: centralize business error HTTP status mapping --- docs/backend-development-roadmap.md | 2 +- .../r01-b-error-status-mapping-instruction.md | 19 ++ docs/r01-b-error-status-mapping-report.md | 46 ++++ .../exception/GlobalExceptionHandler.java | 260 ++++++------------ ...balExceptionHandlerStatusContractTest.java | 108 ++++++++ 5 files changed, 256 insertions(+), 179 deletions(-) create mode 100644 docs/r01-b-error-status-mapping-instruction.md create mode 100644 docs/r01-b-error-status-mapping-report.md create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index bbcb93d..78d0e91 100644 --- a/docs/backend-development-roadmap.md +++ b/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节为准,移出一期 | diff --git a/docs/r01-b-error-status-mapping-instruction.md b/docs/r01-b-error-status-mapping-instruction.md new file mode 100644 index 0000000..e38ca50 --- /dev/null +++ b/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。 diff --git a/docs/r01-b-error-status-mapping-report.md b/docs/r01-b-error-status-mapping-report.md new file mode 100644 index 0000000..317dbf1 --- /dev/null +++ b/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` 集中登记 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; +- 未暂存、未提交、未推送; +- 未删除历史未跟踪文件。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 9a72b61..1fdc341 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/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 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 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) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java new file mode 100644 index 0000000..db96b77 --- /dev/null +++ b/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 FORBIDDEN = Set.of( + "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH", + "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER"); + + private static final Set 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 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 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> 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; + } +}