Browse Source

refactor: centralize request identity and request ID handling

master
Zhang Shun 1 month ago
parent
commit
0b5b8495b1
  1. 3
      docs/backend-development-roadmap.md
  2. 110
      docs/r01-a-request-context-implementation-instruction.md
  3. 284
      docs/r01-a-request-context-implementation-report.md
  4. 81
      docs/r01-structure-performance-design.md
  5. 42
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
  6. 44
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
  7. 7
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  8. 37
      mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java
  9. 44
      mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java
  10. 24
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java
  11. 39
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java
  12. 34
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java
  13. 27
      mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java
  14. 28
      mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java
  15. 37
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
  16. 53
      mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java
  17. 2
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java
  18. 2
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java
  19. 2
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java
  20. 373
      mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java
  21. 150
      mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java
  22. 5
      mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java
  23. 5
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java
  24. 21
      mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java
  25. 17
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java

3
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节为准,移出一期 |

110
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 第一批请求上下文收束完成,待独立代码复验。`

284
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<CurrentUserAccessor>` 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 9302 / 9303 语义、白名单路径与 OPTIONS 放行**均未改动**(`DeletionGuardInterceptorTest` 7 条全绿,含 4 条原有拦截语义用例)。
这条修复是 §1 白名单之外的第 9 个生产文件,**在此明确标出**,未混入 8 个 Controller 的改动叙述。
## 11. 全量测试与干净构建
| 项目 | Tests run | Failures | Errors | Skipped | 构建 |
|---|---|---|---|---|---|
| 独立整改定向复验 | **75** | **0** | **0** | **0** | `BUILD SUCCESS` |
| `mvn clean verify` | **1915** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) |
三个日志中均不存在任何 `Skipped: <非零>` 的测试类。
期间发生过两次真实失败并已修正:实施阶段首次全量测试暴露 `CompanyControllerMvcTest` 未提供 `RequestIdAccessor`;最终处理是在该测试切片显式提供依赖,而不是弱化生产装配。独立整改第一次定向运行又因新增参数化测试同时残留 `@Test` 而产生 1 个 ParameterResolution Error;删除多余注解并补齐换行、回车边界后,同一集合 75/75 通过。两次失败均未通过删除断言或生产兜底绕过。
## 12. OpenAPI
```
python docs/verify_openapi.py
paths: 176
operations: 214
All checks passed!
```
`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。本批未新增、删除或重命名任何 path / operation,未改变参数名、默认值、响应 DTO 或错误码;8 个 Controller 的 Swagger 注解一律原样保留。
## 13. 修改文件清单
**新增**
```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、表、索引或依赖。

81
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。

42
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<List<AbilityTypeResponse>> 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<AbilityAttemptResponse> 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<AbilityAttemptResponse> 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<AbilityAttemptResponse> 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<AbilityResultResponse> 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<AbilityResultResponse> 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<AbilityHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size);
Page<AbilityHistoryItem> 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));
}
}

44
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<List<AssessmentTypeResponse>> 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<List<AssessmentQuestionResponse>> 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<AssessmentAttemptResponse> 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<AssessmentAttemptResponse> 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<AssessmentAttemptResponse> 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<AssessmentResultResponse> 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<AssessmentResultResponse> 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<AssessmentHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size);
Page<AssessmentHistoryItem> 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));
}
}

7
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<ApiResponse<Void>> 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);
}
}

37
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} 与异常响应使用同一个值。
*
* <p>取值优先级:</p>
* <ol>
* <li>{@link RequestIdFilter} 写入的请求属性(规范化后的值);</li>
* <li>请求头 {@code X-Request-Id} —— 过滤器已通过请求包装器把它替换为规范化值,
* 因此这里得到的仍是同一个值;若调用路径绕过了过滤器,则由
* {@link RequestIdFilter#normalizeRequestId(String)} 按同一规则收敛,
* **不会**把超长或非法客户端头原样返回。</li>
* </ol>
*
* <p>本类不产生第二套生成规则:缺失或空白时复用过滤器同一处
* {@code normalizeRequestId} 逻辑。</p>
*/
@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));
}
}

44
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,便于日志追踪。
*
* <p>本过滤器是 requestId 的**唯一产出点**:规范化后的值同时写入请求属性
* {@link #REQUEST_ID_ATTRIBUTE}、MDC、响应头,并覆盖请求包装器的 {@code X-Request-Id}
* 头,使 Controller、{@code AuditContext} 与异常响应读到同一个值。</p>
*/
@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);
}
}

24
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<ApiResponse<CompetitionRankingResponse>> 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)));
}
}

