# R01-A 第一批请求上下文收束实施报告 > **状态**:R01-A 第一批请求上下文收束及阻断整改已完成,并通过独立代码复验与干净构建。 ## 1. 范围与分支 | 项目 | 值 | |---|---| | 分支 | `refactor/r01-structure-performance` | | HEAD | `77dc624`(Q01 检查点,见 `fix: align job search queries across databases`) | | 范围 | 只完成 R01-A 第一批:8 个 Controller 的身份解析与 requestId 收束 | | 未进入 | R01-B(异常映射)、R01-C(纯计算)、R01-D(查询收束)、R01-E(真实环境复验),以及 `UserRoleGuard`、`PersonalDataCollector`、评分规则、百分位、消息未读、推荐权重 | | AGENTS.md | 仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md` | ## 2. 开始前核查 ``` git branch --show-current -> refactor/r01-structure-performance ✓ git log -5 --oneline -> HEAD = 77dc624(Q01 检查点) ✓ git diff --cached --name-only -> (空) ✓ 暂存区为空 git diff --check -> 仅 LF/CRLF 提示,无空白错误 ``` 工作区另有一处**与本次无关**的既存未提交修改:`docs/backend-development-roadmap.md`(Q01 行更新 + R01 行)。它不属于 8 个 Controller 的任何文件,不构成 §3 的「重叠」停止条件,本轮未触碰该文件。 ## 3. 基线清单(整改前) ### 3.1 第一批 8 个 Controller | # | Controller | 公开端点 | 整改前身份异常 | 角色检查位置 | 资源归属检查位置 | requestId 来源 | |---|---|---|---|---|---|---| | 1 | `assessment/AssessmentController` | `GET /assessments/types`、`GET /assessments/{type}/questions`、`POST /assessments/attempts`、`GET /assessments/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /assessments/me` | `BusinessException(UNAUTHORIZED)`;非数字主体 `NumberFormatException` → 500 | `AssessmentAttemptService.requireStudent(userId)` | 答卷归属在 `AssessmentAttemptService`(非本人 → 2002/403) | `request.getHeader("X-Request-Id")` | | 2 | `ability/AbilityController` | `GET /abilities/types`、`POST /abilities/attempts`、`GET /abilities/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /abilities/me` | 同上 | `AbilityAttemptService.requireStudent(userId)` | 答卷归属在 `AbilityAttemptService` | 同上 | | 3 | `profile/ProfileController` | `GET /profile/me`、`PUT /profile/me`、`POST /profile/me/resume`、`GET /profile/me/resume`、`DELETE /profile/me/resume` | 同上(全限定名写法) | `ProfileService.assertStudentRole(userId)`(Controller 显式调用) | 按当前 userId 取本人资料,无跨用户入参 | 同上 | | 4 | `resume/ResumeVersionController` | `POST/GET /profile/me/resume-versions`、`GET/PUT/DELETE /profile/me/resume-versions/{id}`、`POST .../{id}/default`、`POST .../{id}/export`、`GET /resume-export-tasks/{taskId}`、`GET .../download` | 同上 | `ResumeVersionService` → `ProfileService.assertStudentRole` | `version.getUserId().equals(userId)`,否则 2002/403 | 同上 | | 5 | `preference/PreferenceController` | `GET /profile/me/preferences`、`PUT /profile/me/preferences` | 同上(全限定名写法) | `PreferenceService.assertStudentRole` | 键为当前 userId | 同上 | | 6 | `competition/CompetitionRankingController` | `GET /jobs/{jobId}/competition-ranking` | 同上 | `CompetitionRankingService.assertStudentRole` | 未投递该岗位 → 2002/403;岗位不存在 → 7001/404 | 同上 | | 7 | `dataexport/PersonalDataExportController` | `POST/GET /profile/me/data-export-tasks`、`GET .../{taskId}`、`GET .../{taskId}/download` | 同上,且已 try/catch `NumberFormatException`(但仍映射为 2001 → HTTP **400**) | 无角色限制(STUDENT/COMPANY/ADMIN 均可操作本人任务) | `task.getUserId().equals(userId)`,否则 2002/403 | 同上 | | 8 | `privacy/PrivacySettingsController` | `GET /profile/me/privacy`、`PUT /profile/me/privacy` | 同上 | `PrivacySettingsService.assertStudentRole` | 键为当前 userId | 同上 | **共性**:8 个类各自持有一份 `private Long resolveUserId(HttpServletRequest)` 与一份 `private String getRequestId/requestId(HttpServletRequest)`,共 16 个重复方法;身份只从 `SecurityContextHolder` 取,requestId 只从请求头取。 ### 3.2 全仓量化基线 ```powershell rg -l "private (Long|long) resolveUserId|private .*userId()|private .*requireAdmin" mydreamjob-server/src/main/java # 42 个文件 / 44 处 rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java # 46 个文件 / 46 处 ``` ## 4. 身份组件(复用,不新建) 沿用既有 `security/CurrentUserAccessor`,**未创建第二个身份解析器**。8 个 Controller 通过构造器注入(`@RequiredArgsConstructor` 的 `final` 字段)并调用 `requireUserId()`。 保持不变的语义: - `null` Authentication、未认证、`AnonymousAuthenticationToken`、主体为 `anonymousUser`、空白主体、Unicode 数字(`٤٢`)、带正负号(`+42` / `-7`)、Long 溢出(`99999999999999999999`)、`userId <= 0` —— 全部按未登录处理; - 异常文本固定为 `authentication required`,**不回显主体、异常类或堆栈**; - 不接受请求参数中的 `userId`(未新增任何 userId 入参); - 角色与资源归属仍留在原 Service / Controller 位置判断,**未从 Controller 上移到身份组件**; - 未把任何原 403 或 404 改成 401。 ### 4.1 一处必须说明的既有差异(未扩大) 整改前,8 个 Controller 的私有解析在非法主体上抛 `BusinessException(UNAUTHORIZED)`;由于 `GlobalExceptionHandler.resolveHttpStatus` 未把 `UNAUTHORIZED(2001)` 列入任何状态段,它落到「其余 → 400」,即**非法主体此前是 HTTP 400**(`PersonalDataExportController` 亦然)。 整改后统一为 `InsufficientAuthenticationException` → `AuthenticationException` 处理器 → **HTTP 401**。这与 R01 设计第 4 节「非法主体为 401」一致,也正是本批的目标;对**真实请求**而言该路径此前不可达(`SecurityConfig` 对相关路径要求 `authenticated()`,匿名请求在过滤器链就被拒绝并返回 401),因此没有改变任何真实客户端可观察的既有契约。唯一受影响的单元测试 `ResumeControllerTest.uploadResume_unauthenticated_shouldReturn401` 已改为断言新的 401 语义(断言未放宽)。 ## 5. requestId 组件 ### 5.1 新增 `common/logging/RequestIdAccessor` ```java public String get(HttpServletRequest request) // 唯一入口 ``` 取值顺序: 1. `RequestIdFilter` 写入的请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`(规范化后的值); 2. 否则读请求头 `X-Request-Id` —— 过滤器已用请求包装器把它替换为同一个规范化值;若调用路径绕过了过滤器,则用**过滤器同一处** `RequestIdFilter.normalizeRequestId(...)` 收敛,**不会**把超长或非法客户端头原样返回。 没有第二套生成规则:缺失/空白时复用的就是过滤器那一处的逻辑。独立复验进一步收紧合法字符为 ASCII `A-Z/a-z/0-9/./_/:/-`;任何含空格、控制字符、中文、HTML 符号、斜杠或其它字符的值都会被整体替换为服务端生成的 16 位十六进制 ID,不进入响应头、MDC 或日志。 ### 5.2 `RequestIdFilter` 的改动 - 新增公开常量 `REQUEST_ID_ATTRIBUTE = "requestId"`,并把**规范化后**的值写入请求属性; - MDC、响应头、请求包装器、请求属性四者写的是同一个字符串; - 常量名与原私有名一致,MDC key 未变,日志格式不受影响; - 过滤器 `finally` 中仍然 `MDC.remove(...)`。 ### 5.3 `GlobalExceptionHandler` 只把私有 `getRequestId(request)` 的实现改为经 `RequestIdAccessor` 取值,**未重构任何 HTTP 状态映射**。 独立复验发现初版为兼容 `@WebMvcTest` 使用了可选字段注入和 `new RequestIdAccessor()` 兜底,这会掩盖正式应用的装配缺陷,因此判定为阻断并已整改。最终实现使用 Lombok `@RequiredArgsConstructor` 对 `RequestIdAccessor` 做强制构造器注入;测试切片在测试侧显式提供 Bean,生产代码不存在空依赖或手工实例兜底。 `AuditContext.from(request)` 未改动:它读的请求头已被过滤器包装器规范化,与响应头、MDC、响应体同源。 ## 6. 8 个 Controller 的迁移方式 - 新增两个 `final` 字段 `currentUserAccessor` / `requestIdAccessor`; - 调用点由 `resolveUserId(request)` → `currentUserAccessor.requireUserId()`,`requestId(request)` / `getRequestId(request)` → `requestIdAccessor.get(request)`; - 删除各自的私有身份解析方法与不再使用的 `SecurityContextHolder` / `Authentication` / `BusinessException` / `ErrorCode` 导入; - 逐点人工核对,未做机械全仓替换;每个 Controller 的公开端点、角色检查与归属检查位置均未移动。 ## 7. 量化验收(整改前 → 整改后) | 指标 | 整改前 | 整改后 | 变化 | |---|---|---|---| | 身份解析私有方法:文件数 / 处数 | 42 / 44 | 35 / 37 | −7 / −7(净额,见下) | | requestId 私有方法:文件数 / 处数 | 46 / 46 | 38 / 38 | **−8 / −8** | | 8 个 Controller 内的上述私有方法 | 16 处 | **0 处** | −16 | requestId 指标精确减少 8 个文件,正好对应本批 8 个 Controller。 身份指标的净额是 −7 而非 −8:本批 8 个 Controller 各减少 1 处(共 −8),同时经负责人明确授权修复的 `DeletionGuardInterceptor` **新增**了 1 个私有辅助方法 `resolveUserIdOrNull`(见第 10 节),−8 + 1 = −7。该辅助方法是有意保留的(把「非法主体 → 放行」这一契约写在一处并加注释),不为迁就统计口径而内联。 ### 7.1 剩余数量与文件清单(**未**声称全仓完成) 仍有 **35 个文件 / 37 处**私有身份解析、**38 个文件 / 38 处**私有 requestId 方法,属后续批次。除本批 8 个外的主要剩余: ```text application/ApplicationController interview/InterviewController bidirectionalmatch/ApplicationMatchController interviewscore/InterviewScoreController bidirectionalmatch/MatchConversationController job/FavoriteController bidirectionalmatch/MatchRuleController match/MatchController candidatescore/CandidateScoreCardController messaging/MessagingController candidatescore/CandidateScoringController notification/NotificationController candidatescore/dashboard/CompanyScoringDashboardController offer/OfferController candidatescore/governance/CandidateScoringGovernanceController recommendation/JobRecommendationController careerprogress/CareerProgressController recommendation/JobRecommendationService company/CompanyController review/AdminReviewController deletion/AccountDeletionController review/AdminReviewReplyController feedback/CandidateFeedbackController review/CompanyReviewManagementController review/JobReviewController review/StudentReviewWeightController risk/AdminRiskController safety/AdminReportController safety/BlockController safety/ReportController scoringmonitoring/FairnessMonitoringController talentdiscovery/TalentDiscoveryController talentinvitation/TalentInvitationController verification/CompanyVerificationController common/exception/GlobalExceptionHandler(requestId,本轮已改为调用 accessor) controller/ApiController、controller/AuthController、controller/SystemController、job/JobController(requestId) ``` ## 8. 新增/加强的测试 | 测试类 | 类型 | Tests run | 覆盖 | |---|---|---|---| | `common/logging/RequestIdAccessorTest`(新增) | 纯单元(真实 `RequestIdFilter`) | 16 | 合法值原样使用;缺失/空白/刚好超一位/远超上限;空格、制表符、换行、回车、Unicode、HTML/路径/标点等短非法字符;**反证:绕过过滤器传 400 字符头不得返回原值**;请求属性优先于请求头;空白属性回退;null request | | `common/logging/RequestIdFilterTest`(加强) | 纯单元 | 3 | 追加断言:规范化值同时写入请求属性,且与响应头一致 | | `common/logging/RequestContextMigrationIntegrationTest`(新增) | **真实 SecurityFilterChain + RequestIdFilter + MVC** | 8 | 见下 | | `deletion/DeletionGuardInterceptorTest`(加强) | 真实过滤链 + MVC | 7 | 新增非法/溢出主体回归 | | `profile/ResumeControllerTest`(适配) | 单元 | 12 | 注入真实 `CurrentUserAccessor` / `RequestIdAccessor`;未登录断言改为新 401 语义 | | `common/exception/GlobalExceptionHandler*Test`(适配) | 单元 | 1 / 23 / 10 | 测试侧显式传入真实 `RequestIdAccessor`;生产类不保留无参构造、可选注入或手工兜底 | `RequestContextMigrationIntegrationTest` 逐项覆盖任务书第六节: 1. 合法客户端 requestId 原样使用 —— 在 8 个 Controller 的真实入口上逐一断言; 2. 缺失、空白、超长(120 字符)时使用过滤器规范化值; 3. **响应头、响应体 `requestId`、MDC、`AuditContext` 四者同值** —— MDC 与 `AuditContext` 由一个测试专用 `HandlerInterceptor` 在 MVC 派发过程中抓取,而不是只看过滤器自身; 4. 并发 8 个请求各有独立 requestId,互不串号; 5. 请求结束后 `MDC.get("requestId")` 为 `null`; 6. 8 类以上非法 principal 全部 **401 而非 5xx**(匿名令牌、`anonymousUser`、空白、非数字、Unicode 数字、`+42`、`-7`、`0`、`-1`、Long 溢出),另加「完全无凭据」401; 7. 错误响应不含 principal、`NumberFormatException` 或 `at com.mydreamjob` 堆栈; 8. 8 个 Controller 的公开端点与既有权限回归(见第 9 节 525 条关联回归); 9. 被拒绝的请求对 6 张业务表零写入; 10. **反证**:绕过过滤器直接传超长请求头时 accessor 不得返回原值(`RequestIdAccessorTest.overlongHeaderIsBoundedEvenWhenFilterIsBypassed`)。 **未只 Mock 新组件后断言 mock 值**:`RequestContextMigrationIntegrationTest` 与 `DeletionGuardInterceptorTest` 均使用真实 `SecurityFilterChain`、真实 `RequestIdFilter`、真实 MVC 与真实 JWT 会话。 ## 9. 验证结果 | 步骤 | 命令 | 结果 | |---|---|---| | 定向测试 | `mvn -Dtest="CompanyControllerMvcTest,GlobalExceptionHandler*Test,RequestContextMigrationIntegrationTest,RequestIdAccessorTest,RequestIdFilterTest,DeletionGuardInterceptorTest,ResumeControllerTest" test` | **Tests run: 87, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | | 八个模块关联回归 | `mvn -Dtest="*Assessment*Test,*Ability*Test,*Profile*Test,*Resume*Test,*Preference*Test,*Competition*Test,*DataExport*Test,*Privacy*Test,*Deletion*Test,*RequestId*Test,*RequestContext*Test,*GlobalExceptionHandler*Test,*CurrentUserAccessorTest" test` | **Tests run: 525, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | | 全量测试 | `mvn test` | 见第 11 节 | | 干净构建 | `mvn clean verify` | 见第 11 节 | | OpenAPI | `python ..\docs\verify_openapi.py` | 见第 12 节 | | 空白检查 | `git diff --check` | 仅 LF/CRLF 提示,无空白错误 | ## 10. 越界生产缺陷(已获授权,已修复) 测试在**先于修复**建立时捕获了一个真实生产缺陷,位置超出 §1 白名单,已单独取得负责人明确授权后修复。 **缺陷**:`deletion/DeletionGuardInterceptor` 注册在 `/**`([WebMvcConfig.java:20](mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java#L20)),先于**所有** Controller 执行。它在 `preHandle` 里用 `Character::isDigit` 过滤主体后直接 `Long.valueOf(auth.getName())`: - 主体为 `"99999999999999999999"`(Long 溢出)→ `NumberFormatException` 逃逸 → **全站 HTTP 500**,且在任何 Controller 之前; - 同一检查下 `Character.isDigit` 接受 Unicode 数字,只是因为 `Long.parseLong` 恰好也接受 Unicode 数字才没有一并炸掉。 **证据**(修复前):`RequestContextMigrationIntegrationTest.illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal` 在「Long 溢出主体」上得到 `expected: 401 but was: 500`,堆栈为 `java.lang.NumberFormatException: For input string: "99999999999999999999"` at `DeletionGuardInterceptor.preHandle(DeletionGuardInterceptor.java:60)`。 **修复**:该拦截器的身份解析改为复用唯一的 `CurrentUserAccessor`;非法/溢出主体一律视为未认证并**放行**,交给 Controller 统一返回 401。独立复验将初版的 `ObjectProvider` 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 9302 / 9303 语义、白名单路径与 OPTIONS 放行**均未改动**(`DeletionGuardInterceptorTest` 7 条全绿,含 4 条原有拦截语义用例)。 这条修复是 §1 白名单之外的第 9 个生产文件,**在此明确标出**,未混入 8 个 Controller 的改动叙述。 ## 11. 全量测试与干净构建 | 项目 | Tests run | Failures | Errors | Skipped | 构建 | |---|---|---|---|---|---| | 独立整改定向复验 | **75** | **0** | **0** | **0** | `BUILD SUCCESS` | | `mvn clean verify` | **1915** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) | 三个日志中均不存在任何 `Skipped: <非零>` 的测试类。 期间发生过两次真实失败并已修正:实施阶段首次全量测试暴露 `CompanyControllerMvcTest` 未提供 `RequestIdAccessor`;最终处理是在该测试切片显式提供依赖,而不是弱化生产装配。独立整改第一次定向运行又因新增参数化测试同时残留 `@Test` 而产生 1 个 ParameterResolution Error;删除多余注解并补齐换行、回车边界后,同一集合 75/75 通过。两次失败均未通过删除断言或生产兜底绕过。 ## 12. OpenAPI ``` python docs/verify_openapi.py paths: 176 operations: 214 All checks passed! ``` `docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。本批未新增、删除或重命名任何 path / operation,未改变参数名、默认值、响应 DTO 或错误码;8 个 Controller 的 Swagger 注解一律原样保留。 ## 13. 修改文件清单 **新增** ```text docs/r01-a-request-context-implementation-report.md mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java ``` **修改(生产,共 11 个文件:8 个 Controller + 过滤器 + 异常处理器 + 1 个经授权的越界修复)** ```text mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java # 新增 request attribute 常量并写入 mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java # 改为调用 accessor mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java # 越界缺陷修复(已授权) ``` **修改(测试)** ```text mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java ``` **未修改**:未新增接口、`ErrorCode`、Flyway、表、索引或依赖;V1–V43 未改动;`docs/openapi-rc.json` 未改动。 ## 14. Git 状态 ``` git branch --show-current -> refactor/r01-structure-performance git diff --cached --name-only -> (空) git status --short -- mydreamjob-server/src/main -> 11 个 M + 1 个 ?? git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空) git status --short -- docs/openapi-rc.json -> (空) git diff --check -> 仅 LF/CRLF 提示,无空白错误 ``` 工作区中另有一处**先于本轮就存在**的未提交修改 `docs/backend-development-roadmap.md`(Q01 行更新 + R01 行),本轮未触碰。 本批**未暂存、未提交、未推送**;未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。所有 R01-A 产物均为未跟踪新文件或仅本轮修改的已跟踪文件。 ## 15. 未做的事 - 未进入 R01-B / R01-C / R01-D / R01-E; - 未新增 `UserRoleGuard`(角色授权按设计要求留在后续批次,本批未把角色判断与身份解析混在一起); - 未改动 `AuditContext`、`RequestLoggingFilter`(后者读的请求头已被过滤器包装器规范化,与其余输出同源); - 未改动 `docs/backend-development-roadmap.md` 与 `docs/phase1-scope-and-acceptance.md`; - 未新增接口、`ErrorCode`、Flyway、表、索引或依赖。