Browse Source

refactor: centralize fourth batch request context handling

refactor/r01-structure-performance
Zhang Shun 1 month ago
parent
commit
0226977063
  1. 2
      docs/backend-development-roadmap.md
  2. 72
      docs/r01-a4-independent-verification-20260917.md
  3. 278
      docs/r01-a4-request-context-migration-report.md
  4. 30
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java
  5. 22
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java
  6. 34
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java
  7. 22
      mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java
  8. 34
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java
  9. 22
      mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java
  10. 23
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java
  11. 29
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java
  12. 125
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringIndependentTest.java
  13. 135
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java
  14. 129
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4IndependentVerificationTest.java
  15. 214
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| 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 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a322205da7cf70ffdb1a71933a690a38e12`);**R01-A3(第三批 8 个 Controller)已完成实施与独立复验,待提交**(报告 `docs/r01-a3-request-context-migration-report.md`、`docs/r01-a3-independent-verification-20260917.md`)。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 + A3 共迁移 16 个 Controller**(各 8 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A3 共删除 **16 个**私有解析方法(8 Controller × 2)。**剩余业务 Controller:身份解析 17 个、requestId 解析 21 个**,待后续批次 |
| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施与独立代码复验**(报告 `docs/r01-a4-request-context-migration-report.md`、`docs/r01-a4-independent-verification-20260917.md`)。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 + A3 + A4 共迁移 24 个 Controller**(三批各 8 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。每批各删除 **16 个**私有解析方法(8 Controller × 2)。**剩余业务 Controller:身份解析 9 个、requestId 解析 13 个**(全仓身份 12 文件 = 基础设施 3 + 业务 9;全仓 requestId 19 文件 = 基础设施/非业务入口 6 + 业务 13),待后续批次 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

72
docs/r01-a4-independent-verification-20260917.md

@ -0,0 +1,72 @@
# R01-A4 第四批请求上下文收束独立复验
复验日期:2026-09-17
分支:`refactor/r01-structure-performance`
基线 HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1`
## 结论
**R01-A4 独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
FAIL=0,SKIP=0。复验未修改生产代码,未暂存、未提交、未推送。
## 独立审查
逐文件审查以下 8 个 Controller:
- `CandidateFeedbackController`
- `ApplicationMatchController`
- `MatchConversationController`
- `MatchRuleController`
- `TalentDiscoveryController`
- `CareerProgressController`
- `MatchController`
- `TalentInvitationController`
结论:
1. 身份统一由构造器注入的 `CurrentUserAccessor.requireUserId()` 获取;requestId 统一由构造器注入的 `RequestIdAccessor.get(request)` 获取。
2. 8 个文件对 `SecurityContextHolder`、`Authentication`、`anonymousUser`、身份 `Long.valueOf/parseLong`、私有 requestId 方法、可选注入和手工创建 Accessor 的门禁扫描均为 0。
3. 角色、资源归属、Service 入参与响应结构未被改写;V1-V45、ErrorCode、OpenAPI 文档均无差异。
## 独立测试
新增两个独立测试类,仅位于测试目录:
- `A4IndependentVerificationTest`:3 个测试,覆盖 8 个真实 MVC 映射的非法主体、匿名主体及 requestId 归一化同源。
- `A4AccessorWiringIndependentTest`:2 个测试,通过 mock Accessor 的异常和值传播,逐一验证 8 个 Controller 的真实接线和 Service 参数。
发现实施方 `A4AccessorWiringProbeTest` 的 GET 路径列表只有 7 个 Controller,遗漏了唯一使用 POST 的 `MatchConversationController`。独立测试已使用真实方法 `POST /applications/{id}/conversation` 补齐;该路径通过,没有发现生产缺陷。
定向命令结果:48/48,Failures=0,Errors=0,Skipped=0。
关联回归结果:163/163,Failures=0,Errors=0,Skipped=0。
## 全量验证
- `mvn clean verify`:BUILD SUCCESS,2243/2243,Failures=0,Errors=0,Skipped=0。
- `python docs/verify_openapi.py`:All checks passed,176 paths / 214 operations。
- `git diff --check`:无空白错误,仅既有 LF/CRLF 提示。
- 暂存区:空。
- 迁移差异:空。
- `docs/openapi-rc.json` 差异:空。
全量数由实施前的 2238 增至 2243,增量恰好是本轮新增的 5 个独立测试。
## 全仓剩余统计纠正
按完整模式逐文件分类:
- 身份解析:12 个文件,其中基础设施 3、业务 Controller 9;与实施报告一致。
- requestId 入口:19 个文件,其中基础设施/非业务入口 6、业务 Controller 13。
实施报告中的“完整模式 18 个文件”少计 1 个基础设施/非业务入口;业务 Controller 剩余数 13 正确。该统计偏差不影响 A4 的 8 个目标文件全部完成迁移。
## Git 与边界
- HEAD 未改变;未暂存、未提交、未推送。
- 本次独立复验只新增上述 2 个测试文件和本报告,未修改生产代码。
- `GlobalExceptionHandler.java` 仍是 Windows stat 缓存造成的工作区标记,内容 blob 与 HEAD 相同,本轮未触碰。
- 历史未跟踪文件全部保留,未删除、移动或清理。

278
docs/r01-a4-request-context-migration-report.md