39
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<ApiResponse<DataExportTaskResponse>> 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<ApiResponse<DataExportTaskResponse>> 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<byte[]> 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");
}
}

34
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;
* <li>PENDING:拦截 POST/PUT/PATCH/DELETE 写操作返回 409/9302;GET 与 OPTIONS 放行</li>
* </ul>
* 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。
* 未认证、未注销用户不受影响。
* 未认证、未注销用户不受影响。</p>
*
* <p><strong>身份解析复用唯一的 {@link CurrentUserAccessor}</strong>(R01-A):本拦截器注册在
* {@code /**},先于所有 Controller 执行,因此绝不能自己解析主体。此前它用
* {@code Character::isDigit} 判断后直接 {@code Long.valueOf(name)},导致 Long 溢出主体
* (如 {@code "99999999999999999999"})抛出 {@code NumberFormatException},在到达任何
* Controller 之前就逃逸为全站 HTTP 500。现在非法主体一律视为未认证并放行,
* 由 Controller 统一返回 401。</p>
*/
@Component
@RequiredArgsConstructor
@ -37,6 +46,7 @@ public class DeletionGuardInterceptor implements HandlerInterceptor {
private static final Set<String> WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private final ObjectProvider<AccountDeletionService> 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},绝不抛出。
*
* <p>该安全依赖由构造器强制注入;装配缺失时应用必须失败启动。</p>
*/
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);

27
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<ApiResponse<Map<String, Object>>> getPreferences(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> 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<ApiResponse<Map<String, Object>>> savePreferences(
@Valid @RequestBody StudentPreferenceRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> 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)));
}
}

28
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<ApiResponse<PrivacySettingsResponse>> 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<ApiResponse<PrivacySettingsResponse>> 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)));
}
}

37
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<ApiResponse<StudentProfileResponse>> 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<ApiResponse<Map<String, Object>>> updateMyProfile(
@Valid @RequestBody StudentProfileUpdateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> 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<ApiResponse<Map<String, Object>>> uploadResume(
@RequestParam("file") MultipartFile file,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> 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<byte[]> 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<ApiResponse<Map<String, Object>>> 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 {

53
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<ApiResponse<List<Map<String, Object>>>> list(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<byte[]> 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 {

2
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");
}

2
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");
}

2
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");
}

373
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:请求上下文收束的**端到端**证据。
*
* <p>使用真实的 {@code SecurityFilterChain}、{@link RequestIdFilter} 与 MVC 派发,
* 而不是 Mock 掉新组件后断言 mock 值。覆盖:requestId 在响应头 / 响应体 / MDC /
* {@code AuditContext} 中同源、并发不串号、过滤器结束后清理 MDC、八类非法 principal
* 得到 401 而非 500、错误响应不回显 principal、拒绝请求不产生业务写入。</p>
*
* <p>被迁移的 8 个 Controller 全部被真实调用一次。</p>
*/
@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<MigratedEndpoint> 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<String> MDC_VALUE = new ThreadLocal<>();
static final ThreadLocal<String> 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<String, Object> 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<String> failures = new ConcurrentLinkedQueue<>();
try {
List<java.util.concurrent.Future<?>> 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<PrincipalCase> 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}。
*
* <p>这是迁移前既有的链行为,R01-A 明确不把原 403 改成 401。此处只锁定
* 「不是 5xx、不回显 principal」,避免为了凑一个 401 而改动安全语义。</p>
*/
@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<BusinessTable> 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<String> rowCounts(List<BusinessTable> tables, long userId) {
List<String> 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();
}
}

150
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 读取器边界。
*
* <p>核心约束:accessor 只能复用 {@link RequestIdFilter} 产出的规范化值,
* 不得自建第二套生成规则,也不得把超长/非法客户端头原样返回。</p>
*/
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<String> seenByController = new AtomicReference<>();
AtomicReference<String> 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<String> 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", "中文", "<script>",
"bad\\id", "bad/id", "bad!id", "bad&id"
})
void illegalCharacterHeaderIsReplacedWithoutEchoingRawInput(String hostile) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, hostile);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<String> seenByController = new AtomicReference<>();
AtomicReference<String> seenInMdc = new AtomicReference<>();
filter.doFilter(request, response, (wrapped, ignored) -> {
seenByController.set(accessor.get((HttpServletRequest) wrapped));
seenInMdc.set(org.slf4j.MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE));
});
assertEquals(response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), seenByController.get());
assertEquals(seenInMdc.get(), seenByController.get());
assertNotEquals(hostile, seenByController.get(), "不得原样回显客户端头");
assertTrue(seenByController.get().matches("[0-9a-f]{16}"),
"非法输入必须整体替换为服务端生成的十六进制 requestId");
}
// ── 10. 反证:绕过 filter 时不得返回原始超长头 ───────────────
@Test
void overlongHeaderIsBoundedEvenWhenFilterIsBypassed() {
String overlong = "z".repeat(400);
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, overlong);
String value = accessor.get(request);
assertNotEquals(overlong, value, "绕过过滤器时 accessor 不得返回客户端原值");
assertEquals(32, value.length(), "必须按过滤器同一规则收敛到 32 字符");
assertEquals(overlong.substring(0, 32), value);
}
@Test
void tamperedHeaderCannotOverrideNormalizedAttribute() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE, "normalized-by-filter");
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, "attacker-supplied-value");
assertEquals("normalized-by-filter", accessor.get(request),
"过滤器产出的请求属性优先于请求头");
}
@Test
void blankAttributeFallsBackToHeader() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE, " ");
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, "resp-1");
assertEquals("resp-1", accessor.get(request));
}
@Test
void nullRequestStillYieldsUsableId() {
String value = accessor.get(null);
assertNotNull(value);
assertEquals(16, value.length());
}
}

