You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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()。

保持不变的语义:

  • 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

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 个外的主要剩余:

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),先于所有 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、表、索引或依赖。