22 KiB
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 全仓量化基线
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()。
保持不变的语义:
nullAuthentication、未认证、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
public String get(HttpServletRequest request) // 唯一入口
取值顺序:
RequestIdFilter写入的请求属性RequestIdFilter.REQUEST_ID_ATTRIBUTE(规范化后的值);- 否则读请求头
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 个外的主要剩余:
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 逐项覆盖任务书第六节:
- 合法客户端 requestId 原样使用 —— 在 8 个 Controller 的真实入口上逐一断言;
- 缺失、空白、超长(120 字符)时使用过滤器规范化值;
- 响应头、响应体
requestId、MDC、AuditContext四者同值 —— MDC 与AuditContext由一个测试专用HandlerInterceptor在 MVC 派发过程中抓取,而不是只看过滤器自身; - 并发 8 个请求各有独立 requestId,互不串号;
- 请求结束后
MDC.get("requestId")为null; - 8 类以上非法 principal 全部 401 而非 5xx(匿名令牌、
anonymousUser、空白、非数字、Unicode 数字、+42、-7、0、-1、Long 溢出),另加「完全无凭据」401; - 错误响应不含 principal、
NumberFormatException或at com.mydreamjob堆栈; - 8 个 Controller 的公开端点与既有权限回归(见第 9 节 525 条关联回归);
- 被拒绝的请求对 6 张业务表零写入;
- 反证:绕过过滤器直接传超长请求头时 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),先于所有 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<CurrentUserAccessor> 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 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. 修改文件清单
新增
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 个经授权的越界修复)
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 # 越界缺陷修复(已授权)
修改(测试)
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、表、索引或依赖。