forked from baskbuddy/zhangshun
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