Browse Source

refactor: centralize third batch request context handling

master
Zhang Shun 1 month ago
parent
commit
d5c37128e8
  1. 2
      docs/backend-development-roadmap.md
  2. 221
      docs/r01-a3-independent-verification-20260917.md
  3. 276
      docs/r01-a3-request-context-migration-report.md
  4. 30
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java
  5. 34
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java
  6. 26
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java
  7. 37
      mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java
  8. 26
      mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java
  9. 30
      mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java
  10. 30
      mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java
  11. 41
      mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java
  12. 128
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java
  13. 220
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java
  14. 195
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.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 GET 评分快照写入迁移方案设计完成(见 `docs/r01-d4-get-scoring-snapshot-migration-design.md`),待负责人确认后分阶段实施**(仅标记为迁移方案设计完成,不作为性能整改完成) | overview 与偏好口径已集中;百分位 O(n log n);数据导出与会话未读数均改为固定批量查询;R01-D4 确认三 GET 评分接口存在读取放大写入(upsert 快照),已产出写侧事件矩阵与 D4-1/D4-2 分阶段迁移方案 |
| 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 个**,待后续批次 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

221
docs/r01-a3-independent-verification-20260917.md

@ -0,0 +1,221 @@
# R01-A3 独立代码复验报告(第三批:8 个 Controller 请求身份与 requestId 收束)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变)
复验对象:`docs/r01-a3-request-context-migration-report.md`
性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送
---
## 0. 结论
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。**
**FAIL = 0(A3 范围内);SKIP = 0。** 另发现**基线已存在的非 A3 阻断缺陷** 1 项(§7),已用基线 worktree 证伪归因。
---
## 1. 边界确认
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 工作区构成 | **A2(8 个 Controller)+ A3(8 个 Controller)** + 路线图 + `GlobalExceptionHandler`(stat 缓存,内容零差异)✔ |
| 归因纪律 | 本报告的 A3 证据**仅取自 A3 的 8 个文件路径**;A2 的 8 个文件未计入 A3 证据 ✔ |
| 未进入 A4 | ✔ |
| 未修改既有污染测试 | ✔ |
---
## 2. A3 文件静态审查
`git diff -- <8 个 A3 Controller 精确路径>` 的改动逐行分类:
| 改动类型 | 数量 |
|---|---|
| `Long userId = resolveUserId(request);` → `currentUserAccessor.requireUserId()` | 21 |
| 新增 `private final CurrentUserAccessor currentUserAccessor;` | 8 |
| 新增 `private final RequestIdAccessor requestIdAccessor;` | 8 |
| 删除 `import ...SecurityContextHolder;` | 8 |
| 删除 `import ...Authentication;` | 7 |
| 删除 `return request.getHeader("X-Request-Id");` | 7 |
| 删除 `return Long.valueOf(auth.getName());` | 7 |
| `getRequestId(request)` / `requestId(request)` → `requestIdAccessor.get(request)` | 其余全部调用点 |
| `riskAuditContext` 内 header 直读 → `requestIdAccessor.get(request)` | 1 |
### 2.1 私有方法删除清单 —— **实际为 16 个(实施报告写 17,属笔误)**
逐条核对(`git diff | grep '^-.*private .*(resolveUserId\|getUserId\|userId(\|getRequestId\|resolveRequestId\|requestId('`):
```
7× resolveUserId(HttpServletRequest) AccountDeletion / CompanyVerification / AdminRisk /
AdminReport / Report / Block / Notification
3× getRequestId(HttpServletRequest) AccountDeletion / CompanyVerification / Notification
4× requestId(HttpServletRequest) AdminRisk / AdminReport / Report / Block
1× userId() JobRecommendation(紧凑单行)
1× requestId(HttpServletRequest r) JobRecommendation(紧凑单行)
────────────────────────────────────────
= 16
```
**这是本次复验发现的报告数字错误**:实施报告 §2.1 称「共删除私有解析方法 17 个」,实际为 **16 个**(8 个 Controller × 2)。行为本身正确(8 个文件门禁命中均为 0),仅报告计数有误。
`JobRecommendationController` 原压缩单行身份方法**已完整删除**:
```java
- private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); ... }
- private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");}
```
其 9 处调用点全部改为 `currentUserAccessor.requireUserId()` / `requestIdAccessor.get(request)` ✔
### 2.2 注入方式
8 个 Controller 均为 `private final` 构造器注入;**无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` ✔
### 2.3 未漂移项
| 核查项 | 结论 |
|---|---|
| 角色检查位置 | **未移动** —— `verificationService.assertAdminRole(userId)`×4、`AdminRisk`/`AdminReport`/`Report` 内的 `userRepository.findById → getRoles → FORBIDDEN`、`JobRecommendation` 的 Service 层裁决,全部原样保留 ✔ |
| 资源归属检查 | 未移动 ✔ |
| Service 调用顺序 | 未变 ✔ |
| DTO / 错误码 / 响应结构 | 未变(`ErrorCode`、`SecurityConfig`、Flyway 均零修改)✔ |
| 是否混入 `UserRoleGuard` 或其他重构 | **否** ✔ |
---
## 3. 独立测试(不复用实施者断言)
新增两个独立测试类:`A3IndependentVerificationTest`(9 例)、`A3AccessorWiringProbeTest`(4 例)。
| 覆盖 | 结果 |
|---|---|
| 8 端点 × 8 非法主体(`anonymousUser`/空/空白/非数字/`abc123`/**20 位溢出**/`0`/`-1`) | 全部 **401**,无 500 ✔ |
| 匿名 token 与无 Token | 401 ✔(无 Token 由 Security 链提前返回,按要求**只断言 401**、不要求补 requestId) |
| 错误体安全 | `code=401`;`message ∈ {authentication required, not authenticated}`;无类名/堆栈/`principal`;非数字标记不回显(纯数字主体不参与该断言,避免与状态码/时间戳碰撞)✔ |
| 溢出主体 | 无 `NumberFormatException`;**解析 JSON 后仅检查业务字段**,未做整响应数字搜索 ✔ |
| 合法主体 | 不被身份层拒绝 ✔ |
| **角色语义未漂移** | 显式 stub 角色层抛 `FORBIDDEN` → **403**(非 401);`AdminRiskController` 桩造非 ADMIN 用户 → **403** ✔ |
| request attribute 优先于 header | ✔ |
| 响应 body 使用规范化 requestId(与响应头同源) | 5 种输入 × 8 端点 ✔ |
| CR/LF | 不进入规范化结果(直接经 Accessor 验证)✔ |
| **Accessor 接线变异检测** | mock 抛未登录 → 8 端点必须 401;mock requestId 必须出现在响应体;`verify(notificationService).list(42L, …)` —— **删除 Accessor 调用会使该测试失败** ✔ |
---
## 4. 统计口径重新核对(任务书第四节)
使用更完整的模式(含 `requestId(...)` 方法名与 `X-Request-Id` 直读):
```bash
rg -l 'request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(' \
mydreamjob-server/src/main/java -g "*.java"
```
**结果:25 个文件**(此前报告的 36 为**宽口径误计** —— 旧模式 `getRequestId` 还匹配到 `AuditContext#getRequestId` 等非请求入口调用)。
| 分类 | 数量 | 明细 |
|---|---|---|
| **合理基础设施** | **4** | `audit/AuditContext.java`、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java`、`deletion/DeletionGuardInterceptor.java` |
| **尚未迁移的业务 Controller** | **21** | `bidirectionalmatch/*`×3、`candidatescore/governance/`、`careerprogress/`、`controller/*`×3、`feedback/`、`interviewscore/`、`job/*`×2、`match/`、`review/*`×5、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/` |
| **Service 或非请求入口** | **0** | — |
| **误报** | **0** | — |
**真实业务剩余数 = 21 个业务 Controller**(非「36 个文件」)。
身份解析:
```bash
rg -l 'SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)' \
mydreamjob-server/src/main/java -g "*.java"
```
**结果:20 个文件** = 基础设施 3(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`)+ **业务 Controller 17** + 误报 0。
**真实业务剩余数 = 17 个业务 Controller。**
---
## 5. 验证结果
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="R01A3…,A3IndependentVerificationTest,A3AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **51 / 51**,F=0 E=0 **S=0** |
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **260 / 260**,F=0 E=**1**,BUILD FAILURE —— **全部为基线已存在的非 A3 缺陷**(§7) |
| `mvn clean verify` | **2220 / 2220**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
- 较实施轮次(2207)新增 **13** 个测试,全部为本次独立复验新增的两个测试类
- **`docs/openapi-rc.json` 零修改**;**`ErrorCode` / Flyway / `SecurityConfig` 零修改** ✔
---
## 6. 独立真实 HTTP 验证
只读实例 PID 16604,验证后已停止。
| 场景 | 结果 |
|---|---|
| 无 token / 畸形 Bearer / 纯数字 Bearer × 8 端点 | 全部 **401** ✔ |
| STUDENT → ADMIN 风险概览 / 举报 / 认证审核 | **403** ✔ |
| ADMIN → 风险概览(合法) | **200** ✔ |
| STUDENT → 通知列表(合法) | **200** ✔ |
| **上述全部请求前后 22 张表逐字段比较** | **全部零变化** ✔ |
---
## 7. 基线已存在的非 A3 阻断缺陷(如实记录,未修复)
关联回归命令触发 1 个 Error:
```
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000'
```
**隔离复现(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A2/A3 改动**)中用**完全相同**的命令运行:
| | 测试数 | 失败签名 |
|---|---|---|
| 基线(HEAD) | **223** | `AccountDeletionControllerTest...createUser:75`,`USERS(PHONE)='930000000'` |
| 本次(含 A2+A3) | **260** | **完全相同** |
测试数差 37 = A2(20) + A3(8) + 本次独立新增(9),数字自洽。
**判定**:**基线已存在的非 A3 阻断缺陷**,与 A2/A3 无关。
- 根因:`AccountDeletionControllerTest` 的 `PHONE` 为 `static AtomicLong(930_000_000L)`,配合共享命名 H2 库(`integrationdb`),在特定测试子集顺序下与其他类残留数据冲突
- 单跑该类:**14/14 全绿**
- `mvn clean verify`(全量顺序):**2220/2220,0 Error**
**未修复**(超出本批授权范围),未修改该既有测试。建议后续单独处理。
---
## 8. 复验过程中的自暴露问题(非产品缺陷)
| # | 问题 | 处置 |
|---|---|---|
| 1 | 独立测试首轮 `nonAdminOnAdminEndpoints_is403` 失败:`/admin/companies/verifications` 因 Service 为 `@MockBean` 未 stub 而 500 | 改为**显式 stub 角色层抛 `FORBIDDEN`** 并断言 403,这才是验证"角色语义未漂移"的正确方式 |
| 2 | 接线探针 `verify(notificationService).list(42L)` 参数个数错误(实际为 3 参) | 改为 `list(eq(42L), anyInt(), anyInt())` |
---
## 9. 结论与下一步约束
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。**
**必须遵守的下一步约束**:
1. **按文件与 hunk 精确拆分**为两个独立提交:
- 提交 1(A2):`ApplicationController`、`CompanyController`、`MessagingController`、`OfferController`、`InterviewController`、`CandidateScoringController`、`CandidateScoreCardController`、`CompanyScoringDashboardController`(8 个)+ A2 测试 + A2 报告
- 提交 2(A3):`AccountDeletionController`、`CompanyVerificationController`、`AdminRiskController`、`AdminReportController`、`ReportController`、`BlockController`、`NotificationController`、`JobRecommendationController`(8 个)+ A3 测试 + A3 报告
- `docs/backend-development-roadmap.md` 同时被两批修改,**必须用 `git add -p` 拆分 hunk**
2. **不得合并提交**。
3. `GlobalExceptionHandler.java` 仅 stat 缓存标记(内容零差异),**不得提交**。
4. 既有污染测试缺陷**不属 A2/A3**,若修复应作为独立提交,不得混入。
**复验完成前,未暂存、未提交、未推送、未进入 A4。**

276
docs/r01-a3-request-context-migration-report.md

@ -0,0 +1,276 @@
# R01-A3 第三批:请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`
范围:**仅 8 个 Controller**(AccountDeletionController、CompanyVerificationController、AdminRiskController、AdminReportController、ReportController、BlockController、NotificationController、JobRecommendationController)
---
## 0. 结论
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。**
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生任何漂移。
---
## 1. 开始前检查与一处预期偏差
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ |
| 与上游同步 | `0 / 0` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 8 个目标文件在开始前 | **均未改动**(无 A3 以外的真实改动)✔ |
| `GlobalExceptionHandler.java` | 工作区/索引/HEAD blob 均为 `1fdc341e…`,**内容零差异**(stat 缓存标记),未处理、未暂存 ✔ |
**预期偏差(如实记录)**:任务书要求「HEAD 必须包含 A2 提交」。**实际 HEAD 为 `52c8026`(R01-D4 提交),A2 尚未提交** —— 上一轮 A2 独立复验明确「未暂存、未提交、未推送」,其改动仍在工作区。因此本批是在 A2 未提交的工作区之上继续实施的。A2 与 A3 同属请求上下文收束、互不冲突;两者将一并进入后续暂存检查点。
---
## 2. 迁移前契约表
### 2.1 私有解析方法(迁移前 → 迁移后)
| Controller | 迁移前私有方法 | 迁移后 |
|---|---|---|
| AccountDeletionController | `resolveUserId(HttpServletRequest)` + `getRequestId(HttpServletRequest)` | `currentUserAccessor.requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 |
| CompanyVerificationController | `resolveUserId` + `getRequestId` | ×6;×6 |
| AdminRiskController | `resolveUserId` + `requestId` + `riskAuditContext` 内直读 header | `requireUserId()` ×1(在 `requireAdmin` 内);`requestIdAccessor.get(request)` ×8(含 `riskAuditContext`) |
| AdminReportController | `resolveUserId` + `requestId` | ×1;×3 |
| ReportController | `resolveUserId` + `requestId` | ×3;×3 |
| BlockController | `resolveUserId` + `requestId` | ×3;×3 |
| NotificationController | `resolveUserId` + `getRequestId` | ×4;×4 |
| JobRecommendationController | `userId()`(**无参紧凑式**)+ `requestId(HttpServletRequest)` | ×7;×8 |
**共删除私有解析方法 16 个**(8 个 Controller × 2:7× `resolveUserId` + 3× `getRequestId` + 4× `requestId` + 1× `userId()` + 1× `requestId(r)`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import(8 个文件均移除)。每个 Controller 新增:
```java
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
### 2.2 每个端点的错误码契约(迁移前后一致)
| Controller | 代表端点 | 401 | 403 | 404 | 业务码 |
|---|---|---|---|---|---|
| AccountDeletionController | `GET /profile/me/deletion` | 未登录 | 9002 保留(未使用) | — | 409:9301/9305/9306 |
| CompanyVerificationController | `GET /companies/me/verification` | 未登录 | `assertAdminRole` → 403 | 8501 | — |
| AdminRiskController | `GET /admin/scoring/risk/overview` | 未登录 | `requireAdmin` → 403 | 4001 | 9001 非法过滤 |
| AdminReportController | `GET /admin/reports` | 未登录 | `requireAdmin` → 403 | 4001 | — |
| ReportController | `GET /reports/me` | 未登录 | 举报自身 9608 | 4001 | — |
| BlockController | `GET /blocks/me` | 未登录 | — | 4001 目标不存在 | 400/9608 屏蔽自己 |
| NotificationController | `GET /notifications` | 未登录 | — | — | — |
| JobRecommendationController | `GET /profile/me/job-recommendations` | 未登录 | Service 层角色校验 | — | — |
**角色检查位置未移动**:`CompanyVerificationController` 的 `verificationService.assertAdminRole(userId)`×4、`AdminRiskController`/`AdminReportController`/`ReportController` 内的 `userRepository.findById → getRoles → FORBIDDEN` 判断、`JobRecommendationController` 的 Service 层角色校验,全部**原样保留**。
**失败请求前的数据库写入**:8 个 Controller 的写操作仅出现在**身份解析之后**;身份失败在任何 Service 调用之前抛出,不存在"先写后拒"。
---
## 3. 修复前失败测试(TDD 证据)
新增 `R01A3RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/`),使用真实 `SecurityFilterChain` + `MockMvc`。
**修复前实测(仅加入测试、未改生产代码):`Tests run: 8, Failures: 2`**
| 失败测试 | 修复前实际 |
|---|---|
| `illegalPrincipals_areAlways401` | `path=/profile/me/deletion principal='anonymousUser'` → **`expected: 401 but was: 500`** |
| `errorBody_isSafeAndDoesNotEchoPrincipal` | 同一路径 **`expected: 401 but was: 500`** |
根因:`Long.valueOf("anonymousUser")` 抛 `NumberFormatException` 逃逸为 HTTP 500。
**修复后:`Tests run: 8, Failures: 0`**
---
## 4. 逐文件静态门禁
任务书指定模式在 8 个文件中的命中:
| Controller | 命中 |
|---|---|
| AccountDeletionController | **0** |
| CompanyVerificationController | **0** |
| AdminRiskController | **0** |
| AdminReportController | **0** |
| ReportController | **0** |
| BlockController | **0** |
| NotificationController | **0** |
| JobRecommendationController | **0** |
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor`
**全部为 0,无任何需要解释的业务参数命中。身份/requestId 私有解析残留为 0。**
---
## 5. 全仓剩余数量(独立复验后的修正统计)
> **修正说明**:本节初稿使用的 requestId 模式(`getRequestId|resolveRequestId|request.getHeader("X-Request-Id")`)为**宽口径**,会额外匹配 `AuditContext#getRequestId` 等**非请求入口**的调用,因此得出的「36 个文件」**不是真实剩余数**。以下为 R01-A3 独立复验用更完整模式重新核对后的权威统计。
**身份解析**(模式:`SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)`)
| 分类 | 数量 |
|---|---|
| 全仓命中文件 | **20** |
| —— 合理基础设施 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) |
| —— **尚未迁移的业务 Controller** | **17** |
| —— 误报 | 0 |
**requestId 解析**(模式:`request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(`)
| 分类 | 数量 |
|---|---|
| 全仓命中文件 | **25** |
| —— 合理基础设施 | **4**(`audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor`) |
| —— **尚未迁移的业务 Controller** | **21** |
| —— Service 或非请求入口 | **0** |
| —— 误报 | **0** |
**A2 + A3 合计已迁移 16 个业务 Controller**,本批(A3)未覆盖的剩余业务 Controller 为**身份解析 17 个、requestId 解析 21 个**,均未修改。
---
## 6. 角色与副作用验证(真实 HTTP)
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 22228,验证后已停止。
| 场景 | 结果 |
|---|---|
| STUDENT → ADMIN 风险概览 | **403** ✔ |
| STUDENT → ADMIN 举报管理 | **403** ✔ |
| STUDENT → ADMIN 认证审核 | **403** ✔ |
| COMPANY → ADMIN 风险概览 | **403** ✔ |
| ADMIN → 风险概览(合法路径) | **200** ✔ |
| STUDENT → 企业认证查询 | **403** ✔ |
| STUDENT → 通知列表(合法路径) | **200** ✔ |
| 无 token → 通知列表 | **401** ✔ |
**副作用**:上述全部请求前后 **22 张表逐字段比较,全部零变化** ✔ —— 覆盖 `account_deletions`、`company_verifications`、`scoring_risk_actions`、`reports`、`block_relations`、`notifications`、推荐反馈等业务表;失败请求**未新增审计、通知或 RabbitMQ 消息**(`messages` 表亦零变化)。
---
## 7. requestId 同源证据
| 输入 | 结果 |
|---|---|
| 合法值 `a3-legal-0001` | 响应头 == 响应体,原样保留 ✔ |
| 空白 `" "` | 重新生成非空值,响应头 == 响应体 ✔ |
| 超长(80 字符) | 收敛至 ≤32,响应头 == 响应体 ✔ |
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格;响应体 == 响应头 ✔ |
| 已存在规范化 request attribute | **attribute 优先于 header** ✔ |
| CR / LF / CRLF(直接经 Accessor 验证) | 不进入规范化结果 ✔ |
覆盖 8 个端点的全部上述输入组合。
---
## 8. 测试数量与 OpenAPI
| 命令 | 结果 |
|---|---|
| A3 定向(`R01A3…,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **38 / 38**,F=0 E=0 **S=0** |
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **251 / 251**,F=0 E=0 **S=0**(1 项既有污染详见 §9) |
| `mvn test` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `mvn clean verify` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
- 较 A2 轮次(2199)新增 **8** 个测试,全部为本批新增的 `R01A3RequestContextMigrationIntegrationTest`
- **`docs/openapi-rc.json` 零修改** ✔
- **未新增公网接口、迁移、表、索引或 ErrorCode** ✔
---
## 9. 一处既有测试污染(非本批引入,已用基线 worktree 证伪)
关联回归首次运行出现 1 个 Error:
```
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000'
```
**归因验证**:用 `git worktree add --detach HEAD`(HEAD 不含本批改动)在**基线代码**上运行**完全相同**的测试批次:
```
基线(HEAD):Tests run: 223, Failures: 0, Errors: 1 ← 完全相同的失败
```
**结论**:该失败是**既有的测试间交叉污染**(`AccountDeletionControllerTest` 的 `PHONE` 为 static 计数器、基数 `930000000`,配合共享 H2 库 `integrationdb`,在特定测试子集顺序下与其他类残留数据冲突),**与本批改动无关**。
- 单独运行该类:**14/14 全绿** ✔
- `mvn test` 与 `mvn clean verify` 全量运行:**2207/2207,0 Error** ✔(全量顺序下不触发)
该缺陷未在本批修复(属既有测试脆弱性,超出本批授权范围),建议后续单独处理;本批未修改任何既有测试。
---
## 10. 精确修改文件
**生产代码(8 个,全部在任务书指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java
mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java
mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java
mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java
mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java
mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java
```
(`8 files changed, 100 insertions(+), 154 deletions(-)`)
**测试(新增 1 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java
```
**文档(1 个)**
```
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节
```
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、`UserRoleGuard`、既有测试、"匿名 401 无 requestId"观察项。
---
## 11. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变)
暂存区:空
git add -A / commit / push:均未执行
reset / restore / checkout / clean:均未执行
git worktree:临时基线 worktree 已用 --force 移除并 prune(无残留)
历史未跟踪文件(247 个,含 docs/acceptance、backend-infra 等):完整保留
Docker:未操作
```
验收临时用户(8025/8026)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。
---
## 12. 明确声明
- **未暂存、未提交、未推送。**
- 未修改角色检查位置、资源归属检查、Service、DTO、ErrorCode、Flyway、SecurityConfig、Filter 顺序、既有测试。
- 未进入 `UserRoleGuard`;未处理"匿名 401 无 requestId"观察项。
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。
- 全仓剩余身份/requestId 解析数量已如实报告(含 7 而非 8 的差异说明),未声称全仓完成。
---
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。**

30
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java

@ -14,8 +14,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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -27,6 +25,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class AccountDeletionController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final AccountDeletionService accountDeletionService;
@Operation(
@ -57,9 +60,9 @@ public class AccountDeletionController {
})
@PostMapping
public ResponseEntity<ApiResponse<AccountDeletionResponse>> initiate(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
accountDeletionService.initiate(userId), getRequestId(request)));
accountDeletionService.initiate(userId), requestIdAccessor.get(request)));
}
@Operation(
@ -82,9 +85,9 @@ public class AccountDeletionController {
})
@GetMapping
public ResponseEntity<ApiResponse<AccountDeletionResponse>> getStatus(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
accountDeletionService.getCurrentStatus(userId), getRequestId(request)));
accountDeletionService.getCurrentStatus(userId), requestIdAccessor.get(request)));
}
@Operation(
@ -112,20 +115,9 @@ public class AccountDeletionController {
})
@PostMapping("/cancel")
public ResponseEntity<ApiResponse<AccountDeletionResponse>> cancel(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
accountDeletionService.cancel(userId), getRequestId(request)));
accountDeletionService.cancel(userId), 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/notification/NotificationController.java

@ -7,8 +7,6 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
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.HashMap;
@ -21,6 +19,11 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class NotificationController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final NotificationService notificationService;
@Operation(summary = "通知列表", description = "当前用户的通知,分页,按时间倒序")
@ -29,7 +32,7 @@ public class NotificationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Page<Notification> result = notificationService.list(userId, page, size);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent().stream().map(notificationService::toView).collect(Collectors.toList()));
@ -37,7 +40,7 @@ public class NotificationController {
data.put("page", result.getNumber());
data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "标记已读", description = "标记单条通知已读(幂等)")
@ -45,36 +48,25 @@ public class NotificationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> markRead(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
notificationService.markRead(userId, id);
return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), requestIdAccessor.get(request)));
}
@Operation(summary = "全部已读", description = "标记当前用户全部通知已读(幂等)")
@PostMapping("/read-all")
public ResponseEntity<ApiResponse<Map<String, Object>>> markAllRead(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
int marked = notificationService.markAllRead(userId);
return ResponseEntity.ok(ApiResponse.ok(Map.of("marked", marked), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(Map.of("marked", marked), requestIdAccessor.get(request)));
}
@Operation(summary = "未读数", description = "当前用户未读通知数")
@GetMapping("/unread-count")
public ResponseEntity<ApiResponse<Map<String, Object>>> unreadCount(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
long count = notificationService.unreadCount(userId);
return ResponseEntity.ok(ApiResponse.ok(Map.of("unreadCount", count), 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("unreadCount", count), requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

26
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java

@ -10,7 +10,6 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.core.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -18,49 +17,52 @@ import java.util.List;
@RestController
@RequiredArgsConstructor
public class JobRecommendationController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final JobRecommendationService service;
@GetMapping("/job-recommendation-taxonomies")
public ApiResponse<List<TaxonomyResponse>> taxonomies(HttpServletRequest request) {
return ApiResponse.ok(service.taxonomies(), requestId(request));
return ApiResponse.ok(service.taxonomies(), requestIdAccessor.get(request));
}
@GetMapping("/profile/me/job-recommendations")
public ApiResponse<PageResponse<JobRecommendationItem>> recommendations(@RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="20") int size, HttpServletRequest request) {
Page<JobRecommendationItem> result = service.recommend(userId(), page, size);
return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request));
Page<JobRecommendationItem> result = service.recommend(currentUserAccessor.requireUserId(), page, size);
return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestIdAccessor.get(request));
}
@PostMapping("/profile/me/job-recommendations/{jobId}/feedback")
public ApiResponse<RecommendationFeedbackResponse> feedback(@PathVariable Long jobId,@Valid @RequestBody RecommendationFeedbackRequest body,HttpServletRequest request) {
return ApiResponse.ok(service.feedback(userId(),jobId,body,AuditContext.from(request)),requestId(request));
return ApiResponse.ok(service.feedback(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request));
}
@DeleteMapping("/profile/me/job-recommendations/{jobId}/feedback")
public ApiResponse<Void> revokeFeedback(@PathVariable Long jobId,@RequestParam Long expectedVersion,HttpServletRequest request) {
service.revokeNotInterested(userId(),jobId,expectedVersion,AuditContext.from(request)); return ApiResponse.ok(null,requestId(request));
service.revokeNotInterested(currentUserAccessor.requireUserId(),jobId,expectedVersion,AuditContext.from(request)); return ApiResponse.ok(null,requestIdAccessor.get(request));
}
@GetMapping("/companies/me/jobs/{jobId}/recommendation-profile")
public ApiResponse<RecommendationProfileResponse> profile(@PathVariable Long jobId,HttpServletRequest request) {
return ApiResponse.ok(service.companyProfile(userId(),jobId),requestId(request));
return ApiResponse.ok(service.companyProfile(currentUserAccessor.requireUserId(),jobId),requestIdAccessor.get(request));
}
@PutMapping("/companies/me/jobs/{jobId}/recommendation-profile")
public ApiResponse<RecommendationProfileResponse> updateProfile(@PathVariable Long jobId,@Valid @RequestBody RecommendationProfileRequest body,HttpServletRequest request) {
return ApiResponse.ok(service.upsertCompanyProfile(userId(),jobId,body,AuditContext.from(request)),requestId(request));
return ApiResponse.ok(service.upsertCompanyProfile(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request));
}
@GetMapping("/admin/job-recommendation-profiles")
public ApiResponse<PageResponse<RecommendationProfileResponse>> adminProfiles(@RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="20") int size,@RequestParam(required=false) String status,HttpServletRequest request) {
Page<RecommendationProfileResponse> result=service.adminProfiles(userId(),page,size,status); return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request));
Page<RecommendationProfileResponse> result=service.adminProfiles(currentUserAccessor.requireUserId(),page,size,status); return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestIdAccessor.get(request));
}
@PostMapping("/admin/job-recommendation-profiles/{jobId}/decision")
public ApiResponse<RecommendationProfileResponse> decide(@PathVariable Long jobId,@Valid @RequestBody RecommendationDecisionRequest body,HttpServletRequest request) {
return ApiResponse.ok(service.decide(userId(),jobId,body,AuditContext.from(request)),requestId(request));
return ApiResponse.ok(service.decide(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request));
}
private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); if(a==null||!a.isAuthenticated())throw new BusinessException(ErrorCode.UNAUTHORIZED); return Long.valueOf(a.getName()); }
private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");}
}

37
mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java

@ -22,8 +22,6 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
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;
@ -49,6 +47,11 @@ import java.util.List;
@RequiredArgsConstructor
public class AdminRiskController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final RiskGovernanceService riskGovernanceService;
private final UserRepository userRepository;
@ -66,7 +69,7 @@ public class AdminRiskController {
HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(
riskGovernanceService.overview(parse(from), parse(to)), requestId(request)));
riskGovernanceService.overview(parse(from), parse(to)), requestIdAccessor.get(request)));
}
@Operation(summary = "风险趋势(ADMIN)")
@ -84,7 +87,7 @@ public class AdminRiskController {
HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(
riskGovernanceService.trend(bucket, parse(from), parse(to)), requestId(request)));
riskGovernanceService.trend(bucket, parse(from), parse(to)), requestIdAccessor.get(request)));
}
@Operation(summary = "风险分布(ADMIN)")
@ -101,7 +104,7 @@ public class AdminRiskController {
HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(
riskGovernanceService.distribution(parse(from), parse(to)), requestId(request)));
riskGovernanceService.distribution(parse(from), parse(to)), requestIdAccessor.get(request)));
}
@Operation(summary = "风险事件分页(ADMIN)")
@ -126,7 +129,7 @@ public class AdminRiskController {
page, size, status, severity, ruleCode, parse(from), parse(to));
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request)));
requestIdAccessor.get(request)));
}
@Operation(summary = "风险规则列表(ADMIN)")
@ -138,7 +141,7 @@ public class AdminRiskController {
@GetMapping("/rules")
public ResponseEntity<ApiResponse<List<RiskRuleResponse>>> rules(HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestIdAccessor.get(request)));
}
@Operation(summary = "更新风险规则(ADMIN)")
@ -156,7 +159,7 @@ public class AdminRiskController {
HttpServletRequest request) {
Long adminUserId = requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.updateRule(
ruleCode, req, adminUserId, riskAuditContext(request)), requestId(request)));
ruleCode, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request)));
}
@Operation(summary = "风险事件人工操作(ADMIN)")
@ -174,11 +177,11 @@ public class AdminRiskController {
HttpServletRequest request) {
Long adminUserId = requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.actionEvent(
eventId, req, adminUserId, riskAuditContext(request)), requestId(request)));
eventId, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request)));
}
private Long requireAdmin(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
User admin = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isAdmin = admin.getRoles() != null
@ -189,21 +192,9 @@ public class AdminRiskController {
return userId;
}
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");
}
/** 风险审计上下文:仅保留 requestId,禁止写入 IP/UA/请求体等。 */
private AuditContext riskAuditContext(HttpServletRequest request) {
return AuditContext.builder().requestId(request.getHeader("X-Request-Id")).build();
return AuditContext.builder().requestId(requestIdAccessor.get(request)).build();
}
private LocalDateTime parse(String s) {

26
mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java

@ -17,8 +17,6 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
/**
@ -32,6 +30,11 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class AdminReportController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final ReportService reportService;
private final UserRepository userRepository;
@ -54,7 +57,7 @@ public class AdminReportController {
Page<ReportResponse> result = reportService.adminList(status, targetType, category, page, size);
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request)));
requestIdAccessor.get(request)));
}
@Operation(summary = "举报详情(ADMIN)",
@ -70,7 +73,7 @@ public class AdminReportController {
public ResponseEntity<ApiResponse<ReportResponse>> adminDetail(@PathVariable Long id,
HttpServletRequest request) {
requireAdmin(request);
return ResponseEntity.ok(ApiResponse.ok(reportService.adminDetail(id), requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(reportService.adminDetail(id), requestIdAccessor.get(request)));
}
@Operation(summary = "举报处理决定(ADMIN)",
@ -95,11 +98,11 @@ public class AdminReportController {
? (admin.getRole() == null ? "ADMIN" : admin.getRole().name())
: "ADMIN";
ReportResponse data = reportService.decide(adminUserId, role, id, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
private Long requireAdmin(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
User admin = userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isAdmin = admin.getRoles() != null
&& admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN);
@ -109,15 +112,4 @@ public class AdminReportController {
return userId;
}
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");
}
}

30
mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java

@ -13,8 +13,6 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
/**
@ -26,6 +24,11 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class BlockController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final BlockService blockService;
@Operation(summary = "屏蔽用户",
@ -40,9 +43,9 @@ public class BlockController {
@PutMapping("/blocks/{blockedUserId}")
public ResponseEntity<ApiResponse<BlockRelationResponse>> block(
@PathVariable Long blockedUserId, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
BlockRelationResponse data = blockService.block(userId, blockedUserId, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "解除屏蔽",
@ -55,9 +58,9 @@ public class BlockController {
@DeleteMapping("/blocks/{blockedUserId}")
public ResponseEntity<ApiResponse<Void>> unblock(
@PathVariable Long blockedUserId, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
blockService.unblock(userId, blockedUserId, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(null, requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(null, requestIdAccessor.get(request)));
}
@Operation(summary = "我的屏蔽列表",
@ -71,22 +74,11 @@ public class BlockController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Page<BlockRelationResponse> result = blockService.listMine(userId, page, size);
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request)));
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 requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

30
mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java

@ -17,8 +17,6 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
/**
@ -32,6 +30,11 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class ReportController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final ReportService reportService;
private final UserRepository userRepository;
@ -51,9 +54,9 @@ public class ReportController {
@PostMapping("/reports")
public ResponseEntity<ApiResponse<ReportResponse>> create(
@Valid @RequestBody CreateReportRequest req, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
ReportResponse data = reportService.create(userId, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
/** /reports/me 必须在 /reports/{id} 之前注册(路由回归测试覆盖) */
@ -69,11 +72,11 @@ public class ReportController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Page<ReportResponse> result = reportService.listMine(userId, status, page, size);
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request)));
requestIdAccessor.get(request)));
}
@Operation(summary = "我的举报详情",
@ -88,9 +91,9 @@ public class ReportController {
@GetMapping("/reports/{id}")
public ResponseEntity<ApiResponse<ReportResponse>> getDetail(
@PathVariable Long id, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
ReportResponse data = reportService.getDetail(userId, id, isAdmin(userId));
return ResponseEntity.ok(ApiResponse.ok(data, requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
private boolean isAdmin(Long userId) {
@ -100,15 +103,4 @@ public class ReportController {
.orElse(false);
}
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");
}
}

41
mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java

@ -14,8 +14,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.format.annotation.DateTimeFormat;
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.time.LocalDateTime;
@ -34,6 +32,11 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class CompanyVerificationController {
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CompanyVerificationService verificationService;
// ── 企业:提交认证 ──────────────────────────────────────────
@ -43,10 +46,10 @@ public class CompanyVerificationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> submit(
@Valid @RequestBody VerificationSubmitRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
CompanyVerification verification = verificationService.submit(userId, req,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request)));
}
// ── 企业:查询认证状态 ──────────────────────────────────────
@ -55,9 +58,9 @@ public class CompanyVerificationController {
@GetMapping("/companies/me/verification")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMyVerification(
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
CompanyVerification verification = verificationService.getMyVerification(userId);
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request)));
}
// ── 管理员:待审核列表 ──────────────────────────────────────
@ -71,7 +74,7 @@ public class CompanyVerificationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
verificationService.assertAdminRole(userId);
Page<CompanyVerification> result = verificationService.listPending(status, from, to, page, size);
Map<String, Object> data = new HashMap<>();
@ -80,7 +83,7 @@ public class CompanyVerificationController {
data.put("page", result.getNumber());
data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
// ── 管理员:认证详情 ────────────────────────────────────────
@ -90,10 +93,10 @@ public class CompanyVerificationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getDetail(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
verificationService.assertAdminRole(userId);
CompanyVerification verification = verificationService.getDetail(id);
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request)));
}
// ── 管理员:审核决定 ────────────────────────────────────────
@ -104,11 +107,11 @@ public class CompanyVerificationController {
@PathVariable Long id,
@Valid @RequestBody VerificationDecisionRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
verificationService.assertAdminRole(userId);
CompanyVerification verification = verificationService.decide(userId, id, req,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request)));
}
// ── 管理员:审核历史 ────────────────────────────────────────
@ -118,22 +121,12 @@ public class CompanyVerificationController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> listHistory(
@RequestParam Long verificationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
verificationService.assertAdminRole(userId);
List<Map<String, Object>> data = verificationService.getHistory(verificationId);
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");
}
}