5
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java

@ -28,12 +28,17 @@ class RequestIdFilterTest {
requestIdSeenByController.set(((HttpServletRequest) wrappedRequest).getHeader("X-Request-Id"));
assertEquals(32, requestIdSeenByController.get().length());
assertEquals(requestIdSeenByController.get(), get("requestId"));
// R01-A:规范化值同时写入请求属性,供 RequestIdAccessor 读取
assertEquals(requestIdSeenByController.get(),
((HttpServletRequest) wrappedRequest).getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE));
};
filter.doFilter(request, response, chain);
assertEquals(32, response.getHeader("X-Request-Id").length());
assertEquals(response.getHeader("X-Request-Id"), requestIdSeenByController.get());
assertEquals(response.getHeader("X-Request-Id"),
request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE));
assertNull(get("requestId"), "MDC must be cleared after the request");
}

5
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java

@ -2,6 +2,8 @@ package com.mydreamjob.company;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -17,7 +19,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(CompanyController.class)
@AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class,
AuthSessionService.class, java.time.Clock.class})
AuthSessionService.class, java.time.Clock.class,
RequestIdAccessor.class, CurrentUserAccessor.class})
class CompanyControllerMvcTest {
@Autowired

21
mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java

@ -11,6 +11,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@ -22,6 +23,7 @@ import java.util.concurrent.atomic.AtomicLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@ -162,6 +164,25 @@ class DeletionGuardInterceptorTest {
.andExpect(status().isUnauthorized());
}
/**
* R01-A 回归:本拦截器注册在 {@code /**} 上,先于所有 Controller 执行,
* 因此非法主体绝不能在拦截器层逃逸为 500。
*
* <p>修复前 {@code Long.valueOf(auth.getName())} 对 Long 溢出主体(
* {@code "99999999999999999999"})抛出 {@code NumberFormatException},
* 在到达任何 Controller 之前就返回全站 HTTP 500。</p>
*/
@Test
void illegalOrOverflowingPrincipal_returns401InsteadOf500() throws Exception {
for (String principal : new String[]{
"99999999999999999999", "٤٢", "+42", "-1", "0", " ", "not-a-number"}) {
mockMvc.perform(get("/profile/me/privacy")
.with(authentication(UsernamePasswordAuthenticationToken.authenticated(
principal, null, java.util.List.of()))))
.andExpect(status().isUnauthorized());
}
}
@Test
void options_isAllowed() throws Exception {
long uid = createStudent();

17
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java

@ -4,12 +4,15 @@ import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.security.CurrentUserAccessor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@ -17,6 +20,7 @@ import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
@ -37,6 +41,14 @@ class ResumeControllerTest {
@Mock
private Authentication authentication;
/** R01-A:Controller 身份解析已收束到唯一的 CurrentUserAccessor。 */
@Spy
private CurrentUserAccessor currentUserAccessor = new CurrentUserAccessor();
/** R01-A:requestId 已收束到 RequestIdAccessor。 */
@Spy
private RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
@InjectMocks
private ProfileController controller;
@ -65,7 +77,10 @@ class ResumeControllerTest {
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
"application/pdf", "data".getBytes());
assertThrows(BusinessException.class,
// R01-A:未登录语义由 CurrentUserAccessor 统一产出
// (InsufficientAuthenticationException 由 GlobalExceptionHandler 映射为 401),
// 不再由 Controller 自己抛 BusinessException(2001)。
assertThrows(InsufficientAuthenticationException.class,
() -> controller.uploadResume(file, request));
}

Loading…
Cancel
Save