diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md
index 6db8591..bbcb93d 100644
--- a/docs/backend-development-roadmap.md
+++ b/docs/backend-development-roadmap.md
@@ -14,7 +14,8 @@
| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 |
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
-| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。新增 50 条真实库测试:H2 真实 Repository 19、H2 真实 Service 9、H2 查询副作用 4、真实 MySQL 8(Testcontainers)规格对账 13、MySQL 查询副作用 4、MySQL EXPLAIN 1;全部 `Failures=0 / Errors=0 / Skipped=0` | 不改接口、DTO、状态机、权限与搜索语义;不新增 Flyway / ErrorCode / 公网接口;OpenAPI 保持 176/214;EXPLAIN 观察项(前导通配符、filesort)移交 R01 |
+| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 |
+| R01 结构与性能收束 | 已完成基线审计和权威设计,建立独立分支 `refactor/r01-structure-performance`;确认身份解析 41 个文件、requestId 私有方法 46 个文件及 O(n²)/N+1 热点 | 按 R01-A 至 R01-E 分批实施;第一批只迁移 8 个 Controller 的身份和 requestId,不与异常映射、评分或 SQL 优化混改 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |
diff --git a/docs/r01-a-request-context-implementation-instruction.md b/docs/r01-a-request-context-implementation-instruction.md
new file mode 100644
index 0000000..5955c68
--- /dev/null
+++ b/docs/r01-a-request-context-implementation-instruction.md
@@ -0,0 +1,110 @@
+# R01-A 请求身份与 requestId 收束实施指令
+
+## 一、任务性质
+
+本轮只完成 R01-A 第一批,不进入异常映射、评分规则、百分位、消息未读、PersonalDataCollector 或推荐权重重构。
+
+允许修改公共请求上下文组件、最多 8 个 Controller 及其测试和文档;禁止新增接口、ErrorCode、Flyway、表、索引或依赖。
+
+## 二、开始前核查
+
+在 `C:\Users\15642\Desktop\zhilai` 执行并记录:
+
+```powershell
+git branch --show-current
+git status --short
+git diff --cached --name-only
+git log -5 --oneline
+git diff --check
+```
+
+分支必须为 `refactor/r01-structure-performance`,HEAD 必须包含 Q01 检查点 `77dc624`,暂存区必须为空。完整搜索适用的 AGENTS.md;保护全部历史未跟踪文件。
+
+## 三、先写基线清单
+
+列出所有私有 `resolveUserId/userId/requireAdmin` 与 `requestId/getRequestId` 方法,记录:文件、公开端点、当前异常、角色检查位置、资源归属检查位置、requestId 来源。
+
+第一批只迁移以下 8 个 Controller:
+
+1. `AssessmentController`
+2. `AbilityController`
+3. `ProfileController`
+4. `ResumeVersionController`
+5. `PreferenceController`
+6. `CompetitionRankingController`
+7. `PersonalDataExportController`
+8. `PrivacySettingsController`
+
+若其中存在与未提交修改重叠,停止并报告,不得改换到其它文件凑数。
+
+## 四、身份组件
+
+复用现有 `CurrentUserAccessor`,不要创建第二个身份解析器。Controller 通过构造器注入并调用 `requireUserId()`。
+
+必须保持:
+
+- null Authentication、AnonymousAuthenticationToken、`anonymousUser`、未认证、空白主体、Unicode 数字、带正负号、Long 溢出和 userId<=0 全部 401;
+- 不回显主体内容、异常类或堆栈;
+- 不接受请求参数中的 userId;
+- 角色与资源归属仍由原 Service/Controller 的既有位置判断;
+- 不将原 403 或 404 改成 401。
+
+删除迁移 Controller 内的私有身份解析代码和不再使用的 SecurityContext 依赖。
+
+## 五、requestId 组件
+
+先完整阅读 `common/logging/RequestIdFilter`、`RequestLoggingFilter`、`AuditContext` 和 `GlobalExceptionHandler`。
+
+新增 `common/logging/RequestIdAccessor`,提供 `String get(HttpServletRequest request)`。它必须复用过滤器产出的规范化 requestId;禁止重新生成第二个值,禁止直接信任超长/非法客户端头。
+
+若 RequestIdFilter 当前没有 request attribute,允许增加一个常量属性并写入规范化后的值。响应头、MDC、Controller body、AuditContext 与异常响应必须保持同源。过滤器结束后仍清理 MDC。
+
+只替换上述 8 个 Controller 的私有 requestId 方法。GlobalExceptionHandler 本轮只允许改为调用 accessor,不得重构 HTTP 状态映射。
+
+## 六、必须先新增的测试
+
+新增或加强:
+
+- `RequestIdAccessorTest`
+- `RequestContextMigrationIntegrationTest`
+- 8 个 Controller 的既有权限回归
+
+至少覆盖:
+
+1. 合法客户端 requestId 原样使用;
+2. 缺失、空白、超长、非法字符时使用过滤器规范化值;
+3. 响应头、响应 body、MDC 日志与 AuditContext 同一值;
+4. 并发请求不串 requestId;
+5. 过滤器完成后 MDC 清理;
+6. 八类非法 principal 均 401 而非 500;
+7. 错误响应不包含 principal;
+8. STUDENT/COMPANY/ADMIN 权限矩阵与改动前一致;
+9. 拒绝请求无业务写入;
+10. 反证:绕过 filter 直接传超长请求头时 accessor 不得返回原值。
+
+禁止只 Mock 新组件后断言 Controller 返回 mock 值;至少一组测试必须使用真实 SecurityFilterChain、RequestIdFilter 和 MVC。
+
+## 七、量化验收
+
+整改前后分别统计:
+
+```powershell
+rg -l "private (Long|long) resolveUserId|private .*userId\(\)" mydreamjob-server/src/main/java
+rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java
+```
+
+第一批必须精确减少对应 8 个 Controller 的重复方法,不得声称全仓已完成。报告剩余数量和文件清单。
+
+## 八、验证
+
+依次运行定向测试、八个模块关联测试、`mvn test`、`mvn clean verify`、`python ..\docs\verify_openapi.py`、`git diff --check`。
+
+预期 OpenAPI 保持 176/214;测试数量以实际为准,Failures=0、Errors=0、Skipped=0。
+
+## 九、停止条件
+
+完成 R01-A 第一批代码、测试和报告后停止。暂存区保持空;不得 commit/push;不得进入 R01-B/C/D/E。
+
+最终状态只能写:
+
+`R01-A 第一批请求上下文收束完成,待独立代码复验。`
diff --git a/docs/r01-a-request-context-implementation-report.md b/docs/r01-a-request-context-implementation-report.md
new file mode 100644
index 0000000..ccd7952
--- /dev/null
+++ b/docs/r01-a-request-context-implementation-report.md
@@ -0,0 +1,284 @@
+# R01-A 第一批请求上下文收束实施报告
+
+> **状态**:R01-A 第一批请求上下文收束及阻断整改已完成,并通过独立代码复验与干净构建。
+
+## 1. 范围与分支
+
+| 项目 | 值 |
+|---|---|
+| 分支 | `refactor/r01-structure-performance` |
+| HEAD | `77dc624`(Q01 检查点,见 `fix: align job search queries across databases`) |
+| 范围 | 只完成 R01-A 第一批:8 个 Controller 的身份解析与 requestId 收束 |
+| 未进入 | R01-B(异常映射)、R01-C(纯计算)、R01-D(查询收束)、R01-E(真实环境复验),以及 `UserRoleGuard`、`PersonalDataCollector`、评分规则、百分位、消息未读、推荐权重 |
+| AGENTS.md | 仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md` |
+
+## 2. 开始前核查
+
+```
+git branch --show-current -> refactor/r01-structure-performance ✓
+git log -5 --oneline -> HEAD = 77dc624(Q01 检查点) ✓
+git diff --cached --name-only -> (空) ✓ 暂存区为空
+git diff --check -> 仅 LF/CRLF 提示,无空白错误
+```
+
+工作区另有一处**与本次无关**的既存未提交修改:`docs/backend-development-roadmap.md`(Q01 行更新 + R01 行)。它不属于 8 个 Controller 的任何文件,不构成 §3 的「重叠」停止条件,本轮未触碰该文件。
+
+## 3. 基线清单(整改前)
+
+### 3.1 第一批 8 个 Controller
+
+| # | Controller | 公开端点 | 整改前身份异常 | 角色检查位置 | 资源归属检查位置 | requestId 来源 |
+|---|---|---|---|---|---|---|
+| 1 | `assessment/AssessmentController` | `GET /assessments/types`、`GET /assessments/{type}/questions`、`POST /assessments/attempts`、`GET /assessments/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /assessments/me` | `BusinessException(UNAUTHORIZED)`;非数字主体 `NumberFormatException` → 500 | `AssessmentAttemptService.requireStudent(userId)` | 答卷归属在 `AssessmentAttemptService`(非本人 → 2002/403) | `request.getHeader("X-Request-Id")` |
+| 2 | `ability/AbilityController` | `GET /abilities/types`、`POST /abilities/attempts`、`GET /abilities/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /abilities/me` | 同上 | `AbilityAttemptService.requireStudent(userId)` | 答卷归属在 `AbilityAttemptService` | 同上 |
+| 3 | `profile/ProfileController` | `GET /profile/me`、`PUT /profile/me`、`POST /profile/me/resume`、`GET /profile/me/resume`、`DELETE /profile/me/resume` | 同上(全限定名写法) | `ProfileService.assertStudentRole(userId)`(Controller 显式调用) | 按当前 userId 取本人资料,无跨用户入参 | 同上 |
+| 4 | `resume/ResumeVersionController` | `POST/GET /profile/me/resume-versions`、`GET/PUT/DELETE /profile/me/resume-versions/{id}`、`POST .../{id}/default`、`POST .../{id}/export`、`GET /resume-export-tasks/{taskId}`、`GET .../download` | 同上 | `ResumeVersionService` → `ProfileService.assertStudentRole` | `version.getUserId().equals(userId)`,否则 2002/403 | 同上 |
+| 5 | `preference/PreferenceController` | `GET /profile/me/preferences`、`PUT /profile/me/preferences` | 同上(全限定名写法) | `PreferenceService.assertStudentRole` | 键为当前 userId | 同上 |
+| 6 | `competition/CompetitionRankingController` | `GET /jobs/{jobId}/competition-ranking` | 同上 | `CompetitionRankingService.assertStudentRole` | 未投递该岗位 → 2002/403;岗位不存在 → 7001/404 | 同上 |
+| 7 | `dataexport/PersonalDataExportController` | `POST/GET /profile/me/data-export-tasks`、`GET .../{taskId}`、`GET .../{taskId}/download` | 同上,且已 try/catch `NumberFormatException`(但仍映射为 2001 → HTTP **400**) | 无角色限制(STUDENT/COMPANY/ADMIN 均可操作本人任务) | `task.getUserId().equals(userId)`,否则 2002/403 | 同上 |
+| 8 | `privacy/PrivacySettingsController` | `GET /profile/me/privacy`、`PUT /profile/me/privacy` | 同上 | `PrivacySettingsService.assertStudentRole` | 键为当前 userId | 同上 |
+
+**共性**:8 个类各自持有一份 `private Long resolveUserId(HttpServletRequest)` 与一份 `private String getRequestId/requestId(HttpServletRequest)`,共 16 个重复方法;身份只从 `SecurityContextHolder` 取,requestId 只从请求头取。
+
+### 3.2 全仓量化基线
+
+```powershell
+rg -l "private (Long|long) resolveUserId|private .*userId()|private .*requireAdmin" mydreamjob-server/src/main/java # 42 个文件 / 44 处
+rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java # 46 个文件 / 46 处
+```
+
+## 4. 身份组件(复用,不新建)
+
+沿用既有 `security/CurrentUserAccessor`,**未创建第二个身份解析器**。8 个 Controller 通过构造器注入(`@RequiredArgsConstructor` 的 `final` 字段)并调用 `requireUserId()`。
+
+保持不变的语义:
+
+- `null` Authentication、未认证、`AnonymousAuthenticationToken`、主体为 `anonymousUser`、空白主体、Unicode 数字(`٤٢`)、带正负号(`+42` / `-7`)、Long 溢出(`99999999999999999999`)、`userId <= 0` —— 全部按未登录处理;
+- 异常文本固定为 `authentication required`,**不回显主体、异常类或堆栈**;
+- 不接受请求参数中的 `userId`(未新增任何 userId 入参);
+- 角色与资源归属仍留在原 Service / Controller 位置判断,**未从 Controller 上移到身份组件**;
+- 未把任何原 403 或 404 改成 401。
+
+### 4.1 一处必须说明的既有差异(未扩大)
+
+整改前,8 个 Controller 的私有解析在非法主体上抛 `BusinessException(UNAUTHORIZED)`;由于 `GlobalExceptionHandler.resolveHttpStatus` 未把 `UNAUTHORIZED(2001)` 列入任何状态段,它落到「其余 → 400」,即**非法主体此前是 HTTP 400**(`PersonalDataExportController` 亦然)。
+
+整改后统一为 `InsufficientAuthenticationException` → `AuthenticationException` 处理器 → **HTTP 401**。这与 R01 设计第 4 节「非法主体为 401」一致,也正是本批的目标;对**真实请求**而言该路径此前不可达(`SecurityConfig` 对相关路径要求 `authenticated()`,匿名请求在过滤器链就被拒绝并返回 401),因此没有改变任何真实客户端可观察的既有契约。唯一受影响的单元测试 `ResumeControllerTest.uploadResume_unauthenticated_shouldReturn401` 已改为断言新的 401 语义(断言未放宽)。
+
+## 5. requestId 组件
+
+### 5.1 新增 `common/logging/RequestIdAccessor`
+
+```java
+public String get(HttpServletRequest request) // 唯一入口
+```
+
+取值顺序:
+
+1. `RequestIdFilter` 写入的请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`(规范化后的值);
+2. 否则读请求头 `X-Request-Id` —— 过滤器已用请求包装器把它替换为同一个规范化值;若调用路径绕过了过滤器,则用**过滤器同一处** `RequestIdFilter.normalizeRequestId(...)` 收敛,**不会**把超长或非法客户端头原样返回。
+
+没有第二套生成规则:缺失/空白时复用的就是过滤器那一处的逻辑。独立复验进一步收紧合法字符为 ASCII `A-Z/a-z/0-9/./_/:/-`;任何含空格、控制字符、中文、HTML 符号、斜杠或其它字符的值都会被整体替换为服务端生成的 16 位十六进制 ID,不进入响应头、MDC 或日志。
+
+### 5.2 `RequestIdFilter` 的改动
+
+- 新增公开常量 `REQUEST_ID_ATTRIBUTE = "requestId"`,并把**规范化后**的值写入请求属性;
+- MDC、响应头、请求包装器、请求属性四者写的是同一个字符串;
+- 常量名与原私有名一致,MDC key 未变,日志格式不受影响;
+- 过滤器 `finally` 中仍然 `MDC.remove(...)`。
+
+### 5.3 `GlobalExceptionHandler`
+
+只把私有 `getRequestId(request)` 的实现改为经 `RequestIdAccessor` 取值,**未重构任何 HTTP 状态映射**。
+
+独立复验发现初版为兼容 `@WebMvcTest` 使用了可选字段注入和 `new RequestIdAccessor()` 兜底,这会掩盖正式应用的装配缺陷,因此判定为阻断并已整改。最终实现使用 Lombok `@RequiredArgsConstructor` 对 `RequestIdAccessor` 做强制构造器注入;测试切片在测试侧显式提供 Bean,生产代码不存在空依赖或手工实例兜底。
+
+`AuditContext.from(request)` 未改动:它读的请求头已被过滤器包装器规范化,与响应头、MDC、响应体同源。
+
+## 6. 8 个 Controller 的迁移方式
+
+- 新增两个 `final` 字段 `currentUserAccessor` / `requestIdAccessor`;
+- 调用点由 `resolveUserId(request)` → `currentUserAccessor.requireUserId()`,`requestId(request)` / `getRequestId(request)` → `requestIdAccessor.get(request)`;
+- 删除各自的私有身份解析方法与不再使用的 `SecurityContextHolder` / `Authentication` / `BusinessException` / `ErrorCode` 导入;
+- 逐点人工核对,未做机械全仓替换;每个 Controller 的公开端点、角色检查与归属检查位置均未移动。
+
+## 7. 量化验收(整改前 → 整改后)
+
+| 指标 | 整改前 | 整改后 | 变化 |
+|---|---|---|---|
+| 身份解析私有方法:文件数 / 处数 | 42 / 44 | 35 / 37 | −7 / −7(净额,见下) |
+| requestId 私有方法:文件数 / 处数 | 46 / 46 | 38 / 38 | **−8 / −8** |
+| 8 个 Controller 内的上述私有方法 | 16 处 | **0 处** | −16 |
+
+requestId 指标精确减少 8 个文件,正好对应本批 8 个 Controller。
+
+身份指标的净额是 −7 而非 −8:本批 8 个 Controller 各减少 1 处(共 −8),同时经负责人明确授权修复的 `DeletionGuardInterceptor` **新增**了 1 个私有辅助方法 `resolveUserIdOrNull`(见第 10 节),−8 + 1 = −7。该辅助方法是有意保留的(把「非法主体 → 放行」这一契约写在一处并加注释),不为迁就统计口径而内联。
+
+### 7.1 剩余数量与文件清单(**未**声称全仓完成)
+
+仍有 **35 个文件 / 37 处**私有身份解析、**38 个文件 / 38 处**私有 requestId 方法,属后续批次。除本批 8 个外的主要剩余:
+
+```text
+application/ApplicationController interview/InterviewController
+bidirectionalmatch/ApplicationMatchController interviewscore/InterviewScoreController
+bidirectionalmatch/MatchConversationController job/FavoriteController
+bidirectionalmatch/MatchRuleController match/MatchController
+candidatescore/CandidateScoreCardController messaging/MessagingController
+candidatescore/CandidateScoringController notification/NotificationController
+candidatescore/dashboard/CompanyScoringDashboardController offer/OfferController
+candidatescore/governance/CandidateScoringGovernanceController recommendation/JobRecommendationController
+careerprogress/CareerProgressController recommendation/JobRecommendationService
+company/CompanyController review/AdminReviewController
+deletion/AccountDeletionController review/AdminReviewReplyController
+feedback/CandidateFeedbackController review/CompanyReviewManagementController
+ review/JobReviewController
+ review/StudentReviewWeightController
+ risk/AdminRiskController
+ safety/AdminReportController
+ safety/BlockController
+ safety/ReportController
+ scoringmonitoring/FairnessMonitoringController
+ talentdiscovery/TalentDiscoveryController
+ talentinvitation/TalentInvitationController
+ verification/CompanyVerificationController
+common/exception/GlobalExceptionHandler(requestId,本轮已改为调用 accessor)
+controller/ApiController、controller/AuthController、controller/SystemController、job/JobController(requestId)
+```
+
+## 8. 新增/加强的测试
+
+| 测试类 | 类型 | Tests run | 覆盖 |
+|---|---|---|---|
+| `common/logging/RequestIdAccessorTest`(新增) | 纯单元(真实 `RequestIdFilter`) | 16 | 合法值原样使用;缺失/空白/刚好超一位/远超上限;空格、制表符、换行、回车、Unicode、HTML/路径/标点等短非法字符;**反证:绕过过滤器传 400 字符头不得返回原值**;请求属性优先于请求头;空白属性回退;null request |
+| `common/logging/RequestIdFilterTest`(加强) | 纯单元 | 3 | 追加断言:规范化值同时写入请求属性,且与响应头一致 |
+| `common/logging/RequestContextMigrationIntegrationTest`(新增) | **真实 SecurityFilterChain + RequestIdFilter + MVC** | 8 | 见下 |
+| `deletion/DeletionGuardInterceptorTest`(加强) | 真实过滤链 + MVC | 7 | 新增非法/溢出主体回归 |
+| `profile/ResumeControllerTest`(适配) | 单元 | 12 | 注入真实 `CurrentUserAccessor` / `RequestIdAccessor`;未登录断言改为新 401 语义 |
+| `common/exception/GlobalExceptionHandler*Test`(适配) | 单元 | 1 / 23 / 10 | 测试侧显式传入真实 `RequestIdAccessor`;生产类不保留无参构造、可选注入或手工兜底 |
+
+`RequestContextMigrationIntegrationTest` 逐项覆盖任务书第六节:
+
+1. 合法客户端 requestId 原样使用 —— 在 8 个 Controller 的真实入口上逐一断言;
+2. 缺失、空白、超长(120 字符)时使用过滤器规范化值;
+3. **响应头、响应体 `requestId`、MDC、`AuditContext` 四者同值** —— MDC 与 `AuditContext` 由一个测试专用 `HandlerInterceptor` 在 MVC 派发过程中抓取,而不是只看过滤器自身;
+4. 并发 8 个请求各有独立 requestId,互不串号;
+5. 请求结束后 `MDC.get("requestId")` 为 `null`;
+6. 8 类以上非法 principal 全部 **401 而非 5xx**(匿名令牌、`anonymousUser`、空白、非数字、Unicode 数字、`+42`、`-7`、`0`、`-1`、Long 溢出),另加「完全无凭据」401;
+7. 错误响应不含 principal、`NumberFormatException` 或 `at com.mydreamjob` 堆栈;
+8. 8 个 Controller 的公开端点与既有权限回归(见第 9 节 525 条关联回归);
+9. 被拒绝的请求对 6 张业务表零写入;
+10. **反证**:绕过过滤器直接传超长请求头时 accessor 不得返回原值(`RequestIdAccessorTest.overlongHeaderIsBoundedEvenWhenFilterIsBypassed`)。
+
+**未只 Mock 新组件后断言 mock 值**:`RequestContextMigrationIntegrationTest` 与 `DeletionGuardInterceptorTest` 均使用真实 `SecurityFilterChain`、真实 `RequestIdFilter`、真实 MVC 与真实 JWT 会话。
+
+## 9. 验证结果
+
+| 步骤 | 命令 | 结果 |
+|---|---|---|
+| 定向测试 | `mvn -Dtest="CompanyControllerMvcTest,GlobalExceptionHandler*Test,RequestContextMigrationIntegrationTest,RequestIdAccessorTest,RequestIdFilterTest,DeletionGuardInterceptorTest,ResumeControllerTest" test` | **Tests run: 87, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` |
+| 八个模块关联回归 | `mvn -Dtest="*Assessment*Test,*Ability*Test,*Profile*Test,*Resume*Test,*Preference*Test,*Competition*Test,*DataExport*Test,*Privacy*Test,*Deletion*Test,*RequestId*Test,*RequestContext*Test,*GlobalExceptionHandler*Test,*CurrentUserAccessorTest" test` | **Tests run: 525, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` |
+| 全量测试 | `mvn test` | 见第 11 节 |
+| 干净构建 | `mvn clean verify` | 见第 11 节 |
+| OpenAPI | `python ..\docs\verify_openapi.py` | 见第 12 节 |
+| 空白检查 | `git diff --check` | 仅 LF/CRLF 提示,无空白错误 |
+
+## 10. 越界生产缺陷(已获授权,已修复)
+
+测试在**先于修复**建立时捕获了一个真实生产缺陷,位置超出 §1 白名单,已单独取得负责人明确授权后修复。
+
+**缺陷**:`deletion/DeletionGuardInterceptor` 注册在 `/**`([WebMvcConfig.java:20](mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java#L20)),先于**所有** Controller 执行。它在 `preHandle` 里用 `Character::isDigit` 过滤主体后直接 `Long.valueOf(auth.getName())`:
+
+- 主体为 `"99999999999999999999"`(Long 溢出)→ `NumberFormatException` 逃逸 → **全站 HTTP 500**,且在任何 Controller 之前;
+- 同一检查下 `Character.isDigit` 接受 Unicode 数字,只是因为 `Long.parseLong` 恰好也接受 Unicode 数字才没有一并炸掉。
+
+**证据**(修复前):`RequestContextMigrationIntegrationTest.illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal` 在「Long 溢出主体」上得到 `expected: 401 but was: 500`,堆栈为 `java.lang.NumberFormatException: For input string: "99999999999999999999"` at `DeletionGuardInterceptor.preHandle(DeletionGuardInterceptor.java:60)`。
+
+**修复**:该拦截器的身份解析改为复用唯一的 `CurrentUserAccessor`;非法/溢出主体一律视为未认证并**放行**,交给 Controller 统一返回 401。独立复验将初版的 `ObjectProvider` 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 9302 / 9303 语义、白名单路径与 OPTIONS 放行**均未改动**(`DeletionGuardInterceptorTest` 7 条全绿,含 4 条原有拦截语义用例)。
+
+这条修复是 §1 白名单之外的第 9 个生产文件,**在此明确标出**,未混入 8 个 Controller 的改动叙述。
+
+## 11. 全量测试与干净构建
+
+| 项目 | Tests run | Failures | Errors | Skipped | 构建 |
+|---|---|---|---|---|---|
+| 独立整改定向复验 | **75** | **0** | **0** | **0** | `BUILD SUCCESS` |
+| `mvn clean verify` | **1915** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) |
+
+三个日志中均不存在任何 `Skipped: <非零>` 的测试类。
+
+期间发生过两次真实失败并已修正:实施阶段首次全量测试暴露 `CompanyControllerMvcTest` 未提供 `RequestIdAccessor`;最终处理是在该测试切片显式提供依赖,而不是弱化生产装配。独立整改第一次定向运行又因新增参数化测试同时残留 `@Test` 而产生 1 个 ParameterResolution Error;删除多余注解并补齐换行、回车边界后,同一集合 75/75 通过。两次失败均未通过删除断言或生产兜底绕过。
+
+## 12. OpenAPI
+
+```
+python docs/verify_openapi.py
+paths: 176
+operations: 214
+All checks passed!
+```
+
+`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。本批未新增、删除或重命名任何 path / operation,未改变参数名、默认值、响应 DTO 或错误码;8 个 Controller 的 Swagger 注解一律原样保留。
+
+## 13. 修改文件清单
+
+**新增**
+
+```text
+docs/r01-a-request-context-implementation-report.md
+mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java
+mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java
+```
+
+**修改(生产,共 11 个文件:8 个 Controller + 过滤器 + 异常处理器 + 1 个经授权的越界修复)**
+
+```text
+mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
+mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
+mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
+mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java
+mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java
+mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java
+mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java
+mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java
+mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java # 新增 request attribute 常量并写入
+mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java # 改为调用 accessor
+mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java # 越界缺陷修复(已授权)
+```
+
+**修改(测试)**
+
+```text
+mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java
+mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java
+```
+
+**未修改**:未新增接口、`ErrorCode`、Flyway、表、索引或依赖;V1–V43 未改动;`docs/openapi-rc.json` 未改动。
+
+## 14. Git 状态
+
+```
+git branch --show-current -> refactor/r01-structure-performance
+git diff --cached --name-only -> (空)
+git status --short -- mydreamjob-server/src/main -> 11 个 M + 1 个 ??
+git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空)
+git status --short -- docs/openapi-rc.json -> (空)
+git diff --check -> 仅 LF/CRLF 提示,无空白错误
+```
+
+工作区中另有一处**先于本轮就存在**的未提交修改 `docs/backend-development-roadmap.md`(Q01 行更新 + R01 行),本轮未触碰。
+
+本批**未暂存、未提交、未推送**;未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。所有 R01-A 产物均为未跟踪新文件或仅本轮修改的已跟踪文件。
+
+## 15. 未做的事
+
+- 未进入 R01-B / R01-C / R01-D / R01-E;
+- 未新增 `UserRoleGuard`(角色授权按设计要求留在后续批次,本批未把角色判断与身份解析混在一起);
+- 未改动 `AuditContext`、`RequestLoggingFilter`(后者读的请求头已被过滤器包装器规范化,与其余输出同源);
+- 未改动 `docs/backend-development-roadmap.md` 与 `docs/phase1-scope-and-acceptance.md`;
+- 未新增接口、`ErrorCode`、Flyway、表、索引或依赖。
diff --git a/docs/r01-structure-performance-design.md b/docs/r01-structure-performance-design.md
new file mode 100644
index 0000000..e60e9fa
--- /dev/null
+++ b/docs/r01-structure-performance-design.md
@@ -0,0 +1,81 @@
+# R01 结构与性能收束权威设计
+
+## 1. 目标与边界
+
+R01 在 S01、S02、S03、Q01 已冻结行为之上收束重复代码和可证明的性能热点。所有改动必须保持 HTTP 契约、业务错误码、权限、事务、审计、隐私和数据副作用不变。
+
+禁止在 R01 中新增业务功能、公开接口、Flyway、表或索引;禁止改变评分公式、推荐权重、百分位定义、消息未读定义、简历权限、Token 语义或岗位搜索语义。索引和全文搜索仅形成后续提案,不在本轮实施。
+
+基线:分支 `refactor/r01-structure-performance`,起点 `77dc624`;OpenAPI 176 paths / 214 operations;全量测试 1890/1890。
+
+## 2. 已确认问题
+
+- 41 个生产文件包含私有身份解析方法;不同实现对匿名主体、非数字主体和溢出主体的处理不一致。
+- 46 个生产文件包含私有 requestId 获取方法;部分直接取请求头,未复用 RequestIdFilter 的规范化结果。
+- 角色断言以 `assertStudentRole`、`requireStudent`、`requireAdmin` 等形式分散,存在重复查用户和语义漂移风险。
+- `GlobalExceptionHandler.resolveHttpStatus` 是大型 if 链,并含重复分支。
+- `overviewStatus` 在评分引擎和竞争力排名中存在两套等价规则。
+- `JobRecommendationService.preferenceScore` 被压缩为单行,权重含义难以评审。
+- `CompanyScoringDashboardService.computePercentiles` 对每名候选人扫描全部分数,复杂度 O(n²)。
+- `MessagingService.listConversations` 对每个会话分别加载最多 500 条消息和 read 状态,查询次数随会话数线性增长。
+- `PersonalDataCollector` 存在可批量化的数据读取。
+- 岗位关键字 `%keyword%` 与排序 filesort 是已记录风险,但未经产品和容量决策,不在 R01 直接改搜索技术栈。
+
+## 3. 分批策略
+
+### R01-A:请求身份与 requestId 基础设施
+
+扩展既有 `CurrentUserAccessor`,使所有迁移入口统一拒绝 null、匿名、失效、空白、非 ASCII 数字、溢出和非正 userId。不得在 Controller 接受 userId 参数。
+
+新增单一 `RequestIdAccessor`:优先读取 RequestIdFilter 已规范化的 request attribute;缺失时使用现有安全归一化逻辑,而不是信任任意长度请求头。响应、审计和日志必须使用同一 requestId。
+
+按小批次迁移 Controller,每批最多 8 个类。每个调用点逐项锁定原 401/403 和资源归属语义;禁止机械全仓替换。
+
+角色授权暂不与身份解析混在同一提交。先盘点后新增 `UserRoleGuard`,保持 USER_NOT_FOUND 与 FORBIDDEN 的现有差异,不增加隐式缓存。
+
+### R01-B:异常映射和业务规则唯一来源
+
+将业务错误码到 HTTP 状态的关系提取为不可变映射,启动或测试时检测重复和遗漏;默认仍为 400。不得更改任何现有映射。
+
+提取唯一 `CandidateOverviewStatusPolicy`。评分引擎与竞争排名调用同一实现,并用参数化契约测试证明旧、新结果完全一致。
+
+### R01-C:纯计算与可读性
+
+将百分位改为“排序一次 + 分数组到 lower-rank 的映射”,复杂度 O(n log n)。并列分数必须获得相同百分位;null、小于 2 个有效分数、精度和 HALF_UP 规则保持不变。
+
+拆分 `preferenceScore` 为命名明确的五个维度贡献函数。城市 30、用工类型 20、最低薪资 20、行业 15、岗位族 15 及缺失维度不计分的规则不得变化。
+
+### R01-D:数据库查询收束
+
+消息会话列表新增固定次数聚合查询,一次性按 conversationIds 计算未读数。定义保持:排除 RECALLED;发送者消息是否未读沿用当前 read-row 语义;不存在 read 行即未读。1/20/50 个会话查询次数必须固定。
+
+`markRead` 是否突破 500 条属于独立语义问题,本轮先写失败证据和决策,不与列表未读优化混改。
+
+PersonalDataCollector 按关联键批量读取;导出 JSON 字段、顺序、空值、脱敏与表范围不变。以 1/20/50 数据规模记录查询数和完整导出结果摘要。
+
+### R01-E:真实环境复验
+
+在 MySQL 8.4 上执行查询预算、结果等价和 EXPLAIN。不得把 H2 结果当成 MySQL 证据;不得为了 EXPLAIN 修改迁移或索引。
+
+## 4. 关键契约
+
+| 模块 | 必须保持 |
+|---|---|
+| 身份 | 非法主体为 401,不回显 principal,不产生业务写入 |
+| 角色 | 原有 USER_NOT_FOUND/FORBIDDEN、角色组合与归属判断不变 |
+| requestId | 最大长度、生成规则、响应头、响应体、MDC 与审计同源 |
+| 异常 | 每个 ErrorCode 的 HTTP 状态与响应 body 不变 |
+| 百分位 | `lower / (n-1) * 100`、并列同值、scale=2、HALF_UP |
+| 推荐 | 五项权重和缺失项分母规则逐字保持 |
+| 未读 | RECALLED 不计入;已有 read 行语义不变 |
+| 导出 | 字段、隐私、顺序、内容与错误语义不变 |
+
+## 5. 证据要求
+
+每一批必须先建立特征测试或失败测试,再改生产代码。性能结论必须同时包含结果等价断言、SQL 次数和反证测试;仅写“无 N+1”不算证据。
+
+自动化最低门槛:定向测试、关联回归、`mvn test`、`mvn clean verify`、静态 OpenAPI;Failures/Errors/Skipped 均为 0。涉及 SQL 的批次还必须有真实 MySQL 黑盒。
+
+## 6. 提交边界
+
+每个子阶段独立分支检查点或独立提交;不得将 R01-A 到 R01-E 压成一个大提交。所有历史未跟踪日志和验收产物保留但不提交。不得使用 `git add -A`、reset、restore、checkout 或 clean。
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
index d69d9e7..2f9f7e1 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
@@ -9,8 +9,8 @@ import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
-import com.mydreamjob.common.exception.BusinessException;
-import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -18,8 +18,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -37,6 +35,8 @@ public class AbilityController {
private final AbilityCatalogService catalogService;
private final AbilityAttemptService attemptService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "能力测评类型列表",
description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。")
@@ -46,7 +46,7 @@ public class AbilityController {
})
@GetMapping("/abilities/types")
public ApiResponse> listTypes(HttpServletRequest request) {
- return ApiResponse.ok(catalogService.listTypes(), requestId(request));
+ return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request));
}
@Operation(summary = "创建能力答卷",
@@ -63,8 +63,8 @@ public class AbilityController {
@PostMapping("/abilities/attempts")
public ApiResponse createAttempt(
@Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)),
- requestId(request));
+ return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)),
+ requestIdAccessor.get(request));
}
@Operation(summary = "能力答卷详情",
@@ -78,7 +78,7 @@ public class AbilityController {
@GetMapping("/abilities/attempts/{attemptId}")
public ApiResponse getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request));
+ return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "保存能力测评答案",
@@ -96,8 +96,8 @@ public class AbilityController {
public ApiResponse saveAnswers(
@PathVariable Long attemptId,
@Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req,
- AuditContext.from(request)), requestId(request));
+ return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req,
+ AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "提交能力测评答卷",
@@ -114,8 +114,8 @@ public class AbilityController {
@PostMapping("/abilities/attempts/{attemptId}/submit")
public ApiResponse submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId,
- AuditContext.from(request)), requestId(request));
+ return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId,
+ AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "查看能力测评结果",
@@ -129,7 +129,7 @@ public class AbilityController {
@GetMapping("/abilities/attempts/{attemptId}/result")
public ApiResponse getResult(
@PathVariable Long attemptId, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.loadResult(resolveUserId(request), attemptId), requestId(request));
+ return ApiResponse.ok(attemptService.loadResult(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "我的能力测评历史",
@@ -144,21 +144,9 @@ public class AbilityController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
- Page result = attemptService.listMine(resolveUserId(request), page, size);
+ Page result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size);
return ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
- requestId(request));
- }
-
- private Long resolveUserId(HttpServletRequest request) {
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || !auth.isAuthenticated()) {
- throw new BusinessException(ErrorCode.UNAUTHORIZED);
- }
- return Long.valueOf(auth.getName());
- }
-
- private String requestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
+ requestIdAccessor.get(request));
}
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
index f398e3b..931fc29 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
@@ -10,8 +10,8 @@ import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
-import com.mydreamjob.common.exception.BusinessException;
-import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -19,8 +19,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -39,6 +37,8 @@ public class AssessmentController {
private final AssessmentCatalogService catalogService;
private final AssessmentAttemptService attemptService;
private final AssessmentResultService resultService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "测评类型列表",
description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。")
@@ -48,7 +48,7 @@ public class AssessmentController {
})
@GetMapping("/assessments/types")
public ApiResponse> listTypes(HttpServletRequest request) {
- return ApiResponse.ok(catalogService.listTypes(), requestId(request));
+ return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request));
}
@Operation(summary = "题目列表",
@@ -61,7 +61,7 @@ public class AssessmentController {
@GetMapping("/assessments/{type}/questions")
public ApiResponse> listQuestions(
@PathVariable String type, HttpServletRequest request) {
- return ApiResponse.ok(catalogService.listQuestions(type), requestId(request));
+ return ApiResponse.ok(catalogService.listQuestions(type), requestIdAccessor.get(request));
}
@Operation(summary = "创建答卷",
@@ -77,8 +77,8 @@ public class AssessmentController {
@PostMapping("/assessments/attempts")
public ApiResponse createAttempt(
@Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)),
- requestId(request));
+ return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)),
+ requestIdAccessor.get(request));
}
@Operation(summary = "答卷详情",
@@ -92,7 +92,7 @@ public class AssessmentController {
@GetMapping("/assessments/attempts/{attemptId}")
public ApiResponse getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request));
+ return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "保存答案",
@@ -109,8 +109,8 @@ public class AssessmentController {
public ApiResponse saveAnswers(
@PathVariable Long attemptId,
@Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req,
- AuditContext.from(request)), requestId(request));
+ return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req,
+ AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "提交答卷",
@@ -127,8 +127,8 @@ public class AssessmentController {
@PostMapping("/assessments/attempts/{attemptId}/submit")
public ApiResponse submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
- return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId,
- AuditContext.from(request)), requestId(request));
+ return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId,
+ AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "查看结果",
@@ -143,7 +143,7 @@ public class AssessmentController {
@GetMapping("/assessments/attempts/{attemptId}/result")
public ApiResponse getResult(
@PathVariable Long attemptId, HttpServletRequest request) {
- return ApiResponse.ok(resultService.get(resolveUserId(request), attemptId), requestId(request));
+ return ApiResponse.ok(resultService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "我的测评历史",
@@ -158,21 +158,9 @@ public class AssessmentController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
- Page result = attemptService.listMine(resolveUserId(request), page, size);
+ Page result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size);
return ApiResponse.ok(PageResponse.of(
result.getContent(),
- result.getTotalElements(), result.getNumber(), result.getSize()), requestId(request));
- }
-
- private Long resolveUserId(HttpServletRequest request) {
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || !auth.isAuthenticated()) {
- throw new BusinessException(ErrorCode.UNAUTHORIZED);
- }
- return Long.valueOf(auth.getName());
- }
-
- private String requestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
+ result.getTotalElements(), result.getNumber(), result.getSize()), requestIdAccessor.get(request));
}
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
index 9fedc49..9a72b61 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
@@ -1,9 +1,11 @@
package com.mydreamjob.common.exception;
import com.mydreamjob.common.api.ApiResponse;
+import com.mydreamjob.common.logging.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
+import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@@ -26,8 +28,11 @@ import java.util.Map;
*/
@Slf4j
@RestControllerAdvice
+@RequiredArgsConstructor
public class GlobalExceptionHandler {
+ private final RequestIdAccessor requestIdAccessor;
+
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity> handleConstraintViolation(
ConstraintViolationException ex, HttpServletRequest request) {
@@ -318,6 +323,6 @@ public class GlobalExceptionHandler {
}
private String getRequestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
+ return requestIdAccessor.get(request);
}
}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java
new file mode 100644
index 0000000..a822e3e
--- /dev/null
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java
@@ -0,0 +1,37 @@
+package com.mydreamjob.common.logging;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.stereotype.Component;
+
+/**
+ * 请求 ID 读取器:读取 requestId 的**唯一入口**,保证响应头、MDC、Controller 响应体、
+ * {@code AuditContext} 与异常响应使用同一个值。
+ *
+ * 取值优先级:
+ *
+ * - {@link RequestIdFilter} 写入的请求属性(规范化后的值);
+ * - 请求头 {@code X-Request-Id} —— 过滤器已通过请求包装器把它替换为规范化值,
+ * 因此这里得到的仍是同一个值;若调用路径绕过了过滤器,则由
+ * {@link RequestIdFilter#normalizeRequestId(String)} 按同一规则收敛,
+ * **不会**把超长或非法客户端头原样返回。
+ *
+ *
+ * 本类不产生第二套生成规则:缺失或空白时复用过滤器同一处
+ * {@code normalizeRequestId} 逻辑。
+ */
+@Component
+public class RequestIdAccessor {
+
+ /** 读取当前请求的规范化 requestId。 */
+ public String get(HttpServletRequest request) {
+ if (request == null) {
+ return RequestIdFilter.normalizeRequestId(null);
+ }
+ Object attribute = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE);
+ if (attribute instanceof String value && !value.isBlank()) {
+ return value;
+ }
+ // 过滤器未执行时的兜底:仍按过滤器同一规则收敛,绝不直接回显客户端原值。
+ return RequestIdFilter.normalizeRequestId(request.getHeader(RequestIdFilter.REQUEST_ID_HEADER));
+ }
+}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java
index 43f4bbf..42997c6 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java
@@ -11,20 +11,32 @@ import java.util.Collections;
import java.util.UUID;
/**
- * 请求 ID 过滤器:为每个请求生成/传递唯一 ID,便于日志追踪
+ * 请求 ID 过滤器:为每个请求生成/传递唯一 ID,便于日志追踪。
+ *
+ * 本过滤器是 requestId 的**唯一产出点**:规范化后的值同时写入请求属性
+ * {@link #REQUEST_ID_ATTRIBUTE}、MDC、响应头,并覆盖请求包装器的 {@code X-Request-Id}
+ * 头,使 Controller、{@code AuditContext} 与异常响应读到同一个值。
*/
@Component
public class RequestIdFilter extends OncePerRequestFilter {
- private static final String REQUEST_ID_HEADER = "X-Request-Id";
- private static final String REQUEST_ID_ATTR = "requestId";
+ /** 客户端可传入的请求头名;同时是响应头名。 */
+ static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+ /**
+ * 规范化 requestId 的请求属性名。下游(Controller、异常处理器)应通过
+ * {@link RequestIdAccessor} 读取,而不是各自解析请求头。
+ */
+ public static final String REQUEST_ID_ATTRIBUTE = "requestId";
+
private static final int MAX_REQUEST_ID_LENGTH = 32;
@Override
protected void doFilterInternal(HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response,
jakarta.servlet.FilterChain filterChain) throws IOException, jakarta.servlet.ServletException {
String requestId = normalizeRequestId(request.getHeader(REQUEST_ID_HEADER));
- MDC.put(REQUEST_ID_ATTR, requestId);
+ request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId);
+ MDC.put(REQUEST_ID_ATTRIBUTE, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
try {
// Controllers and AuditContext read the request header directly. Wrap the
@@ -50,17 +62,35 @@ public class RequestIdFilter extends OncePerRequestFilter {
};
filterChain.doFilter(normalizedRequest, response);
} finally {
- MDC.remove(REQUEST_ID_ATTR);
+ MDC.remove(REQUEST_ID_ATTRIBUTE);
}
}
static String normalizeRequestId(String candidate) {
String value = candidate == null ? "" : candidate.trim();
- if (value.isEmpty()) {
- return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+ if (value.isEmpty() || !hasOnlySafeCharacters(value)) {
+ return generatedRequestId();
}
return value.length() <= MAX_REQUEST_ID_LENGTH
? value
: value.substring(0, MAX_REQUEST_ID_LENGTH);
}
+
+ private static boolean hasOnlySafeCharacters(String value) {
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ boolean safe = c >= 'a' && c <= 'z'
+ || c >= 'A' && c <= 'Z'
+ || c >= '0' && c <= '9'
+ || c == '.' || c == '_' || c == ':' || c == '-';
+ if (!safe) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static String generatedRequestId() {
+ return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+ }
}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java
index 84d1a1f..d18c313 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java
@@ -1,17 +1,15 @@
package com.mydreamjob.competition;
import com.mydreamjob.common.api.ApiResponse;
-import com.mydreamjob.common.exception.BusinessException;
-import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.competition.dto.CompetitionRankingResponse;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@@ -28,6 +26,8 @@ import org.springframework.web.bind.annotation.RestController;
public class CompetitionRankingController {
private final CompetitionRankingService competitionRankingService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "同岗位竞争力排名",
description = "仅 STUDENT 且已投递该岗位;返回本人排名/综合分/百分位/维度/缺口及匿名 Top 5。"
@@ -42,20 +42,8 @@ public class CompetitionRankingController {
@GetMapping("/jobs/{jobId}/competition-ranking")
public ResponseEntity> getRanking(
@PathVariable Long jobId, HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
CompetitionRankingResponse data = competitionRankingService.getRanking(userId, jobId);
- return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
- }
-
- private Long resolveUserId(HttpServletRequest request) {
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || !auth.isAuthenticated()) {
- throw new BusinessException(ErrorCode.UNAUTHORIZED);
- }
- return Long.valueOf(auth.getName());
- }
-
- private String getRequestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
+ return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java
index d9d4a79..0ecb19b 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java
@@ -2,10 +2,10 @@ package com.mydreamjob.dataexport;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
-import com.mydreamjob.common.exception.BusinessException;
-import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.dataexport.dto.CreateDataExportRequest;
import com.mydreamjob.dataexport.dto.DataExportTaskResponse;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -15,8 +15,6 @@ import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -37,16 +35,18 @@ import java.nio.charset.StandardCharsets;
public class PersonalDataExportController {
private final DataExportService dataExportService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "创建个人数据导出任务", description = "异步生成 ZIP;同 idempotencyKey 重放返回原任务;注销进行中禁止创建")
@PostMapping("/profile/me/data-export-tasks")
public ResponseEntity> create(
@Valid @RequestBody(required = false) CreateDataExportRequest body,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
CreateDataExportRequest req = body == null ? new CreateDataExportRequest() : body;
return ResponseEntity.ok(ApiResponse.ok(
- dataExportService.create(userId, req), getRequestId(request)));
+ dataExportService.create(userId, req), requestIdAccessor.get(request)));
}
@Operation(summary = "查询个人数据导出任务列表", description = "仅本人;按创建时间倒序分页")
@@ -56,9 +56,9 @@ public class PersonalDataExportController {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String status,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
- dataExportService.list(userId, page, size, status), getRequestId(request)));
+ dataExportService.list(userId, page, size, status), requestIdAccessor.get(request)));
}
@Operation(summary = "查询个人数据导出任务详情", description = "仅本人;不暴露存储路径与内部字段")
@@ -66,9 +66,9 @@ public class PersonalDataExportController {
public ResponseEntity> get(
@PathVariable Long taskId,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
- dataExportService.get(userId, taskId), getRequestId(request)));
+ dataExportService.get(userId, taskId), requestIdAccessor.get(request)));
}
@Operation(summary = "下载个人数据导出 ZIP", description = "仅本人且 COMPLETED 未过期可下载;attachment")
@@ -76,7 +76,7 @@ public class PersonalDataExportController {
public ResponseEntity download(
@PathVariable Long taskId,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
DataExportService.DownloadFile df = dataExportService.download(userId, taskId);
ContentDisposition cd = ContentDisposition.attachment()
.filename(df.filename(), StandardCharsets.UTF_8)
@@ -86,21 +86,4 @@ public class PersonalDataExportController {
.header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
.body(df.content());
}
-
- private Long resolveUserId(HttpServletRequest request) {
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || !auth.isAuthenticated()
- || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) {
- throw new BusinessException(ErrorCode.UNAUTHORIZED);
- }
- try {
- return Long.valueOf(auth.getName());
- } catch (NumberFormatException e) {
- throw new BusinessException(ErrorCode.UNAUTHORIZED);
- }
- }
-
- private String getRequestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
- }
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java
index 522f8ae..5de8e31 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java
@@ -3,11 +3,13 @@ package com.mydreamjob.deletion;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.security.CurrentUserAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.MediaType;
+import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@@ -24,7 +26,14 @@ import java.util.Set;
* PENDING:拦截 POST/PUT/PATCH/DELETE 写操作返回 409/9302;GET 与 OPTIONS 放行
*
* 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。
- * 未认证、未注销用户不受影响。
+ * 未认证、未注销用户不受影响。
+ *
+ * 身份解析复用唯一的 {@link CurrentUserAccessor}(R01-A):本拦截器注册在
+ * {@code /**},先于所有 Controller 执行,因此绝不能自己解析主体。此前它用
+ * {@code Character::isDigit} 判断后直接 {@code Long.valueOf(name)},导致 Long 溢出主体
+ * (如 {@code "99999999999999999999"})抛出 {@code NumberFormatException},在到达任何
+ * Controller 之前就逃逸为全站 HTTP 500。现在非法主体一律视为未认证并放行,
+ * 由 Controller 统一返回 401。
*/
@Component
@RequiredArgsConstructor
@@ -37,6 +46,7 @@ public class DeletionGuardInterceptor implements HandlerInterceptor {
private static final Set WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private final ObjectProvider deletionServiceProvider;
+ private final CurrentUserAccessor currentUserAccessor;
private final ObjectMapper objectMapper;
@Override
@@ -45,11 +55,9 @@ public class DeletionGuardInterceptor implements HandlerInterceptor {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- // 匿名用户(principal 为 "anonymousUser",非数字 userId)等同于未认证,直接放行
- if (auth == null || !auth.isAuthenticated()
- || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))
- || auth.getName() == null || !auth.getName().chars().allMatch(Character::isDigit)) {
+ // 未认证 / 匿名 / 非法或溢出主体 → 一律放行,交由 Controller 按未登录处理(401)。
+ Long userId = resolveUserIdOrNull(SecurityContextHolder.getContext().getAuthentication());
+ if (userId == null) {
return true;
}
// 切片测试(@WebMvcTest)等场景可能无此服务 Bean,此时不拦截
@@ -57,7 +65,6 @@ public class DeletionGuardInterceptor implements HandlerInterceptor {
if (deletionService == null) {
return true;
}
- Long userId = Long.valueOf(auth.getName());
String path = resolvePath(request);
if (DELETION_PATHS.contains(path)) {
return true;
@@ -72,6 +79,19 @@ public class DeletionGuardInterceptor implements HandlerInterceptor {
return true;
}
+ /**
+ * 解析用户 ID;任何非法主体返回 {@code null},绝不抛出。
+ *
+ * 该安全依赖由构造器强制注入;装配缺失时应用必须失败启动。
+ */
+ private Long resolveUserIdOrNull(Authentication authentication) {
+ try {
+ return currentUserAccessor.requireUserId(authentication);
+ } catch (InsufficientAuthenticationException e) {
+ return null;
+ }
+ }
+
private boolean reject(HttpServletResponse response, ErrorCode errorCode,
HttpServletRequest request) throws Exception {
response.setStatus(409);
@@ -92,4 +112,4 @@ public class DeletionGuardInterceptor implements HandlerInterceptor {
}
return uri;
}
-}
\ No newline at end of file
+}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java
index e53f6ed..24d5b57 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java
@@ -1,15 +1,15 @@
package com.mydreamjob.preference;
import com.mydreamjob.common.api.ApiResponse;
+import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.preference.dto.StudentPreferenceRequest;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@@ -21,13 +21,15 @@ import java.util.Map;
public class PreferenceController {
private final PreferenceService preferenceService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "查询求职偏好", description = "仅 STUDENT;无记录时返回空偏好对象")
@GetMapping("/preferences")
public ResponseEntity>> getPreferences(HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
Map view = preferenceService.getPreferences(userId);
- return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
+ return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
@Operation(summary = "保存求职偏好", description = "仅 STUDENT;upsert,完全替换传入字段")
@@ -35,21 +37,8 @@ public class PreferenceController {
public ResponseEntity>> savePreferences(
@Valid @RequestBody StudentPreferenceRequest req,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
Map view = preferenceService.savePreferences(userId, req);
- return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
- }
-
- private Long resolveUserId(HttpServletRequest request) {
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || !auth.isAuthenticated()) {
- throw new com.mydreamjob.common.exception.BusinessException(
- com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
- }
- return Long.valueOf(auth.getName());
- }
-
- private String getRequestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
+ return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java
index 8a16618..be04d75 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java
@@ -1,10 +1,10 @@
package com.mydreamjob.privacy;
import com.mydreamjob.common.api.ApiResponse;
-import com.mydreamjob.common.exception.BusinessException;
-import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.privacy.dto.PrivacySettingsResponse;
import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
@@ -16,8 +16,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -31,6 +29,8 @@ import org.springframework.web.bind.annotation.RestController;
public class PrivacySettingsController {
private final PrivacySettingsService privacySettingsService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
@Operation(
summary = "查询当前学生隐私设置",
@@ -77,9 +77,9 @@ public class PrivacySettingsController {
})
@GetMapping
public ResponseEntity> getMyPrivacySettings(HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
PrivacySettingsResponse response = privacySettingsService.getCurrentUserSettings(userId);
- return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
+ return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(
@@ -139,20 +139,8 @@ public class PrivacySettingsController {
public ResponseEntity> updateMyPrivacySettings(
@Valid @RequestBody PrivacySettingsUpdateRequest req,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
PrivacySettingsResponse response = privacySettingsService.updateCurrentUserSettings(userId, req);
- return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
- }
-
- private Long resolveUserId(HttpServletRequest request) {
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || !auth.isAuthenticated()) {
- throw new BusinessException(ErrorCode.UNAUTHORIZED);
- }
- return Long.valueOf(auth.getName());
- }
-
- private String getRequestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
+ return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
index 4262ba1..63a088f 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
@@ -4,6 +4,8 @@ import com.mydreamjob.profile.dto.StudentProfileResponse;
import com.mydreamjob.profile.dto.StudentProfileUpdateRequest;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.common.api.ApiResponse;
+import com.mydreamjob.common.logging.RequestIdAccessor;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
@@ -18,8 +20,6 @@ import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -33,6 +33,8 @@ import java.util.Map;
public class ProfileController {
private final ProfileService profileService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
// ── 资料 CRUD ────────────────────────────────────────────
@@ -81,10 +83,10 @@ public class ProfileController {
})
@GetMapping("/me")
public ResponseEntity> getMyProfile(HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
profileService.assertStudentRole(userId);
StudentProfileResponse response = profileService.toResponse(profileService.findProfileByUserId(userId));
- return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
+ return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建")
@@ -92,9 +94,9 @@ public class ProfileController {
public ResponseEntity>> updateMyProfile(
@Valid @RequestBody StudentProfileUpdateRequest req,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
Map view = profileService.toView(profileService.updateProfile(userId, req));
- return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
+ return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 简历管理 ──────────────────────────────────────────────
@@ -104,15 +106,15 @@ public class ProfileController {
public ResponseEntity>> uploadResume(
@RequestParam("file") MultipartFile file,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
Map view = profileService.uploadResume(userId, file);
- return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
+ return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
@Operation(summary = "下载简历", description = "下载当前学生上传的简历文件,仅 STUDENT 角色")
@GetMapping("/me/resume")
public ResponseEntity downloadResume(HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
ProfileService.ResumeFile rf = profileService.getResume(userId);
MediaType mediaType = resolveContentType(rf.getContentType());
ContentDisposition cd = ContentDisposition.inline()
@@ -127,27 +129,14 @@ public class ProfileController {
@Operation(summary = "删除简历", description = "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色")
@DeleteMapping("/me/resume")
public ResponseEntity>> deleteResume(HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
profileService.deleteResume(userId);
return ResponseEntity.ok(ApiResponse.ok(
- Map.of("message", "resume deleted"), getRequestId(request)));
+ Map.of("message", "resume deleted"), requestIdAccessor.get(request)));
}
// ── 辅助 ──────────────────────────────────────────────────
- private Long resolveUserId(HttpServletRequest request) {
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- if (auth == null || !auth.isAuthenticated()) {
- throw new com.mydreamjob.common.exception.BusinessException(
- com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
- }
- return Long.valueOf(auth.getName());
- }
-
- private String getRequestId(HttpServletRequest request) {
- return request.getHeader("X-Request-Id");
- }
-
private MediaType resolveContentType(String contentType) {
if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM;
try {
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java
index d01fc56..f6b93b5 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java
@@ -1,9 +1,9 @@
package com.mydreamjob.resume;
import com.mydreamjob.common.api.ApiResponse;
-import com.mydreamjob.common.exception.BusinessException;
-import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
+import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -12,8 +12,6 @@ import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -35,6 +33,8 @@ public class ResumeVersionController {
private final ResumeVersionService resumeVersionService;
private final ResumeExportService resumeExportService;
+ private final CurrentUserAccessor currentUserAccessor;
+ private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "创建简历版本", description = "仅本人(STUDENT);PDF/DOC/DOCX ≤10MB;可附版本名")
@PostMapping(value = "/profile/me/resume-versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -42,18 +42,18 @@ public class ResumeVersionController {
@RequestParam("file") MultipartFile file,
@RequestParam(value = "name", required = false) String name,
HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
ResumeVersion version = resumeVersionService.createVersion(userId, name, file);
- return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request)));
+ return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), requestIdAccessor.get(request)));
}
@Operation(summary = "简历版本列表", description = "本人 ACTIVE 版本,version_no 倒序")
@GetMapping("/profile/me/resume-versions")
public ResponseEntity>>> list(HttpServletRequest request) {
- Long userId = resolveUserId(request);
+ Long userId = currentUserAccessor.requireUserId();
List