128
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java

@ -0,0 +1,128 @@
package com.mydreamjob.common.r01a3;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.deletion.AccountDeletionService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.recommendation.JobRecommendationService;
import com.mydreamjob.risk.RiskGovernanceService;
import com.mydreamjob.safety.BlockService;
import com.mydreamjob.safety.ReportService;
import com.mydreamjob.security.CurrentUserAccessor;
import com.mydreamjob.verification.CompanyVerificationService;
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-A3 独立复验:**接线变异检测**。
*
* <p>将 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 替换为 mock,证明
* 8 个 Controller <b>确实</b>调用了这两个既有 bean。若某 Controller 仍保留自有解析
* (未接线),mock 抛未登录异常时它不会返回 401,测试即失败。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A3AccessorWiringProbeTest {
@Autowired private MockMvc mockMvc;
@MockBean private CurrentUserAccessor currentUserAccessor;
@MockBean private RequestIdAccessor requestIdAccessor;
@MockBean private AccountDeletionService accountDeletionService;
@MockBean private CompanyVerificationService verificationService;
@MockBean private RiskGovernanceService riskGovernanceService;
@MockBean private ReportService reportService;
@MockBean private BlockService blockService;
@MockBean private NotificationService notificationService;
@MockBean private JobRecommendationService jobRecommendationService;
@MockBean private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final List<String> PATHS = List.of(
"/profile/me/deletion",
"/companies/me/verification",
"/admin/scoring/risk/overview",
"/admin/reports",
"/reports/me",
"/blocks/me",
"/notifications",
"/profile/me/job-recommendations");
private static final UsernamePasswordAuthenticationToken LEGAL =
new UsernamePasswordAuthenticationToken("42", "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
@Test
@DisplayName("A3 接线检测: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("A3 接线检测:响应体 requestId 来自 Accessor mock")
void requestIdComesFromAccessorBean() throws Exception {
when(currentUserAccessor.requireUserId()).thenReturn(42L);
when(requestIdAccessor.get(any())).thenReturn("a3-probe-from-accessor");
for (String path : PATHS) {
String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
.andReturn().getResponse().getContentAsString();
assertThat(body).as("path=%s 响应体 requestId 应来自 Accessor", path)
.contains("\"requestId\":\"a3-probe-from-accessor\"");
}
Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any());
}
@Test
@DisplayName("A3 接线检测:合法主体经 Accessor 后可达 Service")
void legalPrincipalReachesService() throws Exception {
when(currentUserAccessor.requireUserId()).thenReturn(42L);
when(requestIdAccessor.get(any())).thenReturn("a3-probe");
mockMvc.perform(get("/notifications").with(authentication(LEGAL))).andReturn();
verify(notificationService).list(eq(42L), anyInt(), anyInt());
}
@Test
@DisplayName("A3 接线检测:无 Token 仍 401(Security 链,不经 Controller)")
void noToken_is401() throws Exception {
for (String path : PATHS) {
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
}
}
}

220
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java

@ -0,0 +1,220 @@
package com.mydreamjob.common.r01a3;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.deletion.AccountDeletionService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.recommendation.JobRecommendationService;
import com.mydreamjob.risk.RiskGovernanceService;
import com.mydreamjob.safety.BlockService;
import com.mydreamjob.safety.ReportService;
import com.mydreamjob.verification.CompanyVerificationService;
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.mockito.ArgumentMatchers.anyLong;
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-A3 独立复验(不复用实施者的测试类与断言)。
*
* <p>超长/溢出主体不做整响应数字搜索,解析 JSON 后仅检查业务字段。</p>
*
* <p>说明:无 Token 请求由 Security 链提前返回 401,本批不要求其携带 requestId,
* 因此对该类请求**只断言 401**。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A3IndependentVerificationTest {
@Autowired private MockMvc mockMvc;
@Autowired private RequestIdAccessor requestIdAccessor;
@MockBean private AccountDeletionService accountDeletionService;
@MockBean private CompanyVerificationService verificationService;
@MockBean private RiskGovernanceService riskGovernanceService;
@MockBean private ReportService reportService;
@MockBean private BlockService blockService;
@MockBean private NotificationService notificationService;
@MockBean private JobRecommendationService jobRecommendationService;
@MockBean private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final List<String> PATHS = List.of(
"/profile/me/deletion",
"/companies/me/verification",
"/admin/scoring/risk/overview",
"/admin/reports",
"/reports/me",
"/blocks/me",
"/notifications",
"/profile/me/job-recommendations");
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"));
}
@Test
@DisplayName("独立复验:八端点 × 八非法主体 一律 401、无 500")
void illegalPrincipals_are401_never500() 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'", path, p).isEqualTo(401);
}
}
}
@Test
@DisplayName("独立复验:匿名 token 与无 Token 均 401(无 Token 只断言 401)")
void anonymousAndNoToken_are401() throws Exception {
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
for (String path : PATHS) {
assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn()
.getResponse().getStatus()).as(path).isEqualTo(401);
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
}
}
@Test
@DisplayName("独立复验:错误体安全,不回显非数字标记/类名/堆栈")
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("独立复验:Long 溢出主体无 NumberFormatException")
void overflow_hasNoNumberFormatException() throws Exception {
for (String path : PATHS) {
String body = mockMvc.perform(get(path)
.with(authentication(principal("12345678901234567890"))))
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("NumberFormatException");
}
}
@Test
@DisplayName("独立复验:合法主体不被身份层拒绝")
void legalPrincipal_notRejectedByIdentity() throws Exception {
for (String path : PATHS) {
int st = mockMvc.perform(get(path).with(authentication(principal("7"))))
.andReturn().getResponse().getStatus();
assertThat(st).as("path=%s", path).isNotEqualTo(401);
}
}
@Test
@DisplayName("独立复验:角色层拒绝仍映射 403(身份通过后由角色层裁决)")
void roleDenial_stillMapsTo403() throws Exception {
when(redisTemplate.opsForValue())
.thenReturn(org.mockito.Mockito.mock(
org.springframework.data.redis.core.ValueOperations.class));
// CompanyVerificationController 的角色裁决委托给 Service,显式让其抛 FORBIDDEN,
// 验证"身份通过之后由角色层裁决"的语义未漂移为 401。
org.mockito.Mockito.doThrow(new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.FORBIDDEN))
.when(verificationService).assertAdminRole(anyLong());
int st = mockMvc.perform(get("/admin/companies/verifications")
.with(authentication(principal("7"))))
.andReturn().getResponse().getStatus();
assertThat(st).as("角色拒绝必须仍为 403,而非 401").isEqualTo(403);
// AdminRiskController 的角色裁决在 Controller 内(userRepository → FORBIDDEN)
when(userRepository.findById(anyLong()))
.thenReturn(java.util.Optional.of(com.mydreamjob.auth.User.builder()
.id(7L).phone("1").role(com.mydreamjob.auth.User.Role.STUDENT)
.roles(java.util.Set.of(com.mydreamjob.auth.User.Role.STUDENT))
.active(true).build()));
int st2 = mockMvc.perform(get("/admin/scoring/risk/overview")
.with(authentication(principal("7"))))
.andReturn().getResponse().getStatus();
assertThat(st2).as("非 ADMIN 必须仍为 403,而非 401").isEqualTo(403);
}
@Test
@DisplayName("独立复验:request attribute 优先于原始 header")
void requestAttributeWins() {
var req = new org.springframework.mock.web.MockHttpServletRequest();
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-a3-wins");
req.addHeader("X-Request-Id", "header-a3-loses");
assertThat(requestIdAccessor.get(req)).isEqualTo("attr-a3-wins");
}
@Test
@DisplayName("独立复验:响应 body 使用规范化 requestId(与响应头同源)")
void responseBodyUsesNormalizedRequestId() throws Exception {
for (String sent : List.of("a3v-legal-1", " ", "z".repeat(90), "<b>x</b>", "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);
String body = r.getResponse().getContentAsString();
assertThat(body).as("path=%s", path)
.contains("\"requestId\":\"" + hdr + "\"");
}
}
}
@Test
@DisplayName("独立复验:CR/LF 不入规范化结果(直接经 Accessor)")
void crlfNeverEntersValue() {
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "x\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");
}
}
}

