# R01-A2 第二批:请求身份与 requestId 收束报告 日期:2026-09-17 分支:`refactor/r01-structure-performance` 基线 HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445` 范围:**仅 8 个 Controller**(ApplicationController、CompanyController、MessagingController、OfferController、InterviewController、CandidateScoringController、CandidateScoreCardController、CompanyScoringDashboardController) --- ## 0. 结论 > **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。** 8 个 Controller 的身份与 requestId 解析已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;requestId 不再由 Controller 直读原始请求头。 --- ## 1. 修改前后的私有解析方法 | Controller | 修改前(私有方法) | 修改后 | |---|---|---| | ApplicationController | `private Long resolveUserId(HttpServletRequest)` → `SecurityContextHolder.getContext().getAuthentication()`;`auth==null \|\| !isAuthenticated()` → `BusinessException(UNAUTHORIZED)`;`return Long.valueOf(auth.getName())`
`private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | `currentUserAccessor.requireUserId()`(10 处)
`requestIdAccessor.get(request)`(9 处) | | CompanyController | 同上(私有 `resolveUserId` + `getRequestId`) | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×6 | | MessagingController | 同上 | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 | | OfferController | 同上 | `requireUserId()` ×13;`requestIdAccessor.get(request)` ×13 | | InterviewController | 同上 | `requireUserId()` ×9;`requestIdAccessor.get(request)` ×9 | | CandidateScoringController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×4;`requestIdAccessor.get(request)` ×4 | | CandidateScoreCardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 | | CompanyScoringDashboardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 | **共删除私有解析方法 16 个**(8 个 `resolveUserId` + 8 个 `getRequestId`),并移除随之不再使用的 import:`org.springframework.security.core.Authentication`、`org.springframework.security.core.context.SecurityContextHolder`(后 3 个 Controller)。 新增字段(每个 Controller): ```java /** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; /** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */ private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; ``` **未做**:未把 userId 加入请求参数;未调整角色检查调用位置;未调整归属判断;未复制第二套解析器。 --- ## 2. 端点 401/403/404 语义 ### 2.1 明确的语义变更(如实记录) | 场景 | 修改前 | 修改后 | 说明 | |---|---|---|---| | SecurityContext 为 null / 未认证 | `BusinessException(UNAUTHORIZED=2001)` | `InsufficientAuthenticationException` | 均映射 401,**无变化** | | **主体名为空 / 非数字 / 溢出 / ≤0 / `anonymousUser`** | `Long.valueOf(name)` 抛 `NumberFormatException` → **HTTP 500** | 统一 **HTTP 401** | **语义变更:500 → 401** | | 主体名合法但空字符串 `""` | `Long.valueOf("")` → **500** | **401** | 同上 | **依据**:`ErrorCode.UNAUTHORIZED(2001)` **不在** `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 的任何 `register` 列表中,`resolveHttpStatus` 的 `getOrDefault(2001, BAD_REQUEST)` 使其落到 **400**;而空/非法名称在 `Long.valueOf` 处直接抛 `NumberFormatException` 逃逸为 **500**(修复前特征测试已实测复现)。 迁移后统一由 `CurrentUserAccessor` 抛 `InsufficientAuthenticationException`,由既有 `AuthenticationException` 处理器映射为 **401** —— 这正是任务书要求的「非法主体统一返回401,不是500」。 > 说明:修改前该路径实际为 **400 或 500**(取决于主体名是否合法可解析),并非 401。因此本次变更**不触及**原本就是 401/403/404 的场景。 ### 2.2 保持不变的语义 - **403(角色/归属)**:`assertCompanyRole`、`assertCompanyOwner`、归属校验等调用位置与逻辑**一字未动** ✔ - **404(资源不存在)**:`CompanyController` 的 `ApiResponse.error(404, "company not found", …)` 等保持不变 ✔ - **业务错误码**:未修改 `ErrorCode`、未修改 `resolveHttpStatus`、未修改 `GlobalExceptionHandler` ✔ - **filter chain 层 401**:无 token 的请求由 `SecurityFilterChain` 在 Controller 之前拒绝,行为不变(既有 `list_noToken_shouldReturn401` 等断言持续通过)✔ --- ## 3. 修复前失败测试 新增 `RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/`),使用**真实 `SecurityFilterChain` + `MockMvc`**(`@SpringBootTest` + `@AutoConfigureMockMvc`)。 **修复前实测(仅加入测试、未改生产代码):`Tests run: 17, Failures: 5`** | 失败测试 | 修复前实际 | |---|---| | `illegalPrincipals_alwaysReturn401_withoutEchoingPrincipal` | **`实际 500`**(空/非法主体时 `Long.valueOf` 抛 `NumberFormatException` 逃逸) | | `failedRequests_produceNoBusinessWrites` | `Status expected:<401> but was:<500>` | | `errorResponse_carriesSameRequestId` | 错误响应未携带同源 requestId | | `requestId_maliciousCharacters_neverReachResponse` | 异常字符进入响应 | | `legalPrincipal_isNotRejectedByIdentityLayer` | 合法主体被身份层拒绝 | **修复后:`Tests run: 17, Failures: 0`**(其中的断言经两轮校正:`principal="0"` 单字符与 JSON 数字碰撞、`AnonymousAuthenticationToken` 由 filter chain 提前拦截返回 `"not authenticated"`、MockMvc 客户端层拒绝含 CR/LF 的请求头 —— 三者均为测试自身缺陷,已修正并如实记录)。 --- ## 4. requestId 同源证据 `RequestIdFilter` 是 requestId 的**唯一产出点**:规范化后同时写入请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`、MDC、响应头,并用请求包装器覆盖 `X-Request-Id`。`RequestIdAccessor.get(request)` 优先读取该请求属性,兜底仍走同一 `normalizeRequestId`。 测试覆盖(每个用例遍历全部 8 个端点): | 用例 | 断言 | |---|---| | 合法值 `r01a2-legal-0001` | 响应头 == 响应体 `requestId` == 原值 ✔ | | 超长值(120 字符) | 收敛为前 32 字符;响应头 == 响应体;响应体不含原超长串 ✔ | | 空白值 `" "` | 重新生成非空值 ✔ | | 异常字符(`