From 0b5b8495b1f8d3fa212fcbc902156a879d432172 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Tue, 15 Sep 2026 16:49:10 +0800 Subject: [PATCH] refactor: centralize request identity and request ID handling --- docs/backend-development-roadmap.md | 3 +- ...uest-context-implementation-instruction.md | 110 ++++++ ...a-request-context-implementation-report.md | 284 +++++++++++++ docs/r01-structure-performance-design.md | 81 ++++ .../mydreamjob/ability/AbilityController.java | 42 +- .../assessment/AssessmentController.java | 44 +-- .../exception/GlobalExceptionHandler.java | 7 +- .../common/logging/RequestIdAccessor.java | 37 ++ .../common/logging/RequestIdFilter.java | 44 ++- .../CompetitionRankingController.java | 24 +- .../PersonalDataExportController.java | 39 +- .../deletion/DeletionGuardInterceptor.java | 36 +- .../preference/PreferenceController.java | 27 +- .../privacy/PrivacySettingsController.java | 28 +- .../mydreamjob/profile/ProfileController.java | 37 +- .../resume/ResumeVersionController.java | 53 +-- ...alExceptionHandlerAccountDeletionTest.java | 4 +- .../GlobalExceptionHandlerHttpStatusTest.java | 2 +- ...GlobalExceptionHandlerReportBlockTest.java | 4 +- ...equestContextMigrationIntegrationTest.java | 373 ++++++++++++++++++ .../common/logging/RequestIdAccessorTest.java | 150 +++++++ .../common/logging/RequestIdFilterTest.java | 5 + .../company/CompanyControllerMvcTest.java | 7 +- .../DeletionGuardInterceptorTest.java | 21 + .../profile/ResumeControllerTest.java | 17 +- 25 files changed, 1258 insertions(+), 221 deletions(-) create mode 100644 docs/r01-a-request-context-implementation-instruction.md create mode 100644 docs/r01-a-request-context-implementation-report.md create mode 100644 docs/r01-structure-performance-design.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 6db8591..bbcb93d 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -14,7 +14,8 @@ | S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | -| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。新增 50 条真实库测试:H2 真实 Repository 19、H2 真实 Service 9、H2 查询副作用 4、真实 MySQL 8(Testcontainers)规格对账 13、MySQL 查询副作用 4、MySQL EXPLAIN 1;全部 `Failures=0 / Errors=0 / Skipped=0` | 不改接口、DTO、状态机、权限与搜索语义;不新增 Flyway / ErrorCode / 公网接口;OpenAPI 保持 176/214;EXPLAIN 观察项(前导通配符、filesort)移交 R01 | +| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | +| R01 结构与性能收束 | 已完成基线审计和权威设计,建立独立分支 `refactor/r01-structure-performance`;确认身份解析 41 个文件、requestId 私有方法 46 个文件及 O(n²)/N+1 热点 | 按 R01-A 至 R01-E 分批实施;第一批只迁移 8 个 Controller 的身份和 requestId,不与异常映射、评分或 SQL 优化混改 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-a-request-context-implementation-instruction.md b/docs/r01-a-request-context-implementation-instruction.md new file mode 100644 index 0000000..5955c68 --- /dev/null +++ b/docs/r01-a-request-context-implementation-instruction.md @@ -0,0 +1,110 @@ +# R01-A 请求身份与 requestId 收束实施指令 + +## 一、任务性质 + +本轮只完成 R01-A 第一批,不进入异常映射、评分规则、百分位、消息未读、PersonalDataCollector 或推荐权重重构。 + +允许修改公共请求上下文组件、最多 8 个 Controller 及其测试和文档;禁止新增接口、ErrorCode、Flyway、表、索引或依赖。 + +## 二、开始前核查 + +在 `C:\Users\15642\Desktop\zhilai` 执行并记录: + +```powershell +git branch --show-current +git status --short +git diff --cached --name-only +git log -5 --oneline +git diff --check +``` + +分支必须为 `refactor/r01-structure-performance`,HEAD 必须包含 Q01 检查点 `77dc624`,暂存区必须为空。完整搜索适用的 AGENTS.md;保护全部历史未跟踪文件。 + +## 三、先写基线清单 + +列出所有私有 `resolveUserId/userId/requireAdmin` 与 `requestId/getRequestId` 方法,记录:文件、公开端点、当前异常、角色检查位置、资源归属检查位置、requestId 来源。 + +第一批只迁移以下 8 个 Controller: + +1. `AssessmentController` +2. `AbilityController` +3. `ProfileController` +4. `ResumeVersionController` +5. `PreferenceController` +6. `CompetitionRankingController` +7. `PersonalDataExportController` +8. `PrivacySettingsController` + +若其中存在与未提交修改重叠,停止并报告,不得改换到其它文件凑数。 + +## 四、身份组件 + +复用现有 `CurrentUserAccessor`,不要创建第二个身份解析器。Controller 通过构造器注入并调用 `requireUserId()`。 + +必须保持: + +- null Authentication、AnonymousAuthenticationToken、`anonymousUser`、未认证、空白主体、Unicode 数字、带正负号、Long 溢出和 userId<=0 全部 401; +- 不回显主体内容、异常类或堆栈; +- 不接受请求参数中的 userId; +- 角色与资源归属仍由原 Service/Controller 的既有位置判断; +- 不将原 403 或 404 改成 401。 + +删除迁移 Controller 内的私有身份解析代码和不再使用的 SecurityContext 依赖。 + +## 五、requestId 组件 + +先完整阅读 `common/logging/RequestIdFilter`、`RequestLoggingFilter`、`AuditContext` 和 `GlobalExceptionHandler`。 + +新增 `common/logging/RequestIdAccessor`,提供 `String get(HttpServletRequest request)`。它必须复用过滤器产出的规范化 requestId;禁止重新生成第二个值,禁止直接信任超长/非法客户端头。 + +若 RequestIdFilter 当前没有 request attribute,允许增加一个常量属性并写入规范化后的值。响应头、MDC、Controller body、AuditContext 与异常响应必须保持同源。过滤器结束后仍清理 MDC。 + +只替换上述 8 个 Controller 的私有 requestId 方法。GlobalExceptionHandler 本轮只允许改为调用 accessor,不得重构 HTTP 状态映射。 + +## 六、必须先新增的测试 + +新增或加强: + +- `RequestIdAccessorTest` +- `RequestContextMigrationIntegrationTest` +- 8 个 Controller 的既有权限回归 + +至少覆盖: + +1. 合法客户端 requestId 原样使用; +2. 缺失、空白、超长、非法字符时使用过滤器规范化值; +3. 响应头、响应 body、MDC 日志与 AuditContext 同一值; +4. 并发请求不串 requestId; +5. 过滤器完成后 MDC 清理; +6. 八类非法 principal 均 401 而非 500; +7. 错误响应不包含 principal; +8. STUDENT/COMPANY/ADMIN 权限矩阵与改动前一致; +9. 拒绝请求无业务写入; +10. 反证:绕过 filter 直接传超长请求头时 accessor 不得返回原值。 + +禁止只 Mock 新组件后断言 Controller 返回 mock 值;至少一组测试必须使用真实 SecurityFilterChain、RequestIdFilter 和 MVC。 + +## 七、量化验收 + +整改前后分别统计: + +```powershell +rg -l "private (Long|long) resolveUserId|private .*userId\(\)" mydreamjob-server/src/main/java +rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java +``` + +第一批必须精确减少对应 8 个 Controller 的重复方法,不得声称全仓已完成。报告剩余数量和文件清单。 + +## 八、验证 + +依次运行定向测试、八个模块关联测试、`mvn test`、`mvn clean verify`、`python ..\docs\verify_openapi.py`、`git diff --check`。 + +预期 OpenAPI 保持 176/214;测试数量以实际为准,Failures=0、Errors=0、Skipped=0。 + +## 九、停止条件 + +完成 R01-A 第一批代码、测试和报告后停止。暂存区保持空;不得 commit/push;不得进入 R01-B/C/D/E。 + +最终状态只能写: + +`R01-A 第一批请求上下文收束完成,待独立代码复验。` diff --git a/docs/r01-a-request-context-implementation-report.md b/docs/r01-a-request-context-implementation-report.md new file mode 100644 index 0000000..ccd7952 --- /dev/null +++ b/docs/r01-a-request-context-implementation-report.md @@ -0,0 +1,284 @@ +# 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、表、索引或依赖。 diff --git a/docs/r01-structure-performance-design.md b/docs/r01-structure-performance-design.md new file mode 100644 index 0000000..e60e9fa --- /dev/null +++ b/docs/r01-structure-performance-design.md @@ -0,0 +1,81 @@ +# R01 结构与性能收束权威设计 + +## 1. 目标与边界 + +R01 在 S01、S02、S03、Q01 已冻结行为之上收束重复代码和可证明的性能热点。所有改动必须保持 HTTP 契约、业务错误码、权限、事务、审计、隐私和数据副作用不变。 + +禁止在 R01 中新增业务功能、公开接口、Flyway、表或索引;禁止改变评分公式、推荐权重、百分位定义、消息未读定义、简历权限、Token 语义或岗位搜索语义。索引和全文搜索仅形成后续提案,不在本轮实施。 + +基线:分支 `refactor/r01-structure-performance`,起点 `77dc624`;OpenAPI 176 paths / 214 operations;全量测试 1890/1890。 + +## 2. 已确认问题 + +- 41 个生产文件包含私有身份解析方法;不同实现对匿名主体、非数字主体和溢出主体的处理不一致。 +- 46 个生产文件包含私有 requestId 获取方法;部分直接取请求头,未复用 RequestIdFilter 的规范化结果。 +- 角色断言以 `assertStudentRole`、`requireStudent`、`requireAdmin` 等形式分散,存在重复查用户和语义漂移风险。 +- `GlobalExceptionHandler.resolveHttpStatus` 是大型 if 链,并含重复分支。 +- `overviewStatus` 在评分引擎和竞争力排名中存在两套等价规则。 +- `JobRecommendationService.preferenceScore` 被压缩为单行,权重含义难以评审。 +- `CompanyScoringDashboardService.computePercentiles` 对每名候选人扫描全部分数,复杂度 O(n²)。 +- `MessagingService.listConversations` 对每个会话分别加载最多 500 条消息和 read 状态,查询次数随会话数线性增长。 +- `PersonalDataCollector` 存在可批量化的数据读取。 +- 岗位关键字 `%keyword%` 与排序 filesort 是已记录风险,但未经产品和容量决策,不在 R01 直接改搜索技术栈。 + +## 3. 分批策略 + +### R01-A:请求身份与 requestId 基础设施 + +扩展既有 `CurrentUserAccessor`,使所有迁移入口统一拒绝 null、匿名、失效、空白、非 ASCII 数字、溢出和非正 userId。不得在 Controller 接受 userId 参数。 + +新增单一 `RequestIdAccessor`:优先读取 RequestIdFilter 已规范化的 request attribute;缺失时使用现有安全归一化逻辑,而不是信任任意长度请求头。响应、审计和日志必须使用同一 requestId。 + +按小批次迁移 Controller,每批最多 8 个类。每个调用点逐项锁定原 401/403 和资源归属语义;禁止机械全仓替换。 + +角色授权暂不与身份解析混在同一提交。先盘点后新增 `UserRoleGuard`,保持 USER_NOT_FOUND 与 FORBIDDEN 的现有差异,不增加隐式缓存。 + +### R01-B:异常映射和业务规则唯一来源 + +将业务错误码到 HTTP 状态的关系提取为不可变映射,启动或测试时检测重复和遗漏;默认仍为 400。不得更改任何现有映射。 + +提取唯一 `CandidateOverviewStatusPolicy`。评分引擎与竞争排名调用同一实现,并用参数化契约测试证明旧、新结果完全一致。 + +### R01-C:纯计算与可读性 + +将百分位改为“排序一次 + 分数组到 lower-rank 的映射”,复杂度 O(n log n)。并列分数必须获得相同百分位;null、小于 2 个有效分数、精度和 HALF_UP 规则保持不变。 + +拆分 `preferenceScore` 为命名明确的五个维度贡献函数。城市 30、用工类型 20、最低薪资 20、行业 15、岗位族 15 及缺失维度不计分的规则不得变化。 + +### R01-D:数据库查询收束 + +消息会话列表新增固定次数聚合查询,一次性按 conversationIds 计算未读数。定义保持:排除 RECALLED;发送者消息是否未读沿用当前 read-row 语义;不存在 read 行即未读。1/20/50 个会话查询次数必须固定。 + +`markRead` 是否突破 500 条属于独立语义问题,本轮先写失败证据和决策,不与列表未读优化混改。 + +PersonalDataCollector 按关联键批量读取;导出 JSON 字段、顺序、空值、脱敏与表范围不变。以 1/20/50 数据规模记录查询数和完整导出结果摘要。 + +### R01-E:真实环境复验 + +在 MySQL 8.4 上执行查询预算、结果等价和 EXPLAIN。不得把 H2 结果当成 MySQL 证据;不得为了 EXPLAIN 修改迁移或索引。 + +## 4. 关键契约 + +| 模块 | 必须保持 | +|---|---| +| 身份 | 非法主体为 401,不回显 principal,不产生业务写入 | +| 角色 | 原有 USER_NOT_FOUND/FORBIDDEN、角色组合与归属判断不变 | +| requestId | 最大长度、生成规则、响应头、响应体、MDC 与审计同源 | +| 异常 | 每个 ErrorCode 的 HTTP 状态与响应 body 不变 | +| 百分位 | `lower / (n-1) * 100`、并列同值、scale=2、HALF_UP | +| 推荐 | 五项权重和缺失项分母规则逐字保持 | +| 未读 | RECALLED 不计入;已有 read 行语义不变 | +| 导出 | 字段、隐私、顺序、内容与错误语义不变 | + +## 5. 证据要求 + +每一批必须先建立特征测试或失败测试,再改生产代码。性能结论必须同时包含结果等价断言、SQL 次数和反证测试;仅写“无 N+1”不算证据。 + +自动化最低门槛:定向测试、关联回归、`mvn test`、`mvn clean verify`、静态 OpenAPI;Failures/Errors/Skipped 均为 0。涉及 SQL 的批次还必须有真实 MySQL 黑盒。 + +## 6. 提交边界 + +每个子阶段独立分支检查点或独立提交;不得将 R01-A 到 R01-E 压成一个大提交。所有历史未跟踪日志和验收产物保留但不提交。不得使用 `git add -A`、reset、restore、checkout 或 clean。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java index d69d9e7..2f9f7e1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java @@ -9,8 +9,8 @@ import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.PageResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,8 +18,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -37,6 +35,8 @@ public class AbilityController { private final AbilityCatalogService catalogService; private final AbilityAttemptService attemptService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "能力测评类型列表", description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。") @@ -46,7 +46,7 @@ public class AbilityController { }) @GetMapping("/abilities/types") public ApiResponse> listTypes(HttpServletRequest request) { - return ApiResponse.ok(catalogService.listTypes(), requestId(request)); + return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request)); } @Operation(summary = "创建能力答卷", @@ -63,8 +63,8 @@ public class AbilityController { @PostMapping("/abilities/attempts") public ApiResponse createAttempt( @Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), - requestId(request)); + return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)), + requestIdAccessor.get(request)); } @Operation(summary = "能力答卷详情", @@ -78,7 +78,7 @@ public class AbilityController { @GetMapping("/abilities/attempts/{attemptId}") public ApiResponse getAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "保存能力测评答案", @@ -96,8 +96,8 @@ public class AbilityController { public ApiResponse saveAnswers( @PathVariable Long attemptId, @Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "提交能力测评答卷", @@ -114,8 +114,8 @@ public class AbilityController { @PostMapping("/abilities/attempts/{attemptId}/submit") public ApiResponse submitAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "查看能力测评结果", @@ -129,7 +129,7 @@ public class AbilityController { @GetMapping("/abilities/attempts/{attemptId}/result") public ApiResponse getResult( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.loadResult(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(attemptService.loadResult(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "我的能力测评历史", @@ -144,21 +144,9 @@ public class AbilityController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Page result = attemptService.listMine(resolveUserId(request), page, size); + Page result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size); return ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request)); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + requestIdAccessor.get(request)); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java index f398e3b..931fc29 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java @@ -10,8 +10,8 @@ import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.PageResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,8 +19,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -39,6 +37,8 @@ public class AssessmentController { private final AssessmentCatalogService catalogService; private final AssessmentAttemptService attemptService; private final AssessmentResultService resultService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "测评类型列表", description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。") @@ -48,7 +48,7 @@ public class AssessmentController { }) @GetMapping("/assessments/types") public ApiResponse> listTypes(HttpServletRequest request) { - return ApiResponse.ok(catalogService.listTypes(), requestId(request)); + return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request)); } @Operation(summary = "题目列表", @@ -61,7 +61,7 @@ public class AssessmentController { @GetMapping("/assessments/{type}/questions") public ApiResponse> listQuestions( @PathVariable String type, HttpServletRequest request) { - return ApiResponse.ok(catalogService.listQuestions(type), requestId(request)); + return ApiResponse.ok(catalogService.listQuestions(type), requestIdAccessor.get(request)); } @Operation(summary = "创建答卷", @@ -77,8 +77,8 @@ public class AssessmentController { @PostMapping("/assessments/attempts") public ApiResponse createAttempt( @Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), - requestId(request)); + return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)), + requestIdAccessor.get(request)); } @Operation(summary = "答卷详情", @@ -92,7 +92,7 @@ public class AssessmentController { @GetMapping("/assessments/attempts/{attemptId}") public ApiResponse getAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "保存答案", @@ -109,8 +109,8 @@ public class AssessmentController { public ApiResponse saveAnswers( @PathVariable Long attemptId, @Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) { - return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "提交答卷", @@ -127,8 +127,8 @@ public class AssessmentController { @PostMapping("/assessments/attempts/{attemptId}/submit") public ApiResponse submitAttempt( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, - AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId, + AuditContext.from(request)), requestIdAccessor.get(request)); } @Operation(summary = "查看结果", @@ -143,7 +143,7 @@ public class AssessmentController { @GetMapping("/assessments/attempts/{attemptId}/result") public ApiResponse getResult( @PathVariable Long attemptId, HttpServletRequest request) { - return ApiResponse.ok(resultService.get(resolveUserId(request), attemptId), requestId(request)); + return ApiResponse.ok(resultService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request)); } @Operation(summary = "我的测评历史", @@ -158,21 +158,9 @@ public class AssessmentController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Page result = attemptService.listMine(resolveUserId(request), page, size); + Page result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size); return ApiResponse.ok(PageResponse.of( result.getContent(), - result.getTotalElements(), result.getNumber(), result.getSize()), requestId(request)); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + result.getTotalElements(), result.getNumber(), result.getSize()), requestIdAccessor.get(request)); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 9fedc49..9a72b61 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,11 @@ package com.mydreamjob.common.exception; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -26,8 +28,11 @@ import java.util.Map; */ @Slf4j @RestControllerAdvice +@RequiredArgsConstructor public class GlobalExceptionHandler { + private final RequestIdAccessor requestIdAccessor; + @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity> handleConstraintViolation( ConstraintViolationException ex, HttpServletRequest request) { @@ -318,6 +323,6 @@ public class GlobalExceptionHandler { } private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return requestIdAccessor.get(request); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java new file mode 100644 index 0000000..a822e3e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java @@ -0,0 +1,37 @@ +package com.mydreamjob.common.logging; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +/** + * 请求 ID 读取器:读取 requestId 的**唯一入口**,保证响应头、MDC、Controller 响应体、 + * {@code AuditContext} 与异常响应使用同一个值。 + * + *