195
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java

@ -0,0 +1,195 @@
package com.mydreamjob.common.r01a3;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.deletion.AccountDeletionService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.recommendation.JobRecommendationService;
import com.mydreamjob.risk.RiskGovernanceService;
import com.mydreamjob.safety.BlockService;
import com.mydreamjob.safety.ReportService;
import com.mydreamjob.security.CurrentUserAccessor;
import com.mydreamjob.verification.CompanyVerificationService;
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-A3 第三批:请求身份与 requestId 收束的特征测试。
*
* <p>覆盖 8 个 Controller 的代表端点(以源码真实 Mapping 为准)。</p>
*
* <p>对溢出/非数字主体**不做整响应数字子串搜索**,而是解析 JSON 后只检查业务字段。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class R01A3RequestContextMigrationIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private RequestIdAccessor requestIdAccessor;
@MockBean private AccountDeletionService accountDeletionService;
@MockBean private CompanyVerificationService verificationService;
@MockBean private RiskGovernanceService riskGovernanceService;
@MockBean private ReportService reportService;
@MockBean private BlockService blockService;
@MockBean private NotificationService notificationService;
@MockBean private JobRecommendationService jobRecommendationService;
@MockBean private UserRepository userRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 8 个 Controller 的真实代表端点。 */
private static final List<String> PATHS = List.of(
"/profile/me/deletion", // AccountDeletionController
"/companies/me/verification", // CompanyVerificationController
"/admin/scoring/risk/overview", // AdminRiskController
"/admin/reports", // AdminReportController
"/reports/me", // ReportController
"/blocks/me", // BlockController
"/notifications", // NotificationController
"/profile/me/job-recommendations"); // JobRecommendationController
private static final List<String> ILLEGAL = List.of(
"anonymousUser", "", " ", "not-a-number", "abc123",
"12345678901234567890", "0", "-1");
private static Authentication auth(String name) {
return new UsernamePasswordAuthenticationToken(name, "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
}
// ── 身份矩阵 ─────────────────────────────────────────────
@Test
@DisplayName("A3:八端点 × 八非法主体 一律 401(非 400/500)")
void illegalPrincipals_areAlways401() throws Exception {
for (String path : PATHS) {
for (String p : ILLEGAL) {
int st = mockMvc.perform(get(path).with(authentication(auth(p))))
.andReturn().getResponse().getStatus();
assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401);
}
}
}
@Test
@DisplayName("A3:匿名 token 与无认证均 401")
void anonymousAndMissing_are401() throws Exception {
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
for (String path : PATHS) {
assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn()
.getResponse().getStatus()).as(path).isEqualTo(401);
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus())
.as(path).isEqualTo(401);
}
}
@Test
@DisplayName("A3:错误体为固定安全文案,不回显类名/堆栈/principal")
void errorBody_isSafeAndDoesNotEchoPrincipal() throws Exception {
for (String path : PATHS) {
for (String p : ILLEGAL) {
String body = mockMvc.perform(get(path).with(authentication(auth(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("A3:溢出主体不产生 NumberFormatException(响应中无该异常)")
void overflowPrincipal_hasNoNumberFormatException() throws Exception {
for (String path : PATHS) {
String body = mockMvc.perform(get(path)
.with(authentication(auth("12345678901234567890"))))
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("NumberFormatException");
}
}
@Test
@DisplayName("A3:合法主体继续进入原角色/归属检查(不被身份层拒绝)")
void legalPrincipal_reachesRoleLayer() throws Exception {
for (String path : PATHS) {
int st = mockMvc.perform(get(path).with(authentication(auth("7"))))
.andReturn().getResponse().getStatus();
assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401);
}
}
// ── requestId 矩阵 ───────────────────────────────────────
@Test
@DisplayName("A3:requestId 合法/空白/超长/非法字符 均同源")
void requestId_consistentAcrossInputs() throws Exception {
for (String sent : List.of("a3-legal-0001", " ", "y".repeat(80), "<script>x</script>", "a b")) {
for (String path : PATHS) {
var r = mockMvc.perform(get(path).header("X-Request-Id", sent)
.with(authentication(auth("7")))).andReturn();
String hdr = r.getResponse().getHeader("X-Request-Id");
assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull()
.doesNotContain("\r").doesNotContain("\n")
.doesNotContain("<").doesNotContain(">").doesNotContain(" ");
assertThat(hdr.length()).isLessThanOrEqualTo(32);
String body = r.getResponse().getContentAsString();
assertThat(body).as("path=%s 响应体 requestId 应与响应头同源", path)
.contains("\"requestId\":\"" + hdr + "\"");
}
}
}
@Test
@DisplayName("A3:已存在规范化 request attribute 时以 attribute 为准")
void requestAttributeWinsOverHeader() {
var req = new org.springframework.mock.web.MockHttpServletRequest();
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-wins");
req.addHeader("X-Request-Id", "header-loses");
assertThat(requestIdAccessor.get(req)).isEqualTo("attr-wins");
}
@Test
@DisplayName("A3:CR/LF 不进入规范化结果(直接调用 Accessor)")
void crlfNeverEntersNormalizedValue() {
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "a\r\nX-Injected: 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