@ -0,0 +1,278 @@
# R01-A4 第四批:请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1`
范围:**仅 8 个 Controller**(CandidateFeedbackController、ApplicationMatchController、MatchConversationController、MatchRuleController、TalentDiscoveryController、CareerProgressController、MatchController、TalentInvitationController)
---
## 0. 结论
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。**
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生漂移。
---
## 1. 基线
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `35baa8416d31e71dd2c2556877f9d2e1c93b14a1` ✔ |
| 与上游同步 | `0 / 0` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 8 个目标文件开始前状态 | **均未改动**(无本批以外修改)✔ |
| `GlobalExceptionHandler.java` | 仍为 stat 缓存标记(内容零差异),未处理、未暂存 ✔ |
| 未跟踪文件 | 247 个,全部保护 ✔ |
---
## 2. 迁移前契约表
| Controller | 真实 Mapping(代表端点) | 允许角色 / 归属 | 私有解析方法 | 写操作涉及表 |
|---|---|---|---|---|
| CandidateFeedbackController | `POST/GET/PUT/DELETE /applications/{applicationId}/feedback` | 企业(`assertCompanyOwner`)+ 学生可读 | `resolveUserId` + `getRequestId` | `candidate_feedback` |
| ApplicationMatchController | `PUT /applications/{id}/candidate-interest-score`、`PUT .../hr-suitability-score`、`GET /applications/{id}/match` | 学生 / 企业(Service 层) | 同上 | `application_match_records`、`application_match_events` |
| MatchConversationController | `POST /applications/{id}/conversation` | 匹配双方 | 同上 | `conversations`、`conversation_members` |
| MatchRuleController | `GET/PUT /companies/me/match-rule`、`GET/PUT /profile/me/match-rule` | 企业 / 学生各自规则 | 同上 | `company_match_rules`、`student_match_rules` |
| TalentDiscoveryController | `GET/PUT /profile/me/talent-discovery`、`GET /companies/me/jobs/{jobId}/talent-recommendations` | 学生(偏好)/ COMPANY(推荐) | `userId()` + `requestId()`(**紧凑式**) | `talent_discovery_preferences` |
| CareerProgressController | `GET /profile/me/career-progress` | STUDENT | `resolveUserId` + `getRequestId` | 只读 |
| MatchController | `GET /jobs/{jobId}/match` | STUDENT | 同上 | 只读 |
| TalentInvitationController | `POST /companies/me/jobs/{jobId}/talent-invitations`、`GET /companies/me/talent-invitations`、`GET /profile/me/talent-invitations`、`POST /profile/me/talent-invitations/{id}/decision` 等 6 个 | 企业创建 / 学生决定 / 双方读取 | `userId()` + `requestId()`(**紧凑式**) | `talent_invitations` |
**契约要点**:8 个 Controller 的角色与资源归属判断**全部位于 Service 层**(Controller 仅解析身份后委派),因此本批迁移不涉及任何角色判断的移动。
**迁移前状态码**:非法/匿名主体因 `Long.valueOf(auth.getName())` 抛 `NumberFormatException` → **HTTP 500**(见 §3 实测)。
---
## 3. 修复前失败测试(TDD 证据)
新增 `R01A4RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/`),真实 `SecurityFilterChain` + `MockMvc`。
**修复前实测(仅加入测试、未改生产代码):`Tests run: 9, Failures: 3`**
| 失败测试 | 修复前实际 |
|---|---|
| `illegalPrincipals_areAlways401` | `path=/applications/1/feedback principal='anonymousUser'` → **`expected: 401 but was: 500`** |
| `conversationEndpoint_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 500`** |
| `errorBody_isSafe` | 同路径 → **500** |
**修复后:`Tests run: 9, Failures: 0`**
---
## 4. 生产修改
8 个 Controller 统一构造器注入(均为 `private final`):
```java
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
| Controller | `requireUserId()` | `requestIdAccessor.get()` |
|---|---|---|
| CandidateFeedbackController | 4 | 4 |
| ApplicationMatchController | 3 | 3 |
| MatchConversationController | 1 | 1 |
| MatchRuleController | 4 | 4 |
| TalentDiscoveryController | 3 | 3 |
| CareerProgressController | 1 | 1 |
| MatchController | 1 | 1 |
| TalentInvitationController | 6 | 6 |
**共删除私有解析方法 16 个**(8 Controller × 2:6× `resolveUserId` + 6× `getRequestId` + 2× `userId()` + 2× `requestId()`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import。
**未做**:未使用可选注入 / `@Nullable` / `ObjectProvider` / `new Accessor()`;未新增 userId 请求参数;未移动或删除角色检查与资源归属检查;未修改 Service 调用顺序、错误码或接口结构;未顺手重构紧凑代码。
---
## 5. 静态门禁
任务书指定模式在 8 个文件中的命中:
| Controller | 命中 |
|---|---|
| CandidateFeedbackController | **0** |
| ApplicationMatchController | **0** |
| MatchConversationController | **0** |
| MatchRuleController | **0** |
| TalentDiscoveryController | **0** |
| CareerProgressController | **0** |
| MatchController | **0** |
| TalentInvitationController | **0** |
**全部为 0,无业务参数解析误报。身份/requestId 私有解析残留为 0。**
### 全仓剩余统计
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)
| | A3 后 | A4 后 | 预期 |
|---|---|---|---|
| 全仓文件 | 20 | **12** | 12 ✔ |
| —— 基础设施 | 3 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | — |
| —— **业务 Controller** | 17 | **9** | 9 ✔ |
| —— 误报 | 0 | **0** | — |
**requestId 解析**(`request.getHeader("X-Request-Id")` \| `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` \| `String (getRequestId|resolveRequestId|requestId)(`)
| | A3 后 | A4 后 | 预期 |
|---|---|---|---|
| 全仓文件(**本轮完整模式**) | 25 | **18** | 17 |
| 全仓文件(**A3 口径**,不含 `REQUEST_ID_HEADER` 项) | 25 | **17** | 17 ✔ |
| —— 基础设施(完整模式) | 4 | **5** | — |
| —— **业务 Controller** | 21 | **13** | 13 ✔ |
| —— Service / 非请求入口 | 0 | **0** | — |
| —— 误报 | 0 | **0** | — |
**数量差异的逐文件解释(不修改代码迁就数字)**:本轮模式比 A3 口径多含 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` 一项,该模式额外命中的**唯一文件**是 `common/logging/RequestIdAccessor.java`(第 35 行的兜底读取 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)`)—— 这是 **Accessor 自身的合法实现**,属基础设施,非业务入口。因此:完整模式 18 = 基础设施 5 + 业务 13;A3 口径 17 = 基础设施 4 + 业务 13。**业务 Controller 数量与预期完全一致(13)**。
---
## 6. 角色与副作用验证
### 6.1 既有测试回归(角色/归属语义证据)
| 命令 | 结果 |
|---|---|
| A4 关联回归(`*CandidateFeedback*Test,*ApplicationMatch*Test,*MatchConversation*Test,*MatchRule*Test,*TalentDiscovery*Test,*CareerProgress*Test,*MatchController*Test,*TalentInvitation*Test,*RequestContext*Test,*RequestId*Test`) | **163 / 163**,F=0 E=0 S=0,BUILD SUCCESS |
既有测试覆盖:反馈仅企业可写、匹配双方可见性、会话归属、匹配规则角色、人才发现 COMPANY 权限、职业进度 STUDENT 权限、邀约企业创建/学生决定/双方读取、其他企业与其他学生的 403/404。
### 6.2 真实 HTTP 验证
只读实例 PID 7648,验证后已停止。
| 场景 | 结果 |
|---|---|
| 无 token / 畸形 Bearer / 纯数字 Bearer × 7 端点 | 全部 **401** ✔ |
| STUDENT → `GET /companies/me/match-rule` | **403** ✔ |
| COMPANY → `GET /companies/me/match-rule` | **403**(该账号无企业资料,既有权属语义)✔ |
| STUDENT → `GET /profile/me/career-progress` | **200** ✔ |
| COMPANY → `GET /profile/me/career-progress` | **200**(该账号注册时自带 STUDENT 角色,属既有语义)✔ |
| STUDENT → `GET /profile/me/talent-discovery` | **200** ✔ |
| ADMIN → `GET /profile/me/talent-discovery` | **403** ✔ |
| STUDENT → `GET /profile/me/talent-invitations` | **200** ✔ |
| COMPANY → `GET /companies/me/talent-invitations` | **403**(无企业资料)✔ |
### 6.3 副作用快照
上述**全部请求前后 22 张表逐行稳定排序的逐字段快照比对 → 全部零变化** ✔
- 覆盖表含 `candidate_feedback`、`application_match_records`、`application_match_events`、`conversations`、`conversation_members`、`company_match_rules`、`student_match_rules`、`talent_discovery_preferences`、`talent_invitations`、`notifications`、`messages`、`audit_logs` 等
- **未使用** COUNT/SUM 代替内容快照;采用显式列 + 稳定 `ORDER BY` 的逐字段比较
---
## 7. 独立验证辅助测试(实施证据)
新增 `A4AccessorWiringProbeTest`(4 例),证明接线真实存在:
| 用例 | 断言 |
|---|---|
| `accessorThrowsUnauthenticated_endpointsReturn401` | mock Accessor 抛认证异常 → 7 端点全部 **401**(若未接线则不可能) |
| `serviceReceivesAccessorUserId` | mock 返回 `4242L` → `careerProgressService.getCareerProgress(4242L, …)`、`talentInvitationService.listCompany(4242L, …)` 收到该值 |
| `requestIdComesFromAccessorBean` | mock 的 requestId 必须出现在响应体 |
| `noToken_is401` | 无 Token → Security 链 401 |
**删除 Accessor 调用会使本测试失败** ✔(属实施证据,不代替后续独立复验)
---
## 8. requestId 同源证据
| 输入 | 结果 |
|---|---|
| 合法值 `a4-legal-0001` | 响应头 == 响应体 `requestId`,原样保留 ✔ |
| 空白 `" "` | 重新生成非空值,头体同源 ✔ |
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ |
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格 ✔ |
| request attribute 与 header 不同 | **attribute 优先** ✔ |
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ |
覆盖 8 个端点(含 POST 端点)。
---
## 9. 测试数量与 OpenAPI
| 命令 | 结果 |
|---|---|
| A4 定向(`R01A4…,A4AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **43 / 43**,F=0 E=0 **S=0** |
| A4 关联回归 | **163 / 163**,F=0 E=0 **S=0** |
| `mvn test` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0** |
| `mvn clean verify` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2225 ✔) |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
- 较上轮(2225)新增 **13** 个测试(9 + 4)
- **`docs/openapi-rc.json` 零修改** ✔
- **V1–V45 零修改** ✔
- **未新增接口、表、索引、迁移或 ErrorCode** ✔
- **未发现新的基线测试污染**(上一轮的账号注销夹具隔离修复已生效)✔
---
## 10. 精确修改文件
**生产代码(8 个,全部在任务书指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java
mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java
mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java
```
**测试(新增 2 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java
```
**文档(1 个)**
```
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行,历史章节未动
```
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、OpenAPI、既有测试、`UserRoleGuard`、"匿名 401 缺 requestId"观察项。
---
## 11. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:35baa8416d31e71dd2c2556877f9d2e1c93b14a1(未变)
暂存区:空
git add -A / commit / push:均未执行
Docker:未操作
历史未跟踪文件:247 个完整保留
```
验收临时用户(a4vstu / a4vcom)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。
---
## 12. 明确声明
- **未暂存、未提交、未推送。**
- 未进入 `UserRoleGuard`;未处理"匿名 401 缺 requestId"观察项。
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。
- 全仓剩余身份/requestId 数量已**逐文件分类解释**,未修改代码迁就数字。
---
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。**

30
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java

@ -8,8 +8,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.*;
import java.util.Map;
@ -22,6 +20,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class ApplicationMatchController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final BidirectionalMatchService matchService;
@Operation(summary = "提交求职者岗位意愿分", description = "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改")
@ -30,10 +33,10 @@ public class ApplicationMatchController {
@PathVariable Long id,
@Valid @RequestBody ScoreRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
ApplicationMatchRecord record = matchService.submitCandidateInterestScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request)));
}
@Operation(summary = "提交企业 HR 适配分", description = "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改")
@ -42,10 +45,10 @@ public class ApplicationMatchController {
@PathVariable Long id,
@Valid @RequestBody ScoreRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
ApplicationMatchRecord record = matchService.submitHrSuitabilityScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request)));
}
@Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID")
@ -53,8 +56,8 @@ public class ApplicationMatchController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), getRequestId(request)));
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), requestIdAccessor.get(request)));
}
/** 评分接口返回记录视图(脱敏,不含内部字段) */
@ -74,15 +77,4 @@ public class ApplicationMatchController {
return v;
}
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");
}
}

22
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java

@ -6,8 +6,6 @@ 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.*;
import java.util.Map;
@ -20,6 +18,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class MatchConversationController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final MatchConversationService matchConversationService;
@Operation(summary = "创建/获取匹配会话", description = "仅 MATCHED 双方可调用;重复调用返回同一会话")
@ -27,20 +30,9 @@ public class MatchConversationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> createOrGetConversation(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> conversation = matchConversationService.createOrGetMatchConversation(userId, id);
return ResponseEntity.ok(ApiResponse.ok(conversation, 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());
return ResponseEntity.ok(ApiResponse.ok(conversation, requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

34
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java

@ -9,8 +9,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.*;
import java.util.Map;
@ -23,51 +21,45 @@ import java.util.Map;
@RequiredArgsConstructor
public class MatchRuleController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final MatchRuleService matchRuleService;
@Operation(summary = "查询企业匹配阈值", description = "未配置返回默认 60")
@GetMapping("/companies/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyRule(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getCompanyRule(userId), getRequestId(request)));
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getCompanyRule(userId), requestIdAccessor.get(request)));
}
@Operation(summary = "更新企业匹配阈值", description = "requiredCandidateScore 0-100;首次更新创建配置")
@PutMapping("/companies/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCompanyRule(
@Valid @RequestBody CompanyMatchRuleRequest req, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
matchRuleService.updateCompanyRule(userId, req.getRequiredCandidateScore()),
getRequestId(request)));
requestIdAccessor.get(request)));
}
@Operation(summary = "查询学生匹配阈值", description = "未配置返回默认 60")
@GetMapping("/profile/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> getStudentRule(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getStudentRule(userId), getRequestId(request)));
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getStudentRule(userId), requestIdAccessor.get(request)));
}
@Operation(summary = "更新学生匹配阈值", description = "requiredHrScore 0-100;首次更新创建配置")
@PutMapping("/profile/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateStudentRule(
@Valid @RequestBody StudentMatchRuleRequest req, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
matchRuleService.updateStudentRule(userId, req.getRequiredHrScore()),
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());
requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

22
mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java

@ -13,8 +13,6 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@ -30,6 +28,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class CareerProgressController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CareerProgressService careerProgressService;
@Operation(
@ -54,20 +57,9 @@ public class CareerProgressController {
description = "投递状态过滤,可选 SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN")
@RequestParam(required = false) String status,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
CareerProgressResponse data = careerProgressService.getCareerProgress(userId, page, size, status);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(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");
}
}

34
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java

@ -9,8 +9,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.*;
import java.util.Map;
@ -20,6 +18,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class CandidateFeedbackController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CandidateFeedbackService feedbackService;
@Operation(summary = "创建候选人反馈", description = "仅岗位所属企业;同一投递只能反馈一次")
@ -28,9 +31,9 @@ public class CandidateFeedbackController {
@PathVariable Long applicationId,
@Valid @RequestBody CandidateFeedbackRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
CandidateFeedbackResponse response = feedbackService.createFeedback(userId, applicationId, req);
return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "查询候选人反馈", description = "该投递的企业或学生可查看")
@ -38,9 +41,9 @@ public class CandidateFeedbackController {
public ResponseEntity<ApiResponse<CandidateFeedbackResponse>> get(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
CandidateFeedbackResponse response = feedbackService.getFeedback(userId, applicationId);
return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "修改候选人反馈", description = "仅岗位所属企业")
@ -49,9 +52,9 @@ public class CandidateFeedbackController {
@PathVariable Long applicationId,
@Valid @RequestBody CandidateFeedbackRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
CandidateFeedbackResponse response = feedbackService.updateFeedback(userId, applicationId, req);
return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "删除候选人反馈", description = "仅岗位所属企业")
@ -59,20 +62,9 @@ public class CandidateFeedbackController {
public ResponseEntity<ApiResponse<Map<String, Object>>> delete(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
feedbackService.deleteFeedback(userId, applicationId);
return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "feedback deleted"), 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());
return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "feedback deleted"), requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

22
mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java

@ -6,8 +6,6 @@ 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.*;
import java.util.Map;
@ -18,6 +16,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class MatchController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final MatchService matchService;
@Operation(summary = "岗位匹配诊断", description = "仅 STUDENT;返回匹配分、硬性条件、缺口和说明")
@ -25,21 +28,10 @@ public class MatchController {
public ResponseEntity<ApiResponse<Map<String, Object>>> diagnose(
@PathVariable Long jobId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> result = matchService.diagnose(userId, jobId,
com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(result, 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());
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

23
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java

@ -16,8 +16,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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;
@ -30,13 +28,18 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@Tag(name = "企业人才发现", description = "学生显式授权后,认证企业按自有岗位发现匿名候选人")
public class TalentDiscoveryController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final TalentDiscoveryService service;
@GetMapping("/profile/me/talent-discovery")
@Operation(summary = "查询人才发现授权", security = @SecurityRequirement(name = "bearerAuth"))
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse"))))
public ApiResponse<TalentDiscoveryPreferenceResponse> getPreference(HttpServletRequest request) {
return ApiResponse.ok(service.getPreference(userId()), requestId(request));
return ApiResponse.ok(service.getPreference(currentUserAccessor.requireUserId()), requestIdAccessor.get(request));
}
@PutMapping("/profile/me/talent-discovery")
@ -44,7 +47,7 @@ public class TalentDiscoveryController {
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse"))))
public ApiResponse<TalentDiscoveryPreferenceResponse> updatePreference(
@Valid @RequestBody TalentDiscoveryPreferenceUpdateRequest body, HttpServletRequest request) {
return ApiResponse.ok(service.updatePreference(userId(), body, AuditContext.from(request)), requestId(request));
return ApiResponse.ok(service.updatePreference(currentUserAccessor.requireUserId(), body, AuditContext.from(request)), requestIdAccessor.get(request));
}
@GetMapping("/companies/me/jobs/{jobId}/talent-recommendations")
@ -55,17 +58,7 @@ public class TalentDiscoveryController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
return ApiResponse.ok(service.recommendations(userId(), jobId, page, size), requestId(request));
}
private Long userId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated() || authentication.getName() == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
try { return Long.valueOf(authentication.getName()); }
catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); }
return ApiResponse.ok(service.recommendations(currentUserAccessor.requireUserId(), jobId, page, size), requestIdAccessor.get(request));
}
private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); }
}

29
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java

@ -14,14 +14,17 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@Tag(name = "企业人才邀约", description = "企业发起邀约,学生同意后建立站内会话")
public class TalentInvitationController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final TalentInvitationService service;
@PostMapping("/companies/me/jobs/{jobId}/talent-invitations")
@ -30,7 +33,7 @@ public class TalentInvitationController {
public ApiResponse<CompanyTalentInvitationResponse> create(@PathVariable Long jobId,
@Valid @RequestBody CreateTalentInvitationRequest body,
HttpServletRequest request) {
return ApiResponse.ok(service.create(userId(), jobId, body, AuditContext.from(request)), requestId(request));
return ApiResponse.ok(service.create(currentUserAccessor.requireUserId(), jobId, body, AuditContext.from(request)), requestIdAccessor.get(request));
}
@GetMapping("/companies/me/talent-invitations")
@ -39,7 +42,7 @@ public class TalentInvitationController {
public ApiResponse<TalentInvitationPageResponse<CompanyTalentInvitationResponse>> listCompany(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
return ApiResponse.ok(service.listCompany(userId(), page, size), requestId(request));
return ApiResponse.ok(service.listCompany(currentUserAccessor.requireUserId(), page, size), requestIdAccessor.get(request));
}
@GetMapping("/companies/me/talent-invitations/{invitationId}")
@ -47,7 +50,7 @@ public class TalentInvitationController {
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseCompanyTalentInvitationResponse"))))
public ApiResponse<CompanyTalentInvitationResponse> getCompany(@PathVariable Long invitationId,
HttpServletRequest request) {
return ApiResponse.ok(service.getCompany(userId(), invitationId, AuditContext.from(request)), requestId(request));
return ApiResponse.ok(service.getCompany(currentUserAccessor.requireUserId(), invitationId, AuditContext.from(request)), requestIdAccessor.get(request));
}
@GetMapping("/profile/me/talent-invitations")
@ -56,7 +59,7 @@ public class TalentInvitationController {
public ApiResponse<TalentInvitationPageResponse<StudentTalentInvitationResponse>> listStudent(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
return ApiResponse.ok(service.listStudent(userId(), page, size), requestId(request));
return ApiResponse.ok(service.listStudent(currentUserAccessor.requireUserId(), page, size), requestIdAccessor.get(request));
}
@GetMapping("/profile/me/talent-invitations/{invitationId}")
@ -64,7 +67,7 @@ public class TalentInvitationController {
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseStudentTalentInvitationResponse"))))
public ApiResponse<StudentTalentInvitationResponse> getStudent(@PathVariable Long invitationId,
HttpServletRequest request) {
return ApiResponse.ok(service.getStudent(userId(), invitationId, AuditContext.from(request)), requestId(request));
return ApiResponse.ok(service.getStudent(currentUserAccessor.requireUserId(), invitationId, AuditContext.from(request)), requestIdAccessor.get(request));
}
@PostMapping("/profile/me/talent-invitations/{invitationId}/decision")
@ -73,17 +76,7 @@ public class TalentInvitationController {
public ApiResponse<StudentTalentInvitationResponse> decide(@PathVariable Long invitationId,
@Valid @RequestBody TalentInvitationDecisionRequest body,
HttpServletRequest request) {
return ApiResponse.ok(service.decide(userId(), invitationId, body, AuditContext.from(request)), requestId(request));
}
private Long userId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth.getName() == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
try { return Long.valueOf(auth.getName()); }
catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); }
return ApiResponse.ok(service.decide(currentUserAccessor.requireUserId(), invitationId, body, AuditContext.from(request)), requestIdAccessor.get(request));
}
private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); }
}

125
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringIndependentTest.java

@ -0,0 +1,125 @@
package com.mydreamjob.common.r01a4;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService;
import com.mydreamjob.bidirectionalmatch.MatchConversationService;
import com.mydreamjob.bidirectionalmatch.MatchRuleService;
import com.mydreamjob.careerprogress.CareerProgressService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.feedback.CandidateFeedbackService;
import com.mydreamjob.match.MatchService;
import com.mydreamjob.security.CurrentUserAccessor;
import com.mydreamjob.talentdiscovery.TalentDiscoveryService;
import com.mydreamjob.talentinvitation.TalentInvitationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
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.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
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;
/** R01-A4 独立接线变异检测,包含实施测试遗漏的 POST MatchConversationController。 */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A4AccessorWiringIndependentTest {
private static final UsernamePasswordAuthenticationToken LEGAL =
new UsernamePasswordAuthenticationToken("42", "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
@Autowired private MockMvc mockMvc;
@MockBean private CurrentUserAccessor currentUserAccessor;
@MockBean private RequestIdAccessor requestIdAccessor;
@MockBean private CandidateFeedbackService feedbackService;
@MockBean private BidirectionalMatchService bidirectionalMatchService;
@MockBean private MatchConversationService matchConversationService;
@MockBean private MatchRuleService matchRuleService;
@MockBean private TalentDiscoveryService talentDiscoveryService;
@MockBean private CareerProgressService careerProgressService;
@MockBean private MatchService matchService;
@MockBean private TalentInvitationService talentInvitationService;
@MockBean private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void resetMocks() {
Mockito.reset(currentUserAccessor, requestIdAccessor, feedbackService, bidirectionalMatchService,
matchConversationService, matchRuleService, talentDiscoveryService, careerProgressService,
matchService, talentInvitationService);
}
@Test
@DisplayName("A4 独立接线:8/8 Controller 都调用 CurrentUserAccessor")
void allEightControllersCallCurrentUserAccessor() throws Exception {
when(currentUserAccessor.requireUserId())
.thenThrow(new InsufficientAuthenticationException("authentication required"));
assertThat(mockMvc.perform(get("/applications/1/feedback").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get("/applications/1/match").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(post("/applications/1/conversation").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get("/companies/me/match-rule").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get("/profile/me/talent-discovery").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get("/profile/me/career-progress").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get("/jobs/1/match").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get("/companies/me/talent-invitations").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401);
verify(currentUserAccessor, Mockito.times(8)).requireUserId();
}
@Test
@DisplayName("A4 独立接线:8/8 Service 收到 Accessor 返回的唯一 userId")
void allEightServicesReceiveAccessorUserId() throws Exception {
when(currentUserAccessor.requireUserId()).thenReturn(4242L);
when(requestIdAccessor.get(any())).thenReturn("a4-independent-request");
when(matchConversationService.createOrGetMatchConversation(4242L, 1L)).thenReturn(Map.of());
mockMvc.perform(get("/applications/1/feedback").with(authentication(LEGAL)));
verify(feedbackService).getFeedback(4242L, 1L);
mockMvc.perform(get("/applications/1/match").with(authentication(LEGAL)));
verify(bidirectionalMatchService).getMatchView(4242L, 1L);
mockMvc.perform(post("/applications/1/conversation").with(authentication(LEGAL)));
verify(matchConversationService).createOrGetMatchConversation(4242L, 1L);
mockMvc.perform(get("/companies/me/match-rule").with(authentication(LEGAL)));
verify(matchRuleService).getCompanyRule(4242L);
mockMvc.perform(get("/profile/me/talent-discovery").with(authentication(LEGAL)));
verify(talentDiscoveryService).getPreference(4242L);
mockMvc.perform(get("/profile/me/career-progress").with(authentication(LEGAL)));
verify(careerProgressService).getCareerProgress(eq(4242L), anyInt(), anyInt(), any());
mockMvc.perform(get("/jobs/1/match").with(authentication(LEGAL)));
verify(matchService).diagnose(eq(4242L), eq(1L), any());
mockMvc.perform(get("/companies/me/talent-invitations").with(authentication(LEGAL)));
verify(talentInvitationService).listCompany(eq(4242L), anyInt(), anyInt());
verify(currentUserAccessor, Mockito.times(8)).requireUserId();
verify(requestIdAccessor, Mockito.times(8)).get(any());
}
}

135
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java

@ -0,0 +1,135 @@
package com.mydreamjob.common.r01a4;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService;
import com.mydreamjob.bidirectionalmatch.MatchConversationService;
import com.mydreamjob.bidirectionalmatch.MatchRuleService;
import com.mydreamjob.careerprogress.CareerProgressService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.feedback.CandidateFeedbackService;
import com.mydreamjob.match.MatchService;
import com.mydreamjob.security.CurrentUserAccessor;
import com.mydreamjob.talentdiscovery.TalentDiscoveryService;
import com.mydreamjob.talentinvitation.TalentInvitationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
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.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
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.result.MockMvcResultMatchers.status;
/**
* R01-A4 实施证据:**接线变异检测**。
*
* <p>将两个 Accessor 替换为 mock,证明 8 个 Controller 确实调用了既有 bean:
* 若某 Controller 仍保留自有解析(未接线),mock 抛未登录时它不会返回 401。
* 因此**删除 Accessor 调用会使本测试失败**。</p>
*
* <p>本测试属实施阶段证据,不代替后续独立复验。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A4AccessorWiringProbeTest {
@Autowired private MockMvc mockMvc;
@MockBean private CurrentUserAccessor currentUserAccessor;
@MockBean private RequestIdAccessor requestIdAccessor;
@MockBean private CandidateFeedbackService feedbackService;
@MockBean private BidirectionalMatchService bidirectionalMatchService;
@MockBean private MatchConversationService matchConversationService;
@MockBean private MatchRuleService matchRuleService;
@MockBean private TalentDiscoveryService talentDiscoveryService;
@MockBean private CareerProgressService careerProgressService;
@MockBean private MatchService matchService;
@MockBean private TalentInvitationService talentInvitationService;
@MockBean private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final List<String> PATHS = List.of(
"/applications/1/feedback",
"/applications/1/match",
"/companies/me/match-rule",
"/profile/me/talent-discovery",
"/profile/me/career-progress",
"/jobs/1/match",
"/companies/me/talent-invitations");
private static final UsernamePasswordAuthenticationToken LEGAL =
new UsernamePasswordAuthenticationToken("42", "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
@Test
@DisplayName("A4 接线检测:Accessor 抛未登录时代表端点必须 401")
void accessorThrowsUnauthenticated_endpointsReturn401() throws Exception {
when(currentUserAccessor.requireUserId())
.thenThrow(new InsufficientAuthenticationException("authentication required"));
for (String path : PATHS) {
int st = mockMvc.perform(get(path).with(authentication(LEGAL)))
.andReturn().getResponse().getStatus();
assertThat(st).as("path=%s 若未接线 Accessor,不会是 401", path).isEqualTo(401);
}
verify(currentUserAccessor, times(PATHS.size())).requireUserId();
}
@Test
@DisplayName("A4 接线检测:Accessor 返回固定 userId 时 Service 收到该值")
void serviceReceivesAccessorUserId() throws Exception {
when(currentUserAccessor.requireUserId()).thenReturn(4242L);
when(requestIdAccessor.get(any())).thenReturn("a4-probe");
mockMvc.perform(get("/profile/me/career-progress")
.with(authentication(LEGAL))).andReturn();
verify(careerProgressService).getCareerProgress(eq(4242L), anyInt(), anyInt(), any());
mockMvc.perform(get("/companies/me/talent-invitations")
.with(authentication(LEGAL))).andReturn();
verify(talentInvitationService).listCompany(eq(4242L), anyInt(), anyInt());
}
@Test
@DisplayName("A4 接线检测:响应体 requestId 来自 Accessor mock")
void requestIdComesFromAccessorBean() throws Exception {
when(currentUserAccessor.requireUserId()).thenReturn(42L);
when(requestIdAccessor.get(any())).thenReturn("a4-probe-from-accessor");
for (String path : PATHS) {
String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
.andReturn().getResponse().getContentAsString();
assertThat(body).as("path=%s", path)
.contains("\"requestId\":\"a4-probe-from-accessor\"");
}
Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any());
}
@Test
@DisplayName("A4 接线检测:无 Token 仍 401(Security 链)")
void noToken_is401() throws Exception {
for (String path : PATHS) {
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
}
}
}

129
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4IndependentVerificationTest.java

@ -0,0 +1,129 @@
package com.mydreamjob.common.r01a4;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService;
import com.mydreamjob.bidirectionalmatch.MatchConversationService;
import com.mydreamjob.bidirectionalmatch.MatchRuleService;
import com.mydreamjob.careerprogress.CareerProgressService;
import com.mydreamjob.feedback.CandidateFeedbackService;
import com.mydreamjob.match.MatchService;
import com.mydreamjob.talentdiscovery.TalentDiscoveryService;
import com.mydreamjob.talentinvitation.TalentInvitationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
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.RequestBuilder;
import java.util.List;
import java.util.function.Function;
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.request.MockMvcRequestBuilders.post;
/** R01-A4 独立复验:八个 Controller 的真实 Mapping 必须全部经过统一身份与 requestId 边界。 */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A4IndependentVerificationTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final List<String> ILLEGAL = List.of(
"anonymousUser", "not-a-number-a4", "12345678901234567890", "0", "-1");
@Autowired private MockMvc mockMvc;
@MockBean private CandidateFeedbackService feedbackService;
@MockBean private BidirectionalMatchService bidirectionalMatchService;
@MockBean private MatchConversationService matchConversationService;
@MockBean private MatchRuleService matchRuleService;
@MockBean private TalentDiscoveryService talentDiscoveryService;
@MockBean private CareerProgressService careerProgressService;
@MockBean private MatchService matchService;
@MockBean private TalentInvitationService talentInvitationService;
@MockBean private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private record Endpoint(String controller, Function<Authentication, RequestBuilder> request) { }
private static List<Endpoint> endpoints() {
return List.of(
new Endpoint("CandidateFeedbackController", a -> get("/applications/1/feedback").with(authentication(a))),
new Endpoint("ApplicationMatchController", a -> get("/applications/1/match").with(authentication(a))),
new Endpoint("MatchConversationController", a -> post("/applications/1/conversation").with(authentication(a))),
new Endpoint("MatchRuleController", a -> get("/companies/me/match-rule").with(authentication(a))),
new Endpoint("TalentDiscoveryController", a -> get("/profile/me/talent-discovery").with(authentication(a))),
new Endpoint("CareerProgressController", a -> get("/profile/me/career-progress").with(authentication(a))),
new Endpoint("MatchController", a -> get("/jobs/1/match").with(authentication(a))),
new Endpoint("TalentInvitationController", a -> get("/companies/me/talent-invitations").with(authentication(a))));
}
private static Authentication principal(String name) {
return new UsernamePasswordAuthenticationToken(name, "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
}
@Test
@DisplayName("A4 独立复验:8/8 真实 Mapping 对非法主体均返回安全的 401")
void allEightMappingsRejectIllegalPrincipalsWithoutLeakage() throws Exception {
assertThat(endpoints()).hasSize(8);
for (Endpoint endpoint : endpoints()) {
for (String illegal : ILLEGAL) {
var response = mockMvc.perform(endpoint.request().apply(principal(illegal)))
.andReturn().getResponse();
assertThat(response.getStatus())
.as("%s principal=%s", endpoint.controller(), illegal).isEqualTo(401);
JsonNode body = JSON.readTree(response.getContentAsString());
assertThat(body.path("code").asInt()).isEqualTo(401);
assertThat(body.path("message").asText())
.doesNotContain("Exception", "java.", "principal", "NumberFormatException",
"not-a-number-a4", "12345678901234567890");
assertThat(body.has("stackTrace")).isFalse();
assertThat(body.has("trace")).isFalse();
}
}
}
@Test
@DisplayName("A4 独立复验:8/8 真实 Mapping 对匿名 token 均返回 401")
void allEightMappingsRejectAnonymousToken() throws Exception {
Authentication anonymous = new AnonymousAuthenticationToken("a4-key", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
for (Endpoint endpoint : endpoints()) {
assertThat(mockMvc.perform(endpoint.request().apply(anonymous)).andReturn().getResponse().getStatus())
.as(endpoint.controller()).isEqualTo(401);
}
}
@Test
@DisplayName("A4 独立复验:8/8 真实 Mapping 的响应 body 与规范化响应头 requestId 同源")
void allEightMappingsUseNormalizedRequestId() throws Exception {
Authentication legal = principal("4242");
for (Endpoint endpoint : endpoints()) {
var request = endpoint.request().apply(legal);
if (request instanceof org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder builder) {
builder.header("X-Request-Id", "a4/<unsafe value>" + "x".repeat(80));
}
var response = mockMvc.perform(request).andReturn().getResponse();
assertThat(response.getStatus()).as(endpoint.controller()).isNotEqualTo(401);
String requestId = response.getHeader("X-Request-Id");
assertThat(requestId).isNotBlank().hasSizeLessThanOrEqualTo(32)
.doesNotContain("<", ">", " ", "\r", "\n");
assertThat(response.getContentAsString())
.contains("\"requestId\":\"" + requestId + "\"");
}
}
}

214
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java

@ -0,0 +1,214 @@
package com.mydreamjob.common.r01a4;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService;
import com.mydreamjob.bidirectionalmatch.MatchConversationService;
import com.mydreamjob.bidirectionalmatch.MatchRuleService;
import com.mydreamjob.careerprogress.CareerProgressService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.feedback.CandidateFeedbackService;
import com.mydreamjob.match.MatchService;
import com.mydreamjob.talentdiscovery.TalentDiscoveryService;
import com.mydreamjob.talentinvitation.TalentInvitationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
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 java.util.List;
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-A4 第四批:请求身份与 requestId 收束的特征测试。
*
* <p>代表端点 URL 全部取自源码真实 Mapping。身份只来自
* {@code CurrentUserAccessor#requireUserId()},requestId 只来自
* {@code RequestIdAccessor#get(HttpServletRequest)}。</p>
*
* <p>对溢出/非数字主体不做整响应数字搜索;无 Token 请求由 Security 链提前返回,
* 本批只断言 401、不要求其携带 requestId。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class R01A4RequestContextMigrationIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private RequestIdAccessor requestIdAccessor;
@MockBean private CandidateFeedbackService feedbackService;
@MockBean private BidirectionalMatchService bidirectionalMatchService;
@MockBean private MatchConversationService matchConversationService;
@MockBean private MatchRuleService matchRuleService;
@MockBean private TalentDiscoveryService talentDiscoveryService;
@MockBean private CareerProgressService careerProgressService;
@MockBean private MatchService matchService;
@MockBean private TalentInvitationService talentInvitationService;
@MockBean private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 8 个 Controller 的真实代表端点(GET,取自源码 Mapping)。 */
private static final List<String> PATHS = List.of(
"/applications/1/feedback", // CandidateFeedbackController
"/applications/1/match", // ApplicationMatchController
"/companies/me/match-rule", // MatchRuleController
"/profile/me/talent-discovery", // TalentDiscoveryController
"/profile/me/career-progress", // CareerProgressController
"/jobs/1/match", // MatchController
"/companies/me/talent-invitations"); // TalentInvitationController
/** MatchConversationController 只有 POST 端点,单独以 POST 覆盖。 */
private static final String CONVERSATION_PATH = "/applications/1/conversation";
private static final List<String> ILLEGAL = List.of(
"anonymousUser", "", " ", "not-a-number", "abc123",
"12345678901234567890", "0", "-1");
private static Authentication principal(String name) {
return new UsernamePasswordAuthenticationToken(name, "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
}
private List<String> allPaths() {
List<String> all = new java.util.ArrayList<>(PATHS);
all.add(CONVERSATION_PATH);
return all;
}
// ── 身份 ─────────────────────────────────────────────────
@Test
@DisplayName("A4:八端点 × 八非法主体 一律 401(非 400/500)")
void illegalPrincipals_areAlways401() throws Exception {
for (String path : PATHS) {
for (String p : ILLEGAL) {
int st = mockMvc.perform(get(path).with(authentication(principal(p))))
.andReturn().getResponse().getStatus();
assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401);
}
}
}
@Test
@DisplayName("A4:POST 端点(MatchConversation)非法主体同样 401")
void conversationEndpoint_illegalPrincipals_are401() throws Exception {
for (String p : ILLEGAL) {
int st = mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.post(CONVERSATION_PATH).with(authentication(principal(p))))
.andReturn().getResponse().getStatus();
assertThat(st).as("principal='%s'", p).isEqualTo(401);
}
}
@Test
@DisplayName("A4:匿名 token 与无 Token 均 401")
void anonymousAndNoToken_are401() throws Exception {
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
for (String path : allPaths()) {
assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn()
.getResponse().getStatus()).as(path).isEqualTo(401);
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
}
}
@Test
@DisplayName("A4:错误体安全,不回显 principal/类名/堆栈")
void errorBody_isSafe() throws Exception {
for (String path : PATHS) {
for (String p : ILLEGAL) {
String body = mockMvc.perform(get(path).with(authentication(principal(p))))
.andReturn().getResponse().getContentAsString();
JsonNode json = MAPPER.readTree(body);
assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401);
String msg = json.path("message").asText();
assertThat(msg).isIn("authentication required", "not authenticated");
assertThat(msg).doesNotContain("Exception").doesNotContain("java.")
.doesNotContain("principal").doesNotContain("at com.mydreamjob");
assertThat(json.has("stackTrace")).isFalse();
assertThat(json.has("trace")).isFalse();
if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) {
assertThat(msg).doesNotContain(p);
}
}
}
}
@Test
@DisplayName("A4:溢出主体无 NumberFormatException")
void overflow_hasNoNumberFormatException() throws Exception {
for (String path : allPaths()) {
String body = mockMvc.perform(get(path)
.with(authentication(principal("12345678901234567890"))))
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("NumberFormatException");
}
}
@Test
@DisplayName("A4:合法主体进入原角色/归属判断(不被身份层拒绝)")
void legalPrincipal_reachesRoleLayer() throws Exception {
for (String path : allPaths()) {
int st = mockMvc.perform(get(path).with(authentication(principal("7"))))
.andReturn().getResponse().getStatus();
assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401);
}
}
// ── requestId ────────────────────────────────────────────
@Test
@DisplayName("A4:requestId 合法/空白/超长/非法字符 均与响应头同源")
void requestId_consistentAcrossInputs() throws Exception {
for (String sent : List.of("a4-legal-0001", " ", "w".repeat(80), "<script>x</script>", "a b")) {
for (String path : PATHS) {
var r = mockMvc.perform(get(path).header("X-Request-Id", sent)
.with(authentication(principal("7")))).andReturn();
String hdr = r.getResponse().getHeader("X-Request-Id");
assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull()
.doesNotContain("<").doesNotContain(">").doesNotContain(" ")
.doesNotContain("\r").doesNotContain("\n");
assertThat(hdr.length()).isLessThanOrEqualTo(32);
assertThat(r.getResponse().getContentAsString()).as("path=%s", path)
.contains("\"requestId\":\"" + hdr + "\"");
}
}
}
@Test
@DisplayName("A4:request attribute 优先于原始 header")
void requestAttributeWins() {
var req = new org.springframework.mock.web.MockHttpServletRequest();
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a4-attr-wins");
req.addHeader("X-Request-Id", "a4-header-loses");
assertThat(requestIdAccessor.get(req)).isEqualTo("a4-attr-wins");
}
@Test
@DisplayName("A4:CR/LF 不进入规范化结果(直接经 Accessor)")
void crlfNeverEntersValue() {
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "z\r\nInjected: 1")) {
var req = new org.springframework.mock.web.MockHttpServletRequest();
req.addHeader("X-Request-Id", bad);
String v = requestIdAccessor.get(req);
assertThat(v).as("输入 %s", bad).isNotBlank().doesNotContain("\r").doesNotContain("\n");
}
}
}
Loading…
Cancel
Save