5 changed files with 256 additions and 179 deletions
@ -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。 |
||||
@ -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; |
||||
|
- 未暂存、未提交、未推送; |
||||
|
- 未删除历史未跟踪文件。 |
||||
@ -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…
Reference in new issue