取值优先级:

+ *
    + *
  1. {@link RequestIdFilter} 写入的请求属性(规范化后的值);
  2. + *
  3. 请求头 {@code X-Request-Id} —— 过滤器已通过请求包装器把它替换为规范化值, + * 因此这里得到的仍是同一个值;若调用路径绕过了过滤器,则由 + * {@link RequestIdFilter#normalizeRequestId(String)} 按同一规则收敛, + * **不会**把超长或非法客户端头原样返回。
  4. + *
+ * + *

本类不产生第二套生成规则:缺失或空白时复用过滤器同一处 + * {@code normalizeRequestId} 逻辑。

+ */ +@Component +public class RequestIdAccessor { + + /** 读取当前请求的规范化 requestId。 */ + public String get(HttpServletRequest request) { + if (request == null) { + return RequestIdFilter.normalizeRequestId(null); + } + Object attribute = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE); + if (attribute instanceof String value && !value.isBlank()) { + return value; + } + // 过滤器未执行时的兜底:仍按过滤器同一规则收敛,绝不直接回显客户端原值。 + return RequestIdFilter.normalizeRequestId(request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java index 43f4bbf..42997c6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java @@ -11,20 +11,32 @@ import java.util.Collections; import java.util.UUID; /** - * 请求 ID 过滤器:为每个请求生成/传递唯一 ID,便于日志追踪 + * 请求 ID 过滤器:为每个请求生成/传递唯一 ID,便于日志追踪。 + * + *

本过滤器是 requestId 的**唯一产出点**:规范化后的值同时写入请求属性 + * {@link #REQUEST_ID_ATTRIBUTE}、MDC、响应头,并覆盖请求包装器的 {@code X-Request-Id} + * 头,使 Controller、{@code AuditContext} 与异常响应读到同一个值。

*/ @Component public class RequestIdFilter extends OncePerRequestFilter { - private static final String REQUEST_ID_HEADER = "X-Request-Id"; - private static final String REQUEST_ID_ATTR = "requestId"; + /** 客户端可传入的请求头名;同时是响应头名。 */ + static final String REQUEST_ID_HEADER = "X-Request-Id"; + + /** + * 规范化 requestId 的请求属性名。下游(Controller、异常处理器)应通过 + * {@link RequestIdAccessor} 读取,而不是各自解析请求头。 + */ + public static final String REQUEST_ID_ATTRIBUTE = "requestId"; + private static final int MAX_REQUEST_ID_LENGTH = 32; @Override protected void doFilterInternal(HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response, jakarta.servlet.FilterChain filterChain) throws IOException, jakarta.servlet.ServletException { String requestId = normalizeRequestId(request.getHeader(REQUEST_ID_HEADER)); - MDC.put(REQUEST_ID_ATTR, requestId); + request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId); + MDC.put(REQUEST_ID_ATTRIBUTE, requestId); response.setHeader(REQUEST_ID_HEADER, requestId); try { // Controllers and AuditContext read the request header directly. Wrap the @@ -50,17 +62,35 @@ public class RequestIdFilter extends OncePerRequestFilter { }; filterChain.doFilter(normalizedRequest, response); } finally { - MDC.remove(REQUEST_ID_ATTR); + MDC.remove(REQUEST_ID_ATTRIBUTE); } } static String normalizeRequestId(String candidate) { String value = candidate == null ? "" : candidate.trim(); - if (value.isEmpty()) { - return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + if (value.isEmpty() || !hasOnlySafeCharacters(value)) { + return generatedRequestId(); } return value.length() <= MAX_REQUEST_ID_LENGTH ? value : value.substring(0, MAX_REQUEST_ID_LENGTH); } + + private static boolean hasOnlySafeCharacters(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean safe = c >= 'a' && c <= 'z' + || c >= 'A' && c <= 'Z' + || c >= '0' && c <= '9' + || c == '.' || c == '_' || c == ':' || c == '-'; + if (!safe) { + return false; + } + } + return true; + } + + private static String generatedRequestId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java index 84d1a1f..d18c313 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java @@ -1,17 +1,15 @@ package com.mydreamjob.competition; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.competition.dto.CompetitionRankingResponse; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; @@ -28,6 +26,8 @@ import org.springframework.web.bind.annotation.RestController; public class CompetitionRankingController { private final CompetitionRankingService competitionRankingService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "同岗位竞争力排名", description = "仅 STUDENT 且已投递该岗位;返回本人排名/综合分/百分位/维度/缺口及匿名 Top 5。" @@ -42,20 +42,8 @@ public class CompetitionRankingController { @GetMapping("/jobs/{jobId}/competition-ranking") public ResponseEntity> getRanking( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CompetitionRankingResponse data = competitionRankingService.getRanking(userId, jobId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java index d9d4a79..0ecb19b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java @@ -2,10 +2,10 @@ package com.mydreamjob.dataexport; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.PageResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.dataexport.dto.CreateDataExportRequest; import com.mydreamjob.dataexport.dto.DataExportTaskResponse; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -15,8 +15,6 @@ import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -37,16 +35,18 @@ import java.nio.charset.StandardCharsets; public class PersonalDataExportController { private final DataExportService dataExportService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "创建个人数据导出任务", description = "异步生成 ZIP;同 idempotencyKey 重放返回原任务;注销进行中禁止创建") @PostMapping("/profile/me/data-export-tasks") public ResponseEntity> create( @Valid @RequestBody(required = false) CreateDataExportRequest body, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CreateDataExportRequest req = body == null ? new CreateDataExportRequest() : body; return ResponseEntity.ok(ApiResponse.ok( - dataExportService.create(userId, req), getRequestId(request))); + dataExportService.create(userId, req), requestIdAccessor.get(request))); } @Operation(summary = "查询个人数据导出任务列表", description = "仅本人;按创建时间倒序分页") @@ -56,9 +56,9 @@ public class PersonalDataExportController { @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String status, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - dataExportService.list(userId, page, size, status), getRequestId(request))); + dataExportService.list(userId, page, size, status), requestIdAccessor.get(request))); } @Operation(summary = "查询个人数据导出任务详情", description = "仅本人;不暴露存储路径与内部字段") @@ -66,9 +66,9 @@ public class PersonalDataExportController { public ResponseEntity> get( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - dataExportService.get(userId, taskId), getRequestId(request))); + dataExportService.get(userId, taskId), requestIdAccessor.get(request))); } @Operation(summary = "下载个人数据导出 ZIP", description = "仅本人且 COMPLETED 未过期可下载;attachment") @@ -76,7 +76,7 @@ public class PersonalDataExportController { public ResponseEntity download( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); DataExportService.DownloadFile df = dataExportService.download(userId, taskId); ContentDisposition cd = ContentDisposition.attachment() .filename(df.filename(), StandardCharsets.UTF_8) @@ -86,21 +86,4 @@ public class PersonalDataExportController { .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString()) .body(df.content()); } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() - || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - try { - return Long.valueOf(auth.getName()); - } catch (NumberFormatException e) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java index 522f8ae..5de8e31 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java @@ -3,11 +3,13 @@ package com.mydreamjob.deletion; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.security.CurrentUserAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.MediaType; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -24,7 +26,14 @@ import java.util.Set; *
  • PENDING:拦截 POST/PUT/PATCH/DELETE 写操作返回 409/9302;GET 与 OPTIONS 放行
  • * * 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。 - * 未认证、未注销用户不受影响。 + * 未认证、未注销用户不受影响。

    + * + *

    身份解析复用唯一的 {@link CurrentUserAccessor}(R01-A):本拦截器注册在 + * {@code /**},先于所有 Controller 执行,因此绝不能自己解析主体。此前它用 + * {@code Character::isDigit} 判断后直接 {@code Long.valueOf(name)},导致 Long 溢出主体 + * (如 {@code "99999999999999999999"})抛出 {@code NumberFormatException},在到达任何 + * Controller 之前就逃逸为全站 HTTP 500。现在非法主体一律视为未认证并放行, + * 由 Controller 统一返回 401。

    */ @Component @RequiredArgsConstructor @@ -37,6 +46,7 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { private static final Set WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); private final ObjectProvider deletionServiceProvider; + private final CurrentUserAccessor currentUserAccessor; private final ObjectMapper objectMapper; @Override @@ -45,11 +55,9 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - // 匿名用户(principal 为 "anonymousUser",非数字 userId)等同于未认证,直接放行 - if (auth == null || !auth.isAuthenticated() - || "anonymousUser".equals(String.valueOf(auth.getPrincipal())) - || auth.getName() == null || !auth.getName().chars().allMatch(Character::isDigit)) { + // 未认证 / 匿名 / 非法或溢出主体 → 一律放行,交由 Controller 按未登录处理(401)。 + Long userId = resolveUserIdOrNull(SecurityContextHolder.getContext().getAuthentication()); + if (userId == null) { return true; } // 切片测试(@WebMvcTest)等场景可能无此服务 Bean,此时不拦截 @@ -57,7 +65,6 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { if (deletionService == null) { return true; } - Long userId = Long.valueOf(auth.getName()); String path = resolvePath(request); if (DELETION_PATHS.contains(path)) { return true; @@ -72,6 +79,19 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { return true; } + /** + * 解析用户 ID;任何非法主体返回 {@code null},绝不抛出。 + * + *

    该安全依赖由构造器强制注入;装配缺失时应用必须失败启动。

    + */ + private Long resolveUserIdOrNull(Authentication authentication) { + try { + return currentUserAccessor.requireUserId(authentication); + } catch (InsufficientAuthenticationException e) { + return null; + } + } + private boolean reject(HttpServletResponse response, ErrorCode errorCode, HttpServletRequest request) throws Exception { response.setStatus(409); @@ -92,4 +112,4 @@ public class DeletionGuardInterceptor implements HandlerInterceptor { } return uri; } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java index e53f6ed..24d5b57 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java @@ -1,15 +1,15 @@ package com.mydreamjob.preference; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.preference.dto.StudentPreferenceRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -21,13 +21,15 @@ import java.util.Map; public class PreferenceController { private final PreferenceService preferenceService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "查询求职偏好", description = "仅 STUDENT;无记录时返回空偏好对象") @GetMapping("/preferences") public ResponseEntity>> getPreferences(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = preferenceService.getPreferences(userId); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "保存求职偏好", description = "仅 STUDENT;upsert,完全替换传入字段") @@ -35,21 +37,8 @@ public class PreferenceController { public ResponseEntity>> savePreferences( @Valid @RequestBody StudentPreferenceRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = preferenceService.savePreferences(userId, req); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java index 8a16618..be04d75 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java @@ -1,10 +1,10 @@ package com.mydreamjob.privacy; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -16,8 +16,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -31,6 +29,8 @@ import org.springframework.web.bind.annotation.RestController; public class PrivacySettingsController { private final PrivacySettingsService privacySettingsService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation( summary = "查询当前学生隐私设置", @@ -77,9 +77,9 @@ public class PrivacySettingsController { }) @GetMapping public ResponseEntity> getMyPrivacySettings(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); PrivacySettingsResponse response = privacySettingsService.getCurrentUserSettings(userId); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation( @@ -139,20 +139,8 @@ public class PrivacySettingsController { public ResponseEntity> updateMyPrivacySettings( @Valid @RequestBody PrivacySettingsUpdateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); PrivacySettingsResponse response = privacySettingsService.updateCurrentUserSettings(userId, req); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java index 4262ba1..63a088f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java @@ -4,6 +4,8 @@ import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -18,8 +20,6 @@ import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -33,6 +33,8 @@ import java.util.Map; public class ProfileController { private final ProfileService profileService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; // ── 资料 CRUD ──────────────────────────────────────────── @@ -81,10 +83,10 @@ public class ProfileController { }) @GetMapping("/me") public ResponseEntity> getMyProfile(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); profileService.assertStudentRole(userId); StudentProfileResponse response = profileService.toResponse(profileService.findProfileByUserId(userId)); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation(summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建") @@ -92,9 +94,9 @@ public class ProfileController { public ResponseEntity>> updateMyProfile( @Valid @RequestBody StudentProfileUpdateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = profileService.toView(profileService.updateProfile(userId, req)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } // ── 简历管理 ────────────────────────────────────────────── @@ -104,15 +106,15 @@ public class ProfileController { public ResponseEntity>> uploadResume( @RequestParam("file") MultipartFile file, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = profileService.uploadResume(userId, file); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "下载简历", description = "下载当前学生上传的简历文件,仅 STUDENT 角色") @GetMapping("/me/resume") public ResponseEntity downloadResume(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ProfileService.ResumeFile rf = profileService.getResume(userId); MediaType mediaType = resolveContentType(rf.getContentType()); ContentDisposition cd = ContentDisposition.inline() @@ -127,27 +129,14 @@ public class ProfileController { @Operation(summary = "删除简历", description = "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色") @DeleteMapping("/me/resume") public ResponseEntity>> deleteResume(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); profileService.deleteResume(userId); return ResponseEntity.ok(ApiResponse.ok( - Map.of("message", "resume deleted"), getRequestId(request))); + Map.of("message", "resume deleted"), requestIdAccessor.get(request))); } // ── 辅助 ────────────────────────────────────────────────── - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } - private MediaType resolveContentType(String contentType) { if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM; try { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java index d01fc56..f6b93b5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java @@ -1,9 +1,9 @@ package com.mydreamjob.resume; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -12,8 +12,6 @@ import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -35,6 +33,8 @@ public class ResumeVersionController { private final ResumeVersionService resumeVersionService; private final ResumeExportService resumeExportService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "创建简历版本", description = "仅本人(STUDENT);PDF/DOC/DOCX ≤10MB;可附版本名") @PostMapping(value = "/profile/me/resume-versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -42,18 +42,18 @@ public class ResumeVersionController { @RequestParam("file") MultipartFile file, @RequestParam(value = "name", required = false) String name, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.createVersion(userId, name, file); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "简历版本列表", description = "本人 ACTIVE 版本,version_no 倒序") @GetMapping("/profile/me/resume-versions") public ResponseEntity>>> list(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); List> data = resumeVersionService.listVersions(userId) .stream().map(resumeVersionService::toView).collect(Collectors.toList()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "简历版本详情", description = "仅本人可查看;他人 403,不存在 404") @@ -61,9 +61,9 @@ public class ResumeVersionController { public ResponseEntity>> get( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.getVersion(userId, id); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "修改简历版本", description = "本人;可选重命名 + 可选替换文件(替换时删除旧文件)") @@ -73,9 +73,9 @@ public class ResumeVersionController { @RequestParam(value = "file", required = false) MultipartFile file, @RequestParam(value = "name", required = false) String name, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.updateVersion(userId, id, name, file); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "删除简历版本", description = "默认版本需先设其他默认;导出进行中不可删;删除文件并移除记录") @@ -83,10 +83,10 @@ public class ResumeVersionController { public ResponseEntity>> delete( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); resumeVersionService.deleteVersion(userId, id); return ResponseEntity.ok(ApiResponse.ok( - Map.of("message", "resume version deleted"), getRequestId(request))); + Map.of("message", "resume version deleted"), requestIdAccessor.get(request))); } @Operation(summary = "设为默认简历", description = "同用户最多一个默认;已默认幂等;并发冲突 409") @@ -94,9 +94,9 @@ public class ResumeVersionController { public ResponseEntity>> setDefault( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeVersion version = resumeVersionService.setDefault(userId, id); - return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request))); } @Operation(summary = "创建简历导出任务", description = "异步复制该版本文件到导出目录;返回任务状态") @@ -104,9 +104,9 @@ public class ResumeVersionController { public ResponseEntity>> createExportTask( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeExportTask task = resumeExportService.createExportTask(userId, id); - return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), requestIdAccessor.get(request))); } @Operation(summary = "导出任务状态", description = "仅本人可查询;SUCCESS 含输出文件,FAILED 含失败原因") @@ -114,9 +114,9 @@ public class ResumeVersionController { public ResponseEntity>> getExportTask( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeExportTask task = resumeExportService.getExportTask(userId, taskId); - return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), requestIdAccessor.get(request))); } @Operation(summary = "下载导出产物", description = "仅本人且任务 SUCCESS 可下载;attachment;不暴露内部路径") @@ -124,7 +124,7 @@ public class ResumeVersionController { public ResponseEntity downloadExportTask( @PathVariable Long taskId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ResumeExportService.DownloadFile df = resumeExportService.download(userId, taskId); MediaType mediaType = resolveContentType(df.getContentType()); ContentDisposition cd = ContentDisposition.attachment() @@ -136,17 +136,6 @@ public class ResumeVersionController { .body(df.getContent()); } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } - private MediaType resolveContentType(String contentType) { if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM; try { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java index 5770dc1..2bd7497 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java @@ -19,7 +19,7 @@ class GlobalExceptionHandlerAccountDeletionTest { @BeforeEach void setUp() { - handler = new GlobalExceptionHandler(); + handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor()); request = new MockHttpServletRequest(); request.setRequestURI("/api/v1/profile/me/deletion"); } @@ -42,4 +42,4 @@ class GlobalExceptionHandlerAccountDeletionTest { assertEquals(code.getCode(), resp.getBody().getCode()); } } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java index e21eadf..c6b9c37 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java @@ -16,7 +16,7 @@ class GlobalExceptionHandlerHttpStatusTest { @BeforeEach void setUp() { - handler = new GlobalExceptionHandler(); + handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor()); request = new MockHttpServletRequest(); request.setRequestURI("/api/v1/test"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java index b37edcb..97a1bd3 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java @@ -22,7 +22,7 @@ class GlobalExceptionHandlerReportBlockTest { @BeforeEach void setUp() { - handler = new GlobalExceptionHandler(); + handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor()); request = new MockHttpServletRequest(); request.setRequestURI("/api/v1/reports"); } @@ -87,4 +87,4 @@ class GlobalExceptionHandlerReportBlockTest { void blockedSend_externallyUsesForbidden_shouldReturnHttp403() { assertEquals(HttpStatus.FORBIDDEN.value(), statusOf(ErrorCode.FORBIDDEN)); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..59f6c8a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java @@ -0,0 +1,373 @@ +package com.mydreamjob.common.logging; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.auth.support.TestAuthTokens; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-A:请求上下文收束的**端到端**证据。 + * + *

    使用真实的 {@code SecurityFilterChain}、{@link RequestIdFilter} 与 MVC 派发, + * 而不是 Mock 掉新组件后断言 mock 值。覆盖:requestId 在响应头 / 响应体 / MDC / + * {@code AuditContext} 中同源、并发不串号、过滤器结束后清理 MDC、八类非法 principal + * 得到 401 而非 500、错误响应不回显 principal、拒绝请求不产生业务写入。

    + * + *

    被迁移的 8 个 Controller 全部被真实调用一次。

    + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class RequestContextMigrationIntegrationTest { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; + private static final AtomicLong PHONE = new AtomicLong(930_000_000L); + + /** 8 个已迁移 Controller 各自的真实入口 + 预期状态码(STUDENT 身份)。 */ + private static final List ENDPOINTS = List.of( + new MigratedEndpoint("/profile/me", 200, "ProfileController"), + new MigratedEndpoint("/profile/me/preferences", 200, "PreferenceController"), + new MigratedEndpoint("/profile/me/privacy", 200, "PrivacySettingsController"), + new MigratedEndpoint("/profile/me/resume-versions", 200, "ResumeVersionController"), + new MigratedEndpoint("/profile/me/data-export-tasks", 200, "PersonalDataExportController"), + new MigratedEndpoint("/assessments/me", 200, "AssessmentController"), + new MigratedEndpoint("/abilities/me", 200, "AbilityController"), + new MigratedEndpoint("/jobs/99999999/competition-ranking", 404, "CompetitionRankingController")); + + private record MigratedEndpoint(String path, int expectedStatus, String controller) { + } + + /** 在真实 MVC 派发过程中抓取 MDC 与 AuditContext,避免只看过滤器自身。 */ + static final class RequestContextProbe { + static final ThreadLocal MDC_VALUE = new ThreadLocal<>(); + static final ThreadLocal AUDIT_VALUE = new ThreadLocal<>(); + + static void clear() { + MDC_VALUE.remove(); + AUDIT_VALUE.remove(); + } + } + + @TestConfiguration + static class RequestContextProbeConfig implements WebMvcConfigurer { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new HandlerInterceptor() { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + RequestContextProbe.MDC_VALUE.set(MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)); + RequestContextProbe.AUDIT_VALUE.set(AuditContext.from(request).getRequestId()); + return true; + } + }); + } + } + + @Autowired private MockMvc mockMvc; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + private long studentId; + + @BeforeEach + void setUp() { + studentId = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .nickname("r01a-context-student") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()).getId(); + } + + @AfterEach + void tearDown() { + RequestContextProbe.clear(); + MDC.clear(); + } + + // ── 3. 响应头、响应体、MDC、AuditContext 同源 ──────────────── + + @Test + void requestIdIsIdenticalAcrossHeaderBodyMdcAndAuditContext() throws Exception { + String requestId = "r01a-consistent-001"; + + for (MigratedEndpoint endpoint : ENDPOINTS) { + RequestContextProbe.clear(); + MvcResult result = mockMvc.perform(get(endpoint.path()) + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, requestId)) + .andExpect(status().is(endpoint.expectedStatus())) + .andReturn(); + + String where = endpoint.controller() + " " + endpoint.path(); + assertThat(result.getResponse().getHeader(REQUEST_ID_HEADER)) + .as("%s:响应头", where).isEqualTo(requestId); + assertThat(bodyRequestId(result)) + .as("%s:响应体 requestId", where).isEqualTo(requestId); + assertThat(RequestContextProbe.MDC_VALUE.get()) + .as("%s:MDC requestId", where).isEqualTo(requestId); + assertThat(RequestContextProbe.AUDIT_VALUE.get()) + .as("%s:AuditContext requestId", where).isEqualTo(requestId); + } + } + + @Test + void overlongRequestIdIsBoundedAndStillConsistentEverywhere() throws Exception { + String overlong = "L".repeat(120); + + RequestContextProbe.clear(); + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, overlong)) + .andExpect(status().isOk()) + .andReturn(); + + String expected = overlong.substring(0, 32); + assertThat(result.getResponse().getHeader(REQUEST_ID_HEADER)).isEqualTo(expected); + assertThat(bodyRequestId(result)).isEqualTo(expected); + assertThat(RequestContextProbe.MDC_VALUE.get()).isEqualTo(expected); + assertThat(RequestContextProbe.AUDIT_VALUE.get()).isEqualTo(expected); + assertThat(bodyRequestId(result)).isNotEqualTo(overlong); + } + + // ── 4. 并发不串号 ──────────────────────────────────────────── + + @Test + void concurrentRequestsDoNotLeakRequestIdAcrossThreads() throws Exception { + int concurrency = 8; + ExecutorService pool = Executors.newFixedThreadPool(concurrency); + CountDownLatch start = new CountDownLatch(1); + ConcurrentLinkedQueue failures = new ConcurrentLinkedQueue<>(); + + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < concurrency; i++) { + String expected = "r01a-concurrent-" + i; + futures.add(pool.submit(() -> { + try { + start.await(); + MvcResult result = mockMvc.perform(get("/profile/me/preferences") + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, expected)) + .andExpect(status().isOk()) + .andReturn(); + String header = result.getResponse().getHeader(REQUEST_ID_HEADER); + String body = bodyRequestId(result); + if (!expected.equals(header) || !expected.equals(body)) { + failures.add(expected + " -> header=" + header + ", body=" + body); + } + } catch (Exception e) { + failures.add(expected + " -> " + e); + } + return null; + })); + } + start.countDown(); + for (var future : futures) { + future.get(60, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + + assertThat(failures).as("并发请求不得互相串用 requestId").isEmpty(); + } + + // ── 5. 过滤器结束后清理 MDC ───────────────────────────────── + + @Test + void mdcIsClearedAfterRequestCompletes() throws Exception { + mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken()) + .header(REQUEST_ID_HEADER, "r01a-mdc-cleanup")) + .andExpect(status().isOk()); + + assertThat(MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)) + .as("请求结束后不得残留 requestId").isNull(); + } + + // ── 6./7. 非法 principal → 401,不回显 principal ────────────── + + @Test + void illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal() throws Exception { + List cases = List.of( + new PrincipalCase("匿名令牌", new AnonymousAuthenticationToken( + "r01a-key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))), + new PrincipalCase("已认证但名称为 anonymousUser", + UsernamePasswordAuthenticationToken.authenticated("anonymousUser", null, List.of())), + new PrincipalCase("空白主体", + UsernamePasswordAuthenticationToken.authenticated(" ", null, List.of())), + new PrincipalCase("非数字主体", + UsernamePasswordAuthenticationToken.authenticated(BAD_PRINCIPAL, null, List.of())), + new PrincipalCase("Unicode 数字主体", + UsernamePasswordAuthenticationToken.authenticated("٤٢", null, List.of())), + new PrincipalCase("带正号主体", + UsernamePasswordAuthenticationToken.authenticated("+42", null, List.of())), + new PrincipalCase("带负号主体", + UsernamePasswordAuthenticationToken.authenticated("-7", null, List.of())), + new PrincipalCase("零主体", + UsernamePasswordAuthenticationToken.authenticated("0", null, List.of())), + new PrincipalCase("负数主体", + UsernamePasswordAuthenticationToken.authenticated("-1", null, List.of())), + new PrincipalCase("Long 溢出主体", + UsernamePasswordAuthenticationToken.authenticated("99999999999999999999", null, List.of()))); + + for (PrincipalCase principalCase : cases) { + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .with(authentication(principalCase.authentication())) + .header(REQUEST_ID_HEADER, "r01a-bad-principal")) + .andReturn(); + + String as = principalCase.label(); + assertThat(result.getResponse().getStatus()) + .as("%s:必须是 401 而不是 5xx(迁移前会因 NumberFormatException 逃逸为 500)", as) + .isEqualTo(401); + assertThat(result.getResponse().getContentAsString()) + .as("%s:错误响应不得回显 principal", as) + .doesNotContain(BAD_PRINCIPAL) + .doesNotContain("anonymousUser") + .doesNotContain("NumberFormatException") + .doesNotContain("at com.mydreamjob"); + } + } + + @Test + void noCredentialsAtAllReturns401WithoutServerError() throws Exception { + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .header(REQUEST_ID_HEADER, "r01a-no-credentials")) + .andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(401); + assertThat(result.getResponse().getContentAsString()).doesNotContain("at com.mydreamjob"); + } + + /** + * 反例边界:一个「非匿名但 isAuthenticated()==false」的合成令牌会在**安全过滤器链** + * 就被判定为拒绝(403),根本到不了 Controller 与 {@code CurrentUserAccessor}。 + * + *

    这是迁移前既有的链行为,R01-A 明确不把原 403 改成 401。此处只锁定 + * 「不是 5xx、不回显 principal」,避免为了凑一个 401 而改动安全语义。

    + */ + @Test + void syntheticUnauthenticatedTokenIsRejectedByChainWithoutServerError() throws Exception { + Authentication synthetic = new UsernamePasswordAuthenticationToken(BAD_PRINCIPAL, null); + + MvcResult result = mockMvc.perform(get("/profile/me/privacy") + .with(authentication(synthetic)) + .header(REQUEST_ID_HEADER, "r01a-synthetic")) + .andReturn(); + + int status = result.getResponse().getStatus(); + assertThat(status) + .as("必须是 4xx 客户端拒绝,不得是 5xx") + .isBetween(400, 499) + .isNotEqualTo(200); + assertThat(result.getResponse().getContentAsString()) + .as("错误响应不得回显 principal") + .doesNotContain(BAD_PRINCIPAL) + .doesNotContain("at com.mydreamjob"); + } + + private record PrincipalCase(String label, Authentication authentication) { + } + + // ── 9. 拒绝请求无业务写入 ──────────────────────────────────── + + @Test + void rejectedRequestsPerformNoBusinessWrite() throws Exception { + List tables = List.of( + new BusinessTable("student_privacy_settings", "student_user_id"), + new BusinessTable("student_preferences", "student_user_id"), + new BusinessTable("resume_versions", "user_id"), + new BusinessTable("personal_data_export_tasks", "user_id"), + new BusinessTable("assessment_attempts", "student_user_id"), + new BusinessTable("ability_attempts", "student_user_id")); + var before = rowCounts(tables, studentId); + + mockMvc.perform(get("/profile/me/privacy") + .with(authentication(UsernamePasswordAuthenticationToken.authenticated( + BAD_PRINCIPAL, null, List.of())))) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/privacy")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/preferences") + .with(authentication(new AnonymousAuthenticationToken( + "r01a-key", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))))) + .andExpect(status().isUnauthorized()); + + assertThat(rowCounts(tables, studentId)) + .as("被拒绝的请求不得写入任何业务表") + .isEqualTo(before); + } + + private record BusinessTable(String name, String ownerColumn) { + } + + private List rowCounts(List tables, long userId) { + List counts = new ArrayList<>(); + for (BusinessTable table : tables) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table.name() + " WHERE " + table.ownerColumn() + " = ?", + Long.class, userId); + counts.add(table.name() + "=" + count); + } + return counts; + } + + // ── 辅助 ───────────────────────────────────────────────────── + + private String studentToken() { + return TestAuthTokens.bearer(studentId, "STUDENT"); + } + + private String bodyRequestId(MvcResult result) throws Exception { + JsonNode root = mapper.readTree(result.getResponse().getContentAsString()); + JsonNode requestId = root.get("requestId"); + return requestId == null ? null : requestId.asText(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java new file mode 100644 index 0000000..85e6874 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java @@ -0,0 +1,150 @@ +package com.mydreamjob.common.logging; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * R01-A requestId 读取器边界。 + * + *

    核心约束:accessor 只能复用 {@link RequestIdFilter} 产出的规范化值, + * 不得自建第二套生成规则,也不得把超长/非法客户端头原样返回。

    + */ +class RequestIdAccessorTest { + + private final RequestIdFilter filter = new RequestIdFilter(); + private final RequestIdAccessor accessor = new RequestIdAccessor(); + + // ── 1. 合法客户端 requestId 原样使用 ──────────────────────── + + @Test + void legalClientRequestIdIsUsedVerbatimAcrossAllOutputs() throws Exception { + String clientId = "client-abc_123"; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, clientId); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference seenByController = new AtomicReference<>(); + AtomicReference seenInMdc = new AtomicReference<>(); + + filter.doFilter(request, response, (wrapped, ignored) -> { + HttpServletRequest req = (HttpServletRequest) wrapped; + seenByController.set(accessor.get(req)); + seenInMdc.set(org.slf4j.MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)); + }); + + assertEquals(clientId, seenByController.get(), "合法客户端值必须原样使用"); + assertEquals(seenByController.get(), response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), + "响应头必须与 Controller 读到的一致"); + assertEquals(seenByController.get(), seenInMdc.get(), "MDC 必须与 Controller 读到的一致"); + } + + // ── 2. 缺失 / 空白 / 超长时使用过滤器规范化值 ──────────────── + + @Test + void missingBlankAndOverlongIdsUseFilterNormalizedValue() throws Exception { + record Case(String header, int expectedLength, String label) { + } + Case[] cases = { + new Case(null, 16, "缺失"), + new Case(" ", 16, "纯空白"), + new Case("\t", 16, "制表符"), + new Case("x".repeat(33), 32, "刚好超一位"), + new Case("y".repeat(200), 32, "远超声明的 32 字符上限"), + }; + + for (Case testCase : cases) { + MockHttpServletRequest request = new MockHttpServletRequest(); + if (testCase.header() != null) { + request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, testCase.header()); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference seenByController = new AtomicReference<>(); + + filter.doFilter(request, response, (wrapped, ignored) -> + seenByController.set(accessor.get((HttpServletRequest) wrapped))); + + String value = seenByController.get(); + assertNotNull(value, testCase.label() + ":必须有值"); + assertEquals(testCase.expectedLength(), value.length(), testCase.label() + ":长度必须被收敛"); + assertEquals(response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), value, + testCase.label() + ":与响应头同源"); + if (testCase.header() == null) { + continue; + } + assertNotEquals(testCase.header(), value, testCase.label() + ":不得原样回显客户端头"); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "bad id", "bad\tid", "bad\nid", "bad\rid", "中文", "