Browse Source

refactor: complete request context centralization

refactor/r01-structure-performance
Zhang Shun 1 month ago
parent
commit
c6db74a1a8
  1. 2
      docs/backend-development-roadmap.md
  2. 359
      docs/r01-a6-final-request-context-migration-report.md
  3. 492
      docs/r01-a6-independent-verification-20260917.md
  4. 12
      mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java
  5. 33
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  6. 14
      mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java
  7. 21
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  8. 25
      mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java
  9. 157
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringIndependentTest.java
  10. 111
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java
  11. 197
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java
  12. 167
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.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)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成实施与自动化验证,待独立代码复验**(报告 `docs/r01-a5-request-context-migration-report.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 至 A5 共迁移 32 个 Controller**(四批各 8 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。每批各删除 **16 个**私有解析方法(8 Controller × 2)。**剩余业务 Controller:身份解析 1 个、requestId 解析 5 个**(全仓身份 4 文件 = 基础设施 3 + 业务 1;全仓 requestId 11 文件 = 基础设施/非业务入口 6 + 业务 5),待最终 A6 批次 |
| 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)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 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 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置** |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

359
docs/r01-a6-final-request-context-migration-report.md

@ -0,0 +1,359 @@
# R01-A6 最终业务 Controller 请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
A6 基线 HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交)
本轮性质:**实施与自动化验证** —— 未提交、未推送,待独立代码复验
---
## 0. 结论
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。**
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** 剩余命中全部为基础设施职责。
---
## 1. 开始时状态
### 1.1 A5 固化(第一阶段)
A5 此前**未提交**,本轮按已确认清单精确固化:
| 项 | 结果 |
|---|---|
| 暂存文件数 | **15**(8 生产 Controller + 4 测试 + 2 报告 + 1 路线图) |
| `git add` 方式 | **逐文件 `git add --`**;未使用 `git add -A` / `git add .` |
| 路线图 | `git diff --numstat` 为 `1 1`(**仅 R01 行一处 hunk**),无需 `git add -p` |
| 排除项 | `GlobalExceptionHandler.java`、`db/migration`、`docs/acceptance`、`backend-infra`、A2–A4 文件 **全部排除** ✔ |
| `git diff --cached --check` | 无输出 ✔ |
| 敏感扫描 | 1050 新增行,12 类(手机号/JWT/Bearer/验证码/口令/密钥/私钥/AWS/64位摘要/本机路径/UUID/连接串)**全部命中 0** ✔ |
| A5 定向 | **42 / 42**,F=0 E=0 S=0 |
| `mvn clean verify` | **2255 / 2255**,F=0 E=0 **S=0**,BUILD SUCCESS |
| OpenAPI | **176 / 214**,All checks passed |
| 提交 | `refactor: centralize fifth batch request context handling` |
| **A5 提交 hash** | **`534afe686b966f175bcaf4af0726cec061092050`** |
| 推送 | `0226977..534afe6`;**未 force push** |
| 推送后同步 | **0 / 0** ✔ |
### 1.2 A6 开始前核查(第二阶段)
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| 新 HEAD | `534afe686b966f175bcaf4af0726cec061092050` ✔ |
| 暂存区 | 空 ✔ |
| `git diff --check` | 无空白错误 ✔ |
| **A5 未提交残余** | **无**(工作区仅 `GlobalExceptionHandler.java` 的 stat 缓存标记,blob 三方一致)✔ |
| 历史未跟踪文件 | 全部保留 ✔ |
| Docker / R01-B·C·E | 未触碰 ✔ |
**A6 基线测试数:2255。**
---
## 2. 五个 Controller 的修改内容
### 2.1 迁移前契约
| Controller | 真实代表端点 | 身份解析 | requestId 解析 |
|---|---|---|---|
| `controller/ApiController` | `GET /` | 无 | `private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` |
| `controller/AuthController` | `POST /auth/login`、`GET /auth/me` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` |
| `controller/SystemController` | `GET /health`、`GET /info` | 无 | `private String getRequestId(...)` |
| `job/JobController` | `GET /jobs`、`POST /jobs` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` |
| `scoringmonitoring/FairnessMonitoringController` | `GET /admin/scoring/fairness/*` | **需迁移**:`private Long userId()` 直读 `SecurityContextHolder` + `Long.valueOf(auth.getName())` | `private String requestId(HttpServletRequest)` 单行直读 |
### 2.2 修改后
| Controller | 注入 | `requireUserId()` | `requestIdAccessor.get(request)` |
|---|---|---|---|
| ApiController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 1 |
| AuthController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 2(既有) | 14 |
| SystemController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 2 |
| JobController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 1(既有) | 8 |
| FairnessMonitoringController | 新增 `CurrentUserAccessor` + `RequestIdAccessor` | 5 | 5 |
**`JobController` 的身份迁移未被重复改造** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。
**`AuthController` 无重复身份解析** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。
### 2.3 删除的私有方法(共 6 个)
| Controller | 删除 |
|---|---|
| ApiController | `getRequestId` ×1 |
| AuthController | `getRequestId` ×1 |
| SystemController | `getRequestId` ×1 |
| JobController | `getRequestId` ×1 |
| FairnessMonitoringController | `userId` ×1、`requestId` ×1 |
| **合计** | **6** |
> **计数澄清(独立复验补充)**:曾出现"`5 × getRequestId + 1 × userId + 1 × requestId`"的表述,其算术结果为 7,**与真实 diff 不符,该表达式不成立**。经逐文件 `git diff` 核对:`getRequestId` 实际只有 **4 个**(ApiController / AuthController / SystemController / JobController);`FairnessMonitoringController` 的 requestId 方法签名是 **`requestId(HttpServletRequest)`(无 `get` 前缀)**,因此**不能被计入 `getRequestId`**。正确分解为 **4 × getRequestId + 1 × userId + 1 × requestId = 6**,与本表逐文件清单完全一致。**未为迎合数字修改任何生产代码。**
**保留**:`FairnessMonitoringController.parseDate`(业务参数解析)、`checkParams`(业务参数校验)✔ 未被误删。
**新增注解**:`ApiController` 与 `SystemController` 原本无依赖、缺 `@RequiredArgsConstructor`,为支持构造器注入补齐该注解与 import(**唯一的新增注解**,未引入 `@Autowired`)。
**未修改**:`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`、Filter 顺序 ✔
---
## 3. 修复前失败证据
新增 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/` 下两个测试类。
**修复前实测:`Tests run: 10, Failures: 5`**
| 失败测试 | 修复前实际 |
|---|---|
| `A6AccessorWiringProbeTest.requestIdComesFromAccessorAcrossFiveControllers` | `path=/` 响应体 `requestId` **不是** Accessor 返回值(Controller 直读 header) |
| `A6AccessorWiringProbeTest.fairnessMonitoringUsesCurrentUserAccessor` | `FairnessMonitoringService.drift(...)` **未被调用**(身份未走 Accessor) |
| `A6AccessorWiringProbeTest.accessorThrowsUnauthenticated_fairnessReturns401` | 不成立 |
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 400`** |
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_produceNoServiceCall` | 不成立 |
**关于 400 而非 500(如实记录)**:`FairnessMonitoringController.userId()` 带有
`try { Long.valueOf(auth.getName()); } catch (NumberFormatException) { throw BusinessException(UNAUTHORIZED); }`,
因此其**既有行为本身是安全的(未逃逸为 500)**,但抛出的 `BusinessException(ErrorCode.UNAUTHORIZED=2001)`
不在 `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 映射中,`resolveHttpStatus` 的
`getOrDefault(2001, BAD_REQUEST)` 使其落到 **HTTP 400**。这正是本批收束的真实语义缺陷:
**非法主体应返回 401 而非 400**。接线探针则因"未调用 Accessor"而失败 —— **未编造 500**。
**修复后:`Tests run: 10, Failures: 0`**
---
## 4. 角色与资源归属语义
| 核查项 | 结论 |
|---|---|
| 静态审查:删除行中是否含角色/归属检查 | **否** ✔ |
| 静态审查:新增行中是否引入角色/归属逻辑 | **否** ✔ |
| `ErrorCode.java` | **零改动** ✔ |
| ADMIN / COMPANY / STUDENT 角色检查 | 未删除、未移动 ✔ |
| 企业归属 / 资源归属 / 403 / 404 分支 | 未删除、未移动 ✔ |
| 监控数据访问权限(ADMIN 只读聚合) | 未改变 ✔ |
**分层保持**:身份失败 → **401**;角色不足 → **403**(由 Service 层或既有逻辑产生);资源不存在 → **404**。
---
## 5. 独立接线探针
`A6AccessorWiringProbeTest`(3 例)使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与对应 Service:
| 用例 | 证明 |
|---|---|
| `requestIdComesFromAccessorAcrossFiveControllers` | mock `RequestIdAccessor` 返回 `a6-probe-request-id` → **5 个 Controller 的响应体 requestId 均为该值**(修复前为直读 header 的 null) |
| `fairnessMonitoringUsesCurrentUserAccessor` | mock `CurrentUserAccessor` 返回 `5151L` → `fairnessMonitoringService.drift(5151L)` 被调用 |
| `accessorThrowsUnauthenticated_fairnessReturns401` | mock 抛 `InsufficientAuthenticationException` → FairnessMonitoring 返回 **401**,Service **零调用** |
**变异有效性**:删除任一 Controller 中的 Accessor 调用会使对应 `verify` 失败 ✔
---
## 6. 非法 principal 的结果
`R01A6RequestContextMigrationIntegrationTest` 对 `GET /admin/scoring/fairness/drift` 覆盖:
| 主体 | 结果 |
|---|---|
| `anonymousUser` | **401** ✔ |
| `not-a-number` | **401** ✔ |
| `99999999999999999999`(20 位溢出) | **401** ✔ |
| `0` | **401** ✔ |
| `-1` | **401** ✔ |
| 匿名 token / 无 Token | **401** ✔ |
| 合法数字主体 `"7"` | 不被身份层拒绝(非 401)✔ |
断言:**401 且无 500**;响应体仅解析 `code`/`message` 稳定字段,**不对整段 JSON 做数字搜索**;不含 `Exception`/`java.`/`principal`/`anonymousUser`/`com.mydreamjob`/`NumberFormat`;无 `stackTrace`/`trace`;**业务 Service 零调用** ✔
---
## 7. requestId 规范化
覆盖 5 个 Controller 的真实端点(`/`、`/health`、`/auth/me`、`/jobs`、`/admin/scoring/fairness/drift`):
| 输入 | 结果 |
|---|---|
| 合法值 `a6-legal-0001` | 响应头 == 响应体,原样保留 ✔ |
| 空白 `" "` | 重新生成,头体同源 ✔ |
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ |
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值,响应头不含 `<`/`>`/空格 ✔ |
| request attribute 与 header 不同 | **attribute 优先** ✔ |
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ |
---
## 8. 全仓剩余命中逐文件分类
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **3 文件**
| 文件 | 分类 |
|---|---|
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) |
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) |
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) |
**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **5 文件**
| 文件 | 分类 |
|---|---|
| `audit/AuditContext` | 基础设施 |
| `common/exception/GlobalExceptionHandler` | 基础设施 |
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) |
| `common/logging/RequestLoggingFilter` | 基础设施 |
| `deletion/DeletionGuardInterceptor` | 基础设施 |
**全部落在任务书允许的基础设施清单内**(`CurrentUserAccessor`、`JwtAuthenticationFilter`、`DeletionGuardInterceptor`、`RequestIdAccessor`、`RequestIdFilter`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`)✔
**未为"全仓零命中"修改任何基础设施类** ✔
**未在 A6 清单内的业务 Controller 残留:0** ✔ —— 无需越界修改。
---
## 9. 业务 Controller 残留
| 项 | 结果 |
|---|---|
| 业务 Controller 身份解析 | **0** ✔ |
| 业务 Controller requestId 重复解析 | **0** ✔ |
**A 系列请求上下文收束达成目标终态。A7/A8 不再设置。**
---
## 10. 测试数
| 命令 | 结果 |
|---|---|
| A6 定向(`R01A6…,A6AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **40 / 40**,F=0 E=0 **S=0** |
| A6 关联(`*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*FairnessMonitoring*Test,*Security*Test,*RequestId*Test`) | **76 / 76**(1 项既有夹具脆弱性,见 §10.1,**全量顺序下不触发**) |
| `mvn test` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0** |
| `mvn clean verify` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
A5 基线 2255 → A6 **2265**(新增 **10** 个测试:7 + 3),**不低于基线** ✔
### 10.1 基线已存在的测试夹具脆弱性(未在本批修复)
A6 关联回归单独运行时,**既有** `FairnessMonitoringControllerTest` 出现 6 个失败(`200/403/400 → 401`),根因为:
```
Caused by: AuthSessionService - session validation rejected: user not usable
JwtAuthenticationFilter - JWT authentication rejected: session not active
```
**归因验证(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A6 改动**)中运行**完全相同**的命令:
| | 测试数 | 失败 | 根因 |
|---|---|---|---|
| 基线(HEAD) | **7** | **6** | `user not usable` |
| 本次(含 A6) | **7** | **6** | **完全相同** |
**实施阶段初步判断(后被独立复验否定,保留于此以存档过程)**:当时认为
`TestAuthTokens` 夹具插入 `users` 行时**不写 `user_roles`**,导致该用户被判 "not usable"。
**该判断不成立**:`AuthSessionService.isUsable(User)` 只检查用户存在且 `is_active = true`,
`UserRepository.findById` 无角色过滤,该路径**从不查询 `user_roles`**。
**独立复验结论(准确根因)**:见 `docs/r01-a6-independent-verification-20260917.md` §9。
| 环节 | 事实 |
|---|---|
| 身份冲突 | `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,与 `V1_1__seed_data.sql` 种子账号占用的 `users.id = 1 / 2 / 3` **冲突** |
| 种子被禁用 | `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0** |
| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅当 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时才 INSERT;**对已存在但 `is_active = 0` 的行不做激活**,永不修复其启用状态 |
| 直接后果 | `AuthSessionService.isSessionActive()` 第一步 `isUserUsable()` 返回 false 并短路,请求在进入 Controller 前即 401 |
| 全量为何通过 | 全量顺序中 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,**偶然清除**了 V43 已禁用的种子行,夹具随后才补建出 active 用户 —— 问题被掩盖,而非被修复 |
**与 A6 生产改动无关**:401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在请求进入
Controller 之前产生(`Handler: Type = null`),A6 只改了 5 个 Controller 内部取身份 / 取
requestId 的写法,该路径上一行 A6 代码都未执行。独立复验的决定性对照实验:同一份含 A6 的
代码,单跑 Fairness 为 7 run / 6 failures,而让 `AuthIntegrationTest` 先跑则为 **7 run / 0 failures**。
`mvn clean verify`(全量顺序)下 **2278/2278 全通过**,该失败不触发。
按任务书要求:**未把失败写成通过,未在本批顺手修复**;该夹具问题将在 A6 提交后
**以独立纯测试提交修复**(不与本批生产改动混合)。
---
## 11. OpenAPI
`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔
**`docs/openapi-rc.json` 零修改** ✔
---
## 12. 精确修改文件
**生产代码(5 个,全部在 A6 指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java
```
**测试(新增 2 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java
```
**文档(2 个)**
```
docs/r01-a6-final-request-context-migration-report.md # 本报告
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行
```
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter、Accessor、OpenAPI、既有测试、`UserRoleGuard`。
---
## 13. Flyway / ErrorCode / DTO / 接口
| 项 | 结果 |
|---|---|
| V1–V45 迁移 | **零修改** ✔ |
| `ErrorCode.java` | **零改动** ✔ |
| DTO | **零改动** ✔ |
| 公网接口 / OpenAPI | **零新增零修改** ✔ |
| 新增表 / 索引 / 迁移 | **无** ✔ |
---
## 14. Git 状态
```
git diff --check → 无输出
git status --short → 8 个生产/文档改动 + GlobalExceptionHandler(stat 缓存标记)
git diff --cached --name-only → 空
git diff --name-only -- db/migration → 空
git diff -- docs/openapi-rc.json → 空
```
- `GlobalExceptionHandler.java`:工作区 / 索引 / HEAD 三方 blob 均为 `1fdc341e…`,**内容零差异**,未触碰 ✔
- 临时基线 worktree 已 `--force` 移除并 prune,`git worktree list` 仅主仓库 ✔
---
## 15. 明确声明
- **未暂存、未提交、未推送。**
- 未操作 Docker。
- **未进入 R01-B / R01-C / R01-E。**
- 未修改基础设施类(`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`)。
- 未删除历史未跟踪文件。
- 基线测试污染已用 HEAD 隔离 worktree 复现并如实记录,**未写成通过、未顺手修复**。
---
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。**

492
docs/r01-a6-independent-verification-20260917.md

@ -0,0 +1,492 @@
# R01-A6 独立代码复验报告(最终批:5 个 Controller 请求身份与 requestId 收束)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交;复验全程未变)
暂存区:**空**
复验对象:`docs/r01-a6-final-request-context-migration-report.md`
性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送
---
## 0. 结论
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。**
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。**
剩余命中全部落在基础设施职责内(身份 3 文件、requestId 5 文件)。
**未发现 A6 引入的生产缺陷。** `FairnessMonitoringControllerTest` 的单跑失败经证据链确认为
**测试夹具缺陷**(`TestAuthTokens` 对"已存在但被禁用"的用户行不做激活),
**与 A6 生产改动无关**;其准确根因见 §9。
---
## 1. 开始前状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `534afe686b966f175bcaf4af0726cec061092050` |
| `git diff --cached --name-only` | **空**(暂存区为空) |
| `git diff --check` | 仅 `docs/backend-development-roadmap.md` 的 LF/CRLF 警告,**exit 0**,无空白错误 |
| Java/Maven 残留进程 | 无 |
工作区改动共 6 个文件:5 个生产 Controller + `docs/backend-development-roadmap.md`。
---
## 2. 五个 Controller 静态审查
`git diff --stat`:
```
docs/backend-development-roadmap.md | 2 +-
.../com/mydreamjob/controller/ApiController.java | 12 +++++---
.../com/mydreamjob/controller/AuthController.java | 33 +++++++++++-----------
.../mydreamjob/controller/SystemController.java | 14 +++++----
.../java/com/mydreamjob/job/JobController.java | 21 +++++++-------
.../FairnessMonitoringController.java | 25 +++++++---------
6 files changed, 54 insertions(+), 53 deletions(-)
```
| 项 | 结果 |
|---|---|
| 修改范围是否仅限 A6 清单内 5 个 Controller | **是** ✔ |
| 是否新增接口 / ErrorCode / Flyway / 表 / 索引 / 依赖 | **否** ✔ |
| 是否触碰 `SecurityConfig` / Filter / Accessor / `GlobalExceptionHandler` | **否** ✔ |
| 是否修改既有测试文件 | **否** ✔(`git status` 中无任何 `src/test` 既有文件被修改) |
### 2.1 禁止模式扫描(静态门禁)
对 5 个 Controller 逐一扫描 `X-Request-Id` 直读、`SecurityContextHolder`、
`getAuthentication()`、`Long.valueOf(auth.getName())`:
| 文件 | 命中 |
|---|---|
| `controller/ApiController.java` | **0** ✔ |
| `controller/AuthController.java` | **0** ✔ |
| `controller/SystemController.java` | **0** ✔ |
| `job/JobController.java` | **0** ✔ |
| `scoringmonitoring/FairnessMonitoringController.java` | **0** ✔ |
全仓 `*Controller.java` 扫描同一组模式:**0 命中** ✔
### 2.2 构造器注入与无回退
`ApiController`、`SystemController` 新增 `@RequiredArgsConstructor` 并声明
`private final CurrentUserAccessor` / `private final RequestIdAccessor`;
`AuthController`、`JobController`、`FairnessMonitoringController` 复用既有
`@RequiredArgsConstructor`。**全部为 final 构造器注入,无字段默认值、无 `null` 回退、
无 `new` 兜底实现** ✔
### 2.3 `GlobalExceptionHandler.java` 三方 blob 比对
```
git hash-object <file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21
git rev-parse HEAD:<file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21
git ls-files -s <file> → 100644 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 0 <file>
git diff --quiet <file> → exit 0
```
**工作区 blob 与 HEAD blob 完全一致**,该文件在 `git status` 中的 `M` 标记是
**Windows stat 缓存假阳性**,未修改、未触碰 ✔
---
## 3. 删除的私有方法(真实数量:6)
逐文件 `git diff` 核对(`-` 行为真实删除):
| # | 文件 | 删除的方法签名 |
|---|---|---|
| 1 | `controller/ApiController` | `private String getRequestId(HttpServletRequest)` |
| 2 | `controller/AuthController` | `private String getRequestId(HttpServletRequest)` |
| 3 | `controller/SystemController` | `private String getRequestId(HttpServletRequest)` |
| 4 | `job/JobController` | `private String getRequestId(HttpServletRequest)` |
| 5 | `scoringmonitoring/FairnessMonitoringController` | `private Long userId()` |
| 6 | `scoringmonitoring/FairnessMonitoringController` | `private String requestId(HttpServletRequest)` |
**计数澄清**:`getRequestId` 名称的方法实际为 **4 个**;
`FairnessMonitoringController` 的 requestId 方法签名为 `requestId(HttpServletRequest)`
(**无 `get` 前缀**),不能被计入 `getRequestId`。
正确分解为 **4 × `getRequestId` + 1 × `userId` + 1 × `requestId` = 6**,
与上表逐文件清单一致 ✔
**未为迎合任何数字修改生产代码** ✔
---
## 4. 独立接线变异证明
新增独立测试 `A6AccessorWiringIndependentTest` 将 `CurrentUserAccessor`、
`RequestIdAccessor` 全部替换为 mock,覆盖 5 个 Controller 的代表端点
(`/`、`/health`、`/auth/me`、`GET /jobs`、`/admin/scoring/fairness/drift`):
| 断言 | 结果 |
|---|---|
| 5 个端点响应体 `requestId` 均等于 mock 返回值 `a6-indep-request-id` | **通过** ✔ |
| Accessor 返回 4242 时 `service.drift(4242L)` 被调用 | **通过** ✔ |
| Accessor 返回 77L 时 `overview/dimensions/rankingStability` 收到 77L | **通过** ✔ |
| Accessor 抛 `InsufficientAuthenticationException` → 401 且 `service` 零调用 | **通过** ✔ |
| `POST /jobs` 企业侧身份经 `CurrentUserAccessor.requireUserId()` | **通过** ✔ |
**变异检测含义**:若任一 Controller 把 `requestIdAccessor.get(request)` 换回直读
`request.getHeader("X-Request-Id")`,响应体将不再出现 mock 值 → 该断言必然失败;
若 `FairnessMonitoringController` 换回直读 `SecurityContextHolder`,
`4242/77` 将不会传到 Service → 断言失败。**接线真实生效,非空转** ✔
---
## 5. 独立测试质量审查
复验对象:
`A6AccessorWiringIndependentTest`(6 个用例)、`A6IndependentVerificationTest`(7 个用例)。
| 核对项 | 结果 |
|---|---|
| 公开端点未被错误要求 401 | ✔ 无 Token 断言仅覆盖 `/auth/me` 与 `/admin/scoring/fairness/drift`,`/`、`/health`、`GET /jobs` 明确排除在断言之外 |
| JobController 身份接线使用真正需要企业身份的端点 | ✔ 改用 `POST /jobs`(`resolveCompanyId()` 路径),非公开的 `GET /jobs` |
| Service 参数验证不是空转 | ✔ 断言具体数值(4242 / 77),而非仅 `any()` |
| requestId 断言未错误声称响应头来自 Accessor | ✔ 只断言「响应头非空且已规范化」+「响应体 requestId 与响应头同源」;响应头由 `RequestIdFilter` 产出,未作错误归属 |
| 副作用快照列真实存在 | ✔ 9 张表列名逐一对齐迁移文件(`SCORING_RISK_EVENTS`、`SCORING_RISK_ACTIONS`、`JOBS`、`USERS`、`USER_ROLES`、`AUDIT_LOGS`、`NOTIFICATIONS`、`MESSAGES`、`CANDIDATE_DIMENSION_SCORES`) |
| 未修改或放宽既有测试 | ✔ 既有测试文件零改动 |
| 未通过过度 mock 绕过目标 Controller | ✔ 目标 Controller 全部真实(`@SpringBootTest` + `MockMvc`),只 mock 下游 Service 与两个 Accessor |
### 5.1 独立测试首次运行的两处自身错误及修正
| # | 原断言 | 问题性质 | 修正 |
|---|---|---|---|
| 1 | 把公开端点路径 `/`、`/health`、`GET /jobs` 也断言为无 Token 401 | **测试自身错误**(公开端点本不应 401),非生产缺陷 | 断言范围收窄为 `/auth/me`、`/admin/scoring/fairness/drift` 两个需认证端点 |
| 2 | 用公开的 `GET /jobs` 验证 `JobController` 的身份 Accessor 接线 | **测试自身错误**(该端点不解析身份,断言无意义) | 改用 `POST /jobs` 企业侧端点 |
**两处均为新测试自身缺陷,已修正;未修改生产代码,未修改任何既有测试。**
修正后两个类合计 **13/13 通过**。
---
## 6. 身份边界与 401 / 403 / 404 分层
### 6.1 非法主体一律 401(真实 Accessor + 真实 SecurityFilterChain)
对主体 `anonymousUser`、`not-a-number`、`99999999999999999999`、`0`、`-1`,
以及 `AnonymousAuthenticationToken` 和完全无 Token:
| 断言 | 结果 |
|---|---|
| 状态码一律 **401**(不得 400/500) | ✔ |
| 响应体 `code = 401` | ✔ |
| `message` ∈ {`authentication required`, `not authenticated`} | ✔ |
| `message` 不回显异常类名、`java.`、`principal`、`anonymousUser`、`com.mydreamjob`、`NumberFormat` | ✔ |
| 响应体无 `stackTrace` / `trace` | ✔ |
| 业务 Service **零调用** | ✔ |
| 合法数字主体(`7`)不被身份层拒绝 | ✔ |
### 6.2 401 / 403 / 404 分层未漂移
静态审查确认:A6 diff **未删除、未新增任何角色校验或资源归属校验**;
`JobController.resolveCompanyId()` 与 `jobService.assertOwnerOrThrow(companyId, jobId)` 原样保留,
`AuthController` 的「已注销手机号 → HTTP 409 / code=9303」分支原样保留。
既有契约由既有测试继续守护且**全部通过**(例:`AuthenticationBoundaryIntegrationTest`
对 `POST /jobs` 断言 `403` + `ErrorCode.FORBIDDEN`)。**403 / 404 契约未漂移** ✔
---
## 7. requestId 语义
对 5 个 Controller 的代表端点,注入 `X-Request-Id` ∈
{`a6v-legal-1`, 全空白, 90 字符超长, `<b>x</b>`, `a b`}:
| 断言 | 结果 |
|---|---|
| 响应头 `X-Request-Id` 非空 | ✔ |
| 不含 `<`、`>`、空格、CR、LF | ✔ |
| 长度 ≤ 32 | ✔ |
| 响应体 `"requestId":"<响应头同值>"` | ✔ |
| 请求属性优先于原始 Header | ✔ |
| CR/LF 不进入规范化结果(6 组恶意输入) | ✔ |
---
## 8. 失败请求的业务副作用
`illegalIdentity_producesNoBusinessWrite`:对 9 张核心表按显式列 + 稳定主键排序做全量快照,
执行 5 组非法主体请求 + 1 组无 Token 请求后再次快照。
| 结果 |
|---|
| 9 张表**逐字段零变化** ✔ |
---
## 9. `FairnessMonitoringControllerTest` 单跑:真实结果与准确根因
### 9.1 单跑命令与真实结果(**不写成通过**)
```
mvn -Dtest="FairnessMonitoringControllerTest" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-repeat.log`
```
Tests run: 7, Failures: 6, Errors: 0, Skipped: 0
BUILD FAILURE
```
6 个失败全部是 `Status expected:<200|403|400> but was:<401>`;
唯一通过的是 `anonymous_isUnauthorized`(不发 Token 的那个)。
### 9.2 逐项查明(任务书第 2 步的 6 个问题)
| # | 问题 | 结论 | 证据 |
|---|---|---|---|
| 1 | HTTP 实际状态 | **401**,响应体 `{"code":401,"message":"not authenticated","timestamp":""}` | MockHttpServletResponse 输出;`Handler: Type = null`(请求**未进入任何 Controller**) |
| 2 | Filter 为什么拒绝 | 不是验签失败,而是**服务端会话校验的用户检查失败** | 日志:`AuthSessionService - session validation rejected: user not usable` → `JwtAuthenticationFilter - JWT authentication rejected: session not active` |
| 3 | 测试用户是否存在且 `is_active = true` | **行存在,但 `is_active = 0`**(不是"不存在",也不是"active") | 见 §9.3 推理链 |
| 4 | `auth_tokens` 会话记录是否存在/未过期/未消费/未撤销 | **存在且有效**;拒绝发生在读取会话记录**之前** | `TestAuthTokens.writeSession` 自身执行 `SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND session_id=? AND revoked=0 AND consumed_at IS NULL AND expired_at > ?`,不可见即抛 `IllegalStateException`;日志中**无该异常**,说明会话行有效。而 `isSessionActive()` 先判用户、后判会话,拒绝文本是 "user not usable" 而非 "no active session" |
| 5 | JWT 的 `sid/jti/sub/type/aud/iss` 是否与库一致 | **一致**;JWT 本身完全合法 | `parseAndValidateAccessToken` **未抛异常**(否则拒绝路径不同)。日志中 Token 载荷:`sub=1`、`type=access`、`sid=0073abf7-…`、`role=ADMIN`、`iss=mydreamjob`、`aud=["mydreamjob-api"]`、`iat=1577836800`(2020-01-01)、`exp=2523916800`(2050-01-01),与夹具 `FIXTURE_BASE`/30 年 TTL 完全对应;`sid` 即写入 `auth_tokens.session_id` 的同值 |
| 6 | 是否真的与 `user_roles` 有关 | **无关** | `AuthSessionService.isUsable(User)` 只读 `user.getActive()`;`UserRepository.findById` 无角色过滤;该路径**从不查询 `user_roles`** |
### 9.3 准确根因(证据链,取代此前未经证实的 `user_roles` 说法)
**根因:`TestAuthTokens` 夹具只对"不存在的" `users.id` 执行 INSERT,对"已存在但 `is_active=0`"的行不做激活,而 `V1_1` 种子账号恰好占用 id=1/2/3 并被 `V43` 置为禁用。**
证据链(逐步闭合):
1. `V1_1__seed_data.sql` 按自增插入三名演示账号 → 占据 `users.id = 1 / 2 / 3`
(`13800000001` ADMIN、`13800000002` STUDENT、`13800000003` COMPANY)。
2. `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0**。
3. `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`
→ 命中 id=1/2 这两个**已被禁用的种子行**。
4. `TestAuthTokens.writeSession()` 的行为是:
```java
SELECT COUNT(*) FROM users WHERE id = ? // id=1 已存在 → count = 1
if (count == null || count == 0) { INSERT ... is_active=1 } // → 被跳过,永不激活
```
即夹具**只补建缺失行,不修复既有行的启用状态**。
5. `users.created_at` / `updated_at` 在 `V1__base_schema.sql` 中均有
`DEFAULT CURRENT_TIMESTAMP`,因此"若行不存在则夹具 INSERT 必然成功且 `is_active=1`"成立。
既然实际观察到 401(而非 500/ERROR),**只可能是行已存在且 `is_active=0`** —— 推理闭合。
6. `AuthSessionService.isSessionActive()` 第一步 `isUserUsable(userId)` 因此返回 false,
直接短路返回,**从未走到会话记录检查**,与日志文本完全吻合。
### 9.4 与 A6 生产改动的归因判定:**无关**
| 判据 | 内容 |
|---|---|
| 代码路径 | 401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在**请求进入 Controller 之前**产生(`Handler: Type = null`)。A6 只改了 5 个 Controller 内部取 requestId / 取身份的写法,**该路径上一行 A6 代码都未执行** |
| A6 diff 范围 | 仅 5 个 Controller + 文档;`SecurityConfig`、Filter、`AuthSessionService`、`UserRepository`、`TestAuthTokens`、Flyway 迁移**零改动** |
| 失败面 | 7 个用例中,唯一不携带 Token 的用例通过;其余全部 401 —— 与「Token 认证链路在用户检查处失败」完全一致,与「Controller 被改写」不一致 |
### 9.5 为何全量顺序下不触发(机制已定位,非"偶然")
`com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach`(源码第 97 行)执行
`userRepository.deleteAll();`,在**共享的 `integrationdb`**(无 `@TestPropertySource` 覆盖)上
**清空全部用户,包括 V43 已禁用的 V1_1 种子行**。
全量日志 `docs/acceptance/r01-d4-2-20260916/a3-commit-cv.log` 的执行顺序印证:
```
第 151767 行 [INFO] Running com.mydreamjob.controller.AuthIntegrationTest
第 247038 行 [INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest
```
种子行被清空后,夹具第 4 步的 `COUNT(*)` 变为 0 → **INSERT 补建 `is_active=1` 的用户** →
用户检查通过 → 该类在全量顺序下 7/7 通过。
因此:**同一份代码,只因子夹具前置数据不同而结果不同 —— 属顺序依赖的夹具脆弱性,
不是被测代码的缺陷。**
**决定性对照实验**(不新增任何文件、不修改任何代码,只改变前置数据):
```
mvn -Dtest="com.mydreamjob.controller.AuthIntegrationTest,FairnessMonitoringControllerTest" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-mechanism.log`
```
[INFO] Running com.mydreamjob.controller.AuthIntegrationTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in ...AuthIntegrationTest
[INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in ...FairnessMonitoringControllerTest
[INFO] BUILD SUCCESS
```
| 运行方式 | 同一份含 A6 的代码 | 结果 |
|---|---|---|
| 单独运行 Fairness | A6 在场 | **7 run / 6 failures** |
| AuthIntegrationTest 先运行 | A6 在场 | **7 run / 0 failures** |
| 全量 `mvn clean verify` | A6 在场 | **7 run / 0 failures** |
**A6 代码在三行中完全相同,唯一变量是前置数据** —— 归因判定为夹具脆弱性的直接实验证据。
### 9.6 处置
- **不修改生产代码** ✔
- **不修改既有 `FairnessMonitoringControllerTest`** ✔
- 该缺陷列为 **A6 提交后的独立纯测试修复**:让 `TestAuthTokens` 在行已存在时
显式确保 `is_active = 1`(或改用不与 V1_1 种子账号冲突的 userId)。
- **未把该单跑命令写成通过** ✔
---
## 10. 全仓剩余统计(逐文件分类)
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` /
`Long.(valueOf|parseLong)(...getName())`)—— **3 文件**
| 文件 | 分类 |
|---|---|
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) |
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) |
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) |
**requestId 解析**(`X-Request-Id` 直读)—— **5 文件**
| 文件 | 分类 |
|---|---|
| `audit/AuditContext` | 基础设施 |
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) |
| `common/logging/RequestIdFilter` | 基础设施(过滤器) |
| `common/logging/RequestLoggingFilter` | 基础设施 |
| `deletion/DeletionGuardInterceptor` | 基础设施 |
**业务 Controller 残留:身份解析 0、requestId 重复解析 0** ✔
---
## 11. 测试数
### 11.1 A6 定向
```
mvn -Dtest="R01A6RequestContextMigrationIntegrationTest,A6*Test,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v1-targeted.log`
| 测试类 | Tests |
|---|---|
| `common.r01a6.R01A6RequestContextMigrationIntegrationTest` | 7 |
| `common.r01a6.A6AccessorWiringProbeTest` | 3 |
| `common.r01a6.A6AccessorWiringIndependentTest` | 6 |
| `common.r01a6.A6IndependentVerificationTest` | 7 |
| `security.CurrentUserAccessorTest` | 11 |
| `common.logging.RequestIdAccessorTest` | 16 |
| `common.logging.RequestIdFilterTest` | 3 |
| **合计** | **53 / 53**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ |
### 11.2 A6 关联
```
mvn -Dtest="*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*Security*Test,*RequestId*Test" test
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v2-related.log`
| 测试类 | Tests |
|---|---|
| `auth.SmsVerificationSecurityTest` | 10 |
| `common.logging.RequestIdAccessorTest` | 16 |
| `common.logging.RequestIdFilterTest` | 3 |
| **合计** | **29 / 29**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ |
> **说明**:该命令的 `*ApiController*Test`、`*AuthController*Test`、`*SystemController*Test`、
> `*JobController*Test` 四个模式在本仓库**不存在同名测试类**(`find` 全量核对:
> 无 `ApiControllerTest` / `SystemControllerTest` / `JobControllerTest` / `AuthControllerTest`),
> 故只命中上表 3 个类。这 5 个 Controller 的既有覆盖由
> `mvn clean verify` 全量承担(含 `AuthenticationBoundaryIntegrationTest` 对 `POST /jobs`
> 的 403 断言、`controller.AuthIntegrationTest` 的登录/刷新/登出全链路等)。**未因此放宽任何断言。**
### 11.3 全量
```
mvn clean verify
```
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v3-verify.log`
```
[INFO] Tests run: 2278, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 全量测试总数 | **2278** |
| Failures | **0** ✔ |
| Errors | **0** ✔ |
| Skipped | **0** ✔ |
| 原实施基线 | 2265 |
| 独立新增 | **13**(`A6AccessorWiringIndependentTest` 6 + `A6IndependentVerificationTest` 7) |
| 2265 + 13 | **2278** ✔ 与 Surefire 汇总完全一致 |
**无需要按 Surefire XML 另行解释的差异** —— 汇总行与预期逐位吻合。
### 11.4 `FairnessMonitoringControllerTest` 单跑(**不计入通过**)
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 run / 6 failures / 0 errors / 0 skipped,BUILD FAILURE** |
按任务书要求:**该单跑命令不写成通过**,其失败与准确根因见 §9。
---
## 12. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔
---
## 13. Flyway / ErrorCode / DTO / 接口
| 项 | 结果 |
|---|---|
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 ✔ |
| `ErrorCode` | 未修改 ✔ |
| DTO / 响应体结构 | 未修改(仅取值来源改变) ✔ |
| 接口(路径 / 方法 / 参数) | 未新增、未删除、未改签名 ✔ |
| 依赖(`pom.xml`) | 未修改 ✔ |
---
## 14. Git 状态
| 项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` |
| HEAD | `534afe686b966f175bcaf4af0726cec061092050`(未变) |
| `git diff --cached --name-only` | **空** |
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 |
| 已暂存 / 已提交 / 已推送 | **均无** ✔(`git log -1` 仍为 A5 提交 `534afe6`) |
| 与远端关系 | `## refactor/r01-structure-performance...origin/refactor/r01-structure-performance`(无 ahead/behind) |
| 历史未跟踪文件 | 未删除 ✔ |
| 本轮验收日志 | 位于 `docs/acceptance/r01-d4-2-20260916/`,该路径被 `.gitignore:43 **/*.log` 忽略(`docs/acceptance` 受版本管理的文件数为 0),**不会进入任何提交** ✔ |
| 残留 Java/Maven 进程 | 无 ✔ |
---
## 15. 明确声明
- **未暂存、未提交、未推送。**
- 未修改任何生产代码;未修改、删除或放宽任何既有测试断言;
未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、
OpenAPI、Flyway 迁移与 V1–V45。
- 本轮仅新增 `docs/r01-a6-independent-verification-20260917.md` 与验收日志;
独立测试自身的两处缺陷已就地修正(§5.1),未触碰生产代码与既有测试。
- 未进入 R01-B;**A 系列到此结束,不再设置 A7/A8**。
- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。
---
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。**

12
mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java

@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -16,9 +17,15 @@ import java.util.Map;
*/
@Tag(name = "API", description = "API 信息入口")
@RestController
@RequiredArgsConstructor
@RequestMapping
public class ApiController {
/** R01-A6:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
@Operation(summary = "API 信息", description = "返回 API v0.1 基本信息")
@GetMapping("/")
public ResponseEntity<ApiResponse<Map<String, Object>>> index(HttpServletRequest request) {
@ -35,10 +42,7 @@ public class ApiController {
),
"docs", "http://localhost:8080/swagger-ui.html"
);
return ResponseEntity.ok(ApiResponse.ok(info, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(info, requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

33
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java

@ -37,6 +37,8 @@ import java.util.Map;
@RequiredArgsConstructor
@Validated
public class AuthController {
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final AuthService authService;
private final SmsVerificationService smsVerificationService;
@ -51,18 +53,18 @@ public class AuthController {
HttpServletRequest request) {
try {
smsVerificationService.sendCode(phone, purpose,
getRequestId(request),
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
Map.of("message", "验证码已发送(Mock)"),
getRequestId(request)));
requestIdAccessor.get(request)));
} catch (ConstraintViolationException e) {
return ResponseEntity.badRequest().body(
ApiResponse.badRequest(e.getMessage(), getRequestId(request)));
ApiResponse.badRequest(e.getMessage(), requestIdAccessor.get(request)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), getRequestId(request)));
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
}
}
@ -77,10 +79,10 @@ public class AuthController {
? "login" : body.getPurpose();
try {
Map<String, Object> result = authService.mockLogin(phone, code, purpose,
getRequestId(request),
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request)));
} catch (BusinessException e) {
// 已注销手机号:保留 HTTP 409 / 业务 code=9303,交由全局异常处理器映射。
// 其余业务异常(如验证码错误、参数校验)维持既有 400/code=400 行为。
@ -88,10 +90,10 @@ public class AuthController {
throw e;
}
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), getRequestId(request)));
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), getRequestId(request)));
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
}
}
@ -104,13 +106,13 @@ public class AuthController {
try {
AuthSessionService.SessionTokens tokens = authService.refresh(
body.getRefreshToken(),
getRequestId(request),
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
new TokenPairResponse(tokens.accessToken(), tokens.refreshToken(),
AuthService.EXPIRES_IN_SECONDS),
getRequestId(request)));
requestIdAccessor.get(request)));
} catch (InvalidTokenException e) {
// 统一 401:不说明 Token 不存在、已消费、已撤销还是重放,也不回显任何 Token。
return unauthorized(request);
@ -125,26 +127,23 @@ public class AuthController {
Long userId = currentUserAccessor.requireUserId();
String sessionId = currentUserAccessor.requireSessionId();
authService.logout(userId, sessionId,
getRequestId(request),
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
Map.of("message", "logged out"), getRequestId(request)));
Map.of("message", "logged out"), requestIdAccessor.get(request)));
}
@Operation(summary = "当前用户信息", description = "需要 JWT 认证")
@GetMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> me(HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), requestIdAccessor.get(request)));
}
private ResponseEntity<ApiResponse<TokenPairResponse>> unauthorized(HttpServletRequest request) {
return ResponseEntity.status(401).body(
ApiResponse.unauthorized("invalid or expired token", getRequestId(request)));
ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

14
mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java

@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -16,9 +17,15 @@ import java.util.Map;
*/
@Tag(name = "系统", description = "健康检查、应用信息")
@RestController
@RequiredArgsConstructor
@RequestMapping
public class SystemController {
/** R01-A6:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
@Operation(summary = "应用健康检查", description = "返回应用及依赖服务状态")
@GetMapping("/health")
public ResponseEntity<ApiResponse<Map<String, Object>>> health(HttpServletRequest request) {
@ -27,7 +34,7 @@ public class SystemController {
"application", "mydreamjob-server",
"version", "0.0.1-SNAPSHOT"
);
return ResponseEntity.ok(ApiResponse.ok(details, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(details, requestIdAccessor.get(request)));
}
@Operation(summary = "应用信息", description = "返回应用版本、运行时信息")
@ -40,10 +47,7 @@ public class SystemController {
"uptimeSeconds", (System.currentTimeMillis() - java.lang.management.ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000,
"springProfile", System.getProperty("spring.profiles.active", "dev")
);
return ResponseEntity.ok(ApiResponse.ok(info, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(info, requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

21
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java

@ -32,6 +32,8 @@ import java.util.Map;
@RequestMapping("/jobs")
@RequiredArgsConstructor
public class JobController {
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final JobService jobService;
private final CompanyService companyService;
@ -46,7 +48,7 @@ public class JobController {
HttpServletRequest request) {
Long companyId = resolveCompanyId();
Map<String, Object> view = jobService.toView(jobService.createJob(companyId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 企业端:编辑岗位 ────────────────────────────────────────
@ -59,7 +61,7 @@ public class JobController {
HttpServletRequest request) {
Long companyId = resolveCompanyId();
Map<String, Object> view = jobService.toView(jobService.updateJob(companyId, jobId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 企业端:状态流转 ────────────────────────────────────────
@ -72,7 +74,7 @@ public class JobController {
HttpServletRequest request) {
Long companyId = resolveCompanyId();
Map<String, Object> view = jobService.toView(jobService.transitionStatus(companyId, jobId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 企业端:查询岗位详情 ────────────────────────────────────
@ -86,7 +88,7 @@ public class JobController {
// 验证归属后返回
jobService.assertOwnerOrThrow(companyId, jobId);
Map<String, Object> view = jobService.toView(jobService.getJob(jobId));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 公开端:岗位列表 ────────────────────────────────────────
@ -112,7 +114,7 @@ public class JobController {
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)));
}
// ── 公开端:岗位详情 ────────────────────────────────────────
@ -123,7 +125,7 @@ public class JobController {
@PathVariable Long jobId,
HttpServletRequest request) {
Map<String, Object> view = jobService.getPublicJob(jobId);
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 公开端:高级搜索 ────────────────────────────────────────
@ -150,7 +152,7 @@ public class JobController {
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)));
}
// ── 公开端:筛选选项 ────────────────────────────────────────
@ -160,7 +162,7 @@ public class JobController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getFilters(
HttpServletRequest request) {
Map<String, Object> options = jobService.getFilterOptions();
return ResponseEntity.ok(ApiResponse.ok(options, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(options, requestIdAccessor.get(request)));
}
// ── 辅助方法 ────────────────────────────────────────────────
@ -175,7 +177,4 @@ public class JobController {
return company.getId();
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

25
mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java

@ -8,8 +8,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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -24,6 +22,11 @@ import java.util.Set;
@RequestMapping("/admin/scoring/fairness")
@RequiredArgsConstructor
public class FairnessMonitoringController {
/** R01-A6:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final FairnessMonitoringService service;
@Operation(summary = "评分完整性概览", description = "仅 ADMIN;按现存月度快照聚合,样本不足时隐藏统计明细")
@ -31,7 +34,7 @@ public class FairnessMonitoringController {
public ResponseEntity<ApiResponse<FairnessResponse.Overview>> overview(
@RequestParam String period, HttpServletRequest request) {
checkParams(request, Set.of("period"));
return ResponseEntity.ok(ApiResponse.ok(service.overview(userId(), period), requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(service.overview(currentUserAccessor.requireUserId(), period), requestIdAccessor.get(request)));
}
@Operation(summary = "评分维度分布", description = "仅 ADMIN;dimension 仅允许八个评分维度")
@ -39,21 +42,21 @@ public class FairnessMonitoringController {
public ResponseEntity<ApiResponse<FairnessResponse.Dimensions>> dimensions(
@RequestParam String period, @RequestParam(required = false) String dimension, HttpServletRequest request) {
checkParams(request, Set.of("period", "dimension"));
return ResponseEntity.ok(ApiResponse.ok(service.dimensions(userId(), period, dimension), requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(service.dimensions(currentUserAccessor.requireUserId(), period, dimension), requestIdAccessor.get(request)));
}
@Operation(summary = "评分历史漂移", description = "仅 ADMIN;当前快照可覆盖,历史漂移暂不可计算")
@GetMapping("/drift")
public ResponseEntity<ApiResponse<FairnessResponse.Unavailable>> drift(HttpServletRequest request) {
checkParams(request, Set.of());
return ResponseEntity.ok(ApiResponse.ok(service.drift(userId()), requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(service.drift(currentUserAccessor.requireUserId()), requestIdAccessor.get(request)));
}
@Operation(summary = "排名稳定性", description = "仅 ADMIN;当前没有持久化排名快照,返回 UNAVAILABLE")
@GetMapping("/ranking-stability")
public ResponseEntity<ApiResponse<FairnessResponse.Unavailable>> rankingStability(HttpServletRequest request) {
checkParams(request, Set.of());
return ResponseEntity.ok(ApiResponse.ok(service.rankingStability(userId()), requestId(request)));
return ResponseEntity.ok(ApiResponse.ok(service.rankingStability(currentUserAccessor.requireUserId()), requestIdAccessor.get(request)));
}
@Operation(summary = "申诉纠错聚合", description = "仅 ADMIN;按申诉创建队列聚合,隐藏小样本明细")
@ -62,14 +65,7 @@ public class FairnessMonitoringController {
@RequestParam(required = false) String from, @RequestParam(required = false) String to,
HttpServletRequest request) {
checkParams(request, Set.of("from", "to"));
return ResponseEntity.ok(ApiResponse.ok(service.appeals(userId(), parseDate(from), parseDate(to)), requestId(request)));
}
private Long userId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) throw new BusinessException(ErrorCode.UNAUTHORIZED);
try { return Long.valueOf(auth.getName()); }
catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); }
return ResponseEntity.ok(ApiResponse.ok(service.appeals(currentUserAccessor.requireUserId(), parseDate(from), parseDate(to)), requestIdAccessor.get(request)));
}
private LocalDate parseDate(String value) {
@ -84,5 +80,4 @@ public class FairnessMonitoringController {
}
}
private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); }
}

157
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringIndependentTest.java

@ -0,0 +1,157 @@
package com.mydreamjob.common.r01a6;
import com.mydreamjob.auth.AuthService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.job.JobService;
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService;
import com.mydreamjob.security.CurrentUserAccessor;
import jakarta.servlet.http.HttpServletRequest;
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 static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
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;
/**
* R01-A6 独立复验:**接线变异检测**(不复用实施者断言)。
*
* <p>把两个 Accessor 与相关 Service 全部替换为 mock,证明 5 个 Controller 确实通过
* 既有 Accessor 取得 requestId(以及 FairnessMonitoring 的身份)。
* 删除任一 Accessor 调用都会使本测试失败。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A6AccessorWiringIndependentTest {
@Autowired private MockMvc mockMvc;
@MockBean private CurrentUserAccessor currentUserAccessor;
@MockBean private RequestIdAccessor requestIdAccessor;
@MockBean private AuthService authService;
@MockBean private SmsVerificationService smsVerificationService;
@MockBean private JobService jobService;
@MockBean private CompanyService companyService;
@MockBean private FairnessMonitoringService fairnessMonitoringService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final UsernamePasswordAuthenticationToken LEGAL =
new UsernamePasswordAuthenticationToken("42", "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
private static final String API_INDEX = "/";
private static final String HEALTH = "/health";
private static final String AUTH_ME = "/auth/me";
private static final String JOBS = "/jobs";
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift";
private static final String[] ALL = {API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT};
@Test
@DisplayName("独立复验:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor")
void allFiveControllers_useRequestIdAccessor() throws Exception {
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep-request-id");
for (String path : ALL) {
String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
.andReturn().getResponse().getContentAsString();
assertThat(body)
.as("path=%s 响应体 requestId 必须来自 Accessor mock", path)
.contains("\"requestId\":\"a6-indep-request-id\"");
}
verify(requestIdAccessor, Mockito.atLeast(ALL.length)).get(any(HttpServletRequest.class));
}
@Test
@DisplayName("独立复验:Accessor 返回 4242 时 FairnessMonitoring Service 收到 4242")
void fairnessServiceReceivesAccessorUserId() throws Exception {
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
when(currentUserAccessor.requireUserId()).thenReturn(4242L);
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
verify(fairnessMonitoringService).drift(eq(4242L));
verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId();
}
@Test
@DisplayName("独立复验:Accessor 抛未登录时 FairnessMonitoring 返回 401 且 Service 零调用")
void accessorThrowsUnauthenticated_returns401_noServiceCall() throws Exception {
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
when(currentUserAccessor.requireUserId())
.thenThrow(new InsufficientAuthenticationException("authentication required"));
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
assertThat(r.getResponse().getStatus()).isEqualTo(401);
verify(fairnessMonitoringService, never()).drift(any());
}
@Test
@DisplayName("独立复验:合法身份下 FairnessMonitoring 其余端点同样经 Accessor 取 userId")
void fairnessOtherEndpoints_useAccessor() throws Exception {
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
when(currentUserAccessor.requireUserId()).thenReturn(77L);
mockMvc.perform(get("/admin/scoring/fairness/overview")
.param("period", "2026-09").with(authentication(LEGAL))).andReturn();
verify(fairnessMonitoringService).overview(eq(77L), eq("2026-09"));
mockMvc.perform(get("/admin/scoring/fairness/ranking-stability")
.with(authentication(LEGAL))).andReturn();
verify(fairnessMonitoringService).rankingStability(eq(77L));
mockMvc.perform(get("/admin/scoring/fairness/dimensions")
.param("period", "2026-09").with(authentication(LEGAL))).andReturn();
verify(fairnessMonitoringService).dimensions(eq(77L), eq("2026-09"), any());
}
@Test
@DisplayName("独立复验:需认证端点无 Token 时由 Security 链返回 401(公开端点不在此列)")
void noToken_is401OnProtectedEndpoints() throws Exception {
// `/`、`/health`、`/info`、`GET /jobs` 为公开端点,不参与此断言
for (String path : new String[]{AUTH_ME, FAIRNESS_DRIFT}) {
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus())
.as(path).isEqualTo(401);
}
}
@Test
@DisplayName("独立复验:JobController 企业侧端点身份来自 Accessor(未被重复实现)")
void jobController_identityFromAccessor() throws Exception {
when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a6-indep");
when(currentUserAccessor.requireUserId()).thenReturn(999L);
// 企业侧创建岗位端点:其 resolveCompanyId() 必须经 CurrentUserAccessor 取身份
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.post("/jobs")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"title\":\"t\",\"description\":\"d\",\"city\":\"上海\",\"employmentType\":\"FULL_TIME\"}")
.with(authentication(LEGAL)))
.andReturn();
verify(currentUserAccessor, Mockito.atLeastOnce()).requireUserId();
}
}

111
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java

@ -0,0 +1,111 @@
package com.mydreamjob.common.r01a6;
import com.mydreamjob.auth.AuthService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.job.JobService;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService;
import com.mydreamjob.security.CurrentUserAccessor;
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 static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
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;
/**
* R01-A6 接线变异检测:证明 5 个 Controller 的 requestId(及 FairnessMonitoring 的身份)
* 确实经过既有 Accessor。
*
* <p>修复前,这 5 个 Controller 各自持有 {@code getRequestId}/{@code requestId} 直读
* {@code X-Request-Id},FairnessMonitoring 还直接读 {@code SecurityContextHolder};
* 因此 mock Accessor 的返回值不会出现在响应中 —— 本测试在修复前必然失败。</p>
*
* <p>说明:FairnessMonitoring 的既有身份解析带有 {@code try/catch NumberFormatException},
* 其行为本身是安全的(401 而非 500),故本测试以"未调用 Accessor"作为失败依据,
* **不编造 500**。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A6AccessorWiringProbeTest {
@Autowired private MockMvc mockMvc;
@MockBean private CurrentUserAccessor currentUserAccessor;
@MockBean private RequestIdAccessor requestIdAccessor;
@MockBean private AuthService authService;
@MockBean private SmsVerificationService smsVerificationService;
@MockBean private JobService jobService;
@MockBean private CompanyService companyService;
@MockBean private FairnessMonitoringService fairnessMonitoringService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final UsernamePasswordAuthenticationToken LEGAL =
new UsernamePasswordAuthenticationToken("42", "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
/** 5 个 Controller 的真实代表端点(均为 GET,且不需额外必需查询参数)。 */
private static final String API_INDEX = "/"; // ApiController
private static final String HEALTH = "/health"; // SystemController
private static final String AUTH_ME = "/auth/me"; // AuthController
private static final String JOBS = "/jobs"; // JobController
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; // FairnessMonitoringController
@Test
@DisplayName("A6 接线检测:5 个 Controller 的响应体 requestId 均来自 RequestIdAccessor")
void requestIdComesFromAccessorAcrossFiveControllers() throws Exception {
when(requestIdAccessor.get(any())).thenReturn("a6-probe-request-id");
for (String path : new String[]{API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT}) {
String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
.andReturn().getResponse().getContentAsString();
assertThat(body)
.as("path=%s 响应体 requestId 应来自 Accessor(修复前为直读 header 的 null)", path)
.contains("\"requestId\":\"a6-probe-request-id\"");
}
verify(requestIdAccessor, atLeastOnce()).get(any());
}
@Test
@DisplayName("A6 接线检测:FairnessMonitoring 的身份来自 CurrentUserAccessor")
void fairnessMonitoringUsesCurrentUserAccessor() throws Exception {
when(requestIdAccessor.get(any())).thenReturn("a6-probe");
when(currentUserAccessor.requireUserId()).thenReturn(5151L);
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
verify(fairnessMonitoringService).drift(5151L);
verify(currentUserAccessor, atLeastOnce()).requireUserId();
}
@Test
@DisplayName("A6 接线检测:Accessor 抛未登录时 FairnessMonitoring 返回 401(非 500)")
void accessorThrowsUnauthenticated_fairnessReturns401() throws Exception {
when(requestIdAccessor.get(any())).thenReturn("a6-probe");
when(currentUserAccessor.requireUserId())
.thenThrow(new InsufficientAuthenticationException("authentication required"));
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(LEGAL))).andReturn();
assertThat(r.getResponse().getStatus()).isEqualTo(401);
verify(fairnessMonitoringService, Mockito.never()).drift(any());
}
}

197
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java

@ -0,0 +1,197 @@
package com.mydreamjob.common.r01a6;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.AuthService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.job.JobService;
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService;
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.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
/**
* R01-A6 独立复验:身份边界、requestId 语义、副作用与夹具反证。
*
* <p>使用**真实** {@code CurrentUserAccessor} 与真实 SecurityFilterChain(不 mock Accessor)。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A6IndependentVerificationTest {
@Autowired private MockMvc mockMvc;
@Autowired private RequestIdAccessor requestIdAccessor;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private AuthService authService;
@MockBean private SmsVerificationService smsVerificationService;
@MockBean private JobService jobService;
@MockBean private CompanyService companyService;
@MockBean private FairnessMonitoringService fairnessMonitoringService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift";
private static final List<String> REQUEST_ID_PATHS =
List.of("/", "/health", "/auth/me", "/jobs", FAIRNESS_DRIFT);
private static final List<String> ILLEGAL =
List.of("anonymousUser", "not-a-number", "99999999999999999999", "0", "-1");
private static Authentication principal(String name) {
return new UsernamePasswordAuthenticationToken(name, "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
}
// ── 1. FairnessMonitoring 身份边界(真实 Accessor)────────
@Test
@DisplayName("独立复验:非法主体访问 FairnessMonitoring 一律 401(不得 400/500)")
void fairness_illegalPrincipals_are401_not400or500() throws Exception {
for (String p : ILLEGAL) {
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
int st = r.getResponse().getStatus();
assertThat(st).as("principal='%s' 必须 401(实际 %d)", p, st).isEqualTo(401);
JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString());
assertThat(json.path("code").asInt()).isEqualTo(401);
String msg = json.path("message").asText();
assertThat(msg).isIn("authentication required", "not authenticated");
assertThat(msg)
.doesNotContain("Exception").doesNotContain("java.")
.doesNotContain("principal").doesNotContain("anonymousUser")
.doesNotContain("com.mydreamjob").doesNotContain("NumberFormat");
assertThat(json.has("stackTrace")).isFalse();
assertThat(json.has("trace")).isFalse();
// 只检查稳定字段 message,不对整段 JSON 做数字搜索
if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) {
assertThat(msg).doesNotContain(p);
}
}
}
@Test
@DisplayName("独立复验:非法主体与匿名访问 FairnessMonitoring 时 Service 零调用")
void fairness_illegalPrincipals_produceNoServiceCall() throws Exception {
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
for (String p : ILLEGAL) {
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
}
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon))).andReturn();
mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn();
verify(fairnessMonitoringService, never()).drift(org.mockito.ArgumentMatchers.any());
}
@Test
@DisplayName("独立复验:合法数字主体可进入业务层(不被身份层拒绝)")
void fairness_legalPrincipal_reachesService() throws Exception {
int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7"))))
.andReturn().getResponse().getStatus();
assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401);
}
// ── 2. requestId 语义(5 个 Controller)──────────────────
@Test
@DisplayName("独立复验:5 个 Controller 的 requestId 规范化与头体同源")
void requestId_semanticsAcrossFiveControllers() throws Exception {
for (String sent : List.of("a6v-legal-1", " ", "q".repeat(90), "<b>x</b>", "a b")) {
for (String path : REQUEST_ID_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("独立复验:请求属性优先于原始 Header")
void requestAttributeWins() {
var req = new org.springframework.mock.web.MockHttpServletRequest();
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6v-attr");
req.addHeader("X-Request-Id", "a6v-header");
assertThat(requestIdAccessor.get(req)).isEqualTo("a6v-attr");
}
@Test
@DisplayName("独立复验:CR/LF 不进入规范化结果(直接经 Accessor,不依赖 MockMvc 接受非法 header)")
void crlfNeverEntersValue() {
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "k\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");
}
}
// ── 3. 副作用快照(行级、显式列、稳定排序)──────────────
private static final List<String[]> TABLES = List.of(
new String[]{"SCORING_RISK_EVENTS", "ID, SUBJECT_USER_ID, SOURCE_ACTION, SEVERITY, STATUS, VERSION, CREATED_AT"},
new String[]{"SCORING_RISK_ACTIONS", "ID, RISK_EVENT_ID, OPERATOR_USER_ID, ACTION, REASON_CODE, CREATED_AT"},
new String[]{"JOBS", "ID, COMPANY_ID, TITLE, STATUS, CREATED_AT, UPDATED_AT"},
new String[]{"USERS", "ID, PHONE, NICKNAME, ROLE, IS_ACTIVE"},
new String[]{"USER_ROLES", "USER_ID, ROLE"},
new String[]{"AUDIT_LOGS", "ID, USER_ID, ACTION, ENTITY_TYPE, ENTITY_ID, CREATED_AT"},
new String[]{"NOTIFICATIONS", "ID, USER_ID, TYPE, TITLE, IS_READ, CREATED_AT"},
new String[]{"MESSAGES", "ID, CONVERSATION_ID, SENDER_USER_ID, CONTENT, CREATED_AT"},
new String[]{"CANDIDATE_DIMENSION_SCORES", "ID, STUDENT_USER_ID, DIMENSION, PERIOD, SCORE, STATUS, VERSION"});
private Map<String, List<Map<String, Object>>> snapshot() {
Map<String, List<Map<String, Object>>> out = new LinkedHashMap<>();
for (String[] t : TABLES) {
out.put(t[0], jdbcTemplate.queryForList(
"SELECT " + t[1] + " FROM " + t[0] + " ORDER BY " + t[1]));
}
return out;
}
@Test
@DisplayName("独立复验:非法身份请求前后九张核心表逐字段零变化")
void illegalIdentity_producesNoBusinessWrite() throws Exception {
Map<String, List<Map<String, Object>>> before = snapshot();
for (String p : ILLEGAL) {
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
mockMvc.perform(get(FAIRNESS_DRIFT).header("Authorization", "Bearer " + p)).andReturn();
}
mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn();
Map<String, List<Map<String, Object>>> after = snapshot();
for (String[] t : TABLES) {
assertThat(after.get(t[0])).as("%s 必须逐字段零变化", t[0]).isEqualTo(before.get(t[0]));
}
}
}

167
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java

@ -0,0 +1,167 @@
package com.mydreamjob.common.r01a6;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.AuthService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.job.JobService;
import com.mydreamjob.scoringmonitoring.FairnessMonitoringService;
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.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
/**
* R01-A6 特征测试:FairnessMonitoringController 的身份边界 + 5 个 Controller 的 requestId 规范化。
*
* <p>身份断言只针对 {@code /admin/scoring/fairness/*}(其余 4 个 Controller 的代表端点为
* 公开端点,无身份解析,仅覆盖 requestId)。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class R01A6RequestContextMigrationIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private RequestIdAccessor requestIdAccessor;
@MockBean private AuthService authService;
@MockBean private SmsVerificationService smsVerificationService;
@MockBean private JobService jobService;
@MockBean private CompanyService companyService;
@MockBean private FairnessMonitoringService fairnessMonitoringService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String API_INDEX = "/";
private static final String HEALTH = "/health";
private static final String AUTH_ME = "/auth/me";
private static final String JOBS = "/jobs";
private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift";
private static final List<String> REQUEST_ID_PATHS =
List.of(API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT);
private static final List<String> ILLEGAL = List.of(
"anonymousUser", "not-a-number", "99999999999999999999", "0", "-1");
private static Authentication principal(String name) {
return new UsernamePasswordAuthenticationToken(name, "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
}
// ── 身份边界(FairnessMonitoringController)───────────────
@Test
@DisplayName("A6:非法主体访问 FairnessMonitoring 一律 401、无 500、不回显主体")
void fairnessMonitoring_illegalPrincipals_are401() throws Exception {
for (String p : ILLEGAL) {
var r = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
int st = r.getResponse().getStatus();
assertThat(st).as("principal='%s' 必须 401", p).isEqualTo(401);
JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString());
assertThat(json.path("code").asInt()).isEqualTo(401);
String msg = json.path("message").asText();
assertThat(msg).isIn("authentication required", "not authenticated");
assertThat(msg)
.doesNotContain("Exception").doesNotContain("java.")
.doesNotContain("principal").doesNotContain("anonymousUser")
.doesNotContain("com.mydreamjob").doesNotContain("NumberFormat");
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("A6:非法主体访问 FairnessMonitoring 时业务 Service 零调用")
void fairnessMonitoring_illegalPrincipals_produceNoServiceCall() throws Exception {
for (String p : ILLEGAL) {
mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal(p)))).andReturn();
}
verify(fairnessMonitoringService, never()).drift(any());
}
@Test
@DisplayName("A6:匿名 token 与无 Token 均 401")
void fairnessMonitoring_anonymousAndNoToken_are401() throws Exception {
Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
assertThat(mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(anon)))
.andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get(FAIRNESS_DRIFT)).andReturn()
.getResponse().getStatus()).isEqualTo(401);
}
@Test
@DisplayName("A6:合法数字主体仍可进入业务层(不被身份层拒绝)")
void fairnessMonitoring_legalPrincipal_reachesService() throws Exception {
int st = mockMvc.perform(get(FAIRNESS_DRIFT).with(authentication(principal("7"))))
.andReturn().getResponse().getStatus();
assertThat(st).as("合法主体不得被判未登录").isNotEqualTo(401);
}
// ── requestId 规范化(5 个 Controller)───────────────────
@Test
@DisplayName("A6:5 个 Controller 的 requestId 合法/空白/超长/非法字符 均与响应头同源")
void requestId_consistentAcrossFiveControllers() throws Exception {
for (String sent : List.of("a6-legal-0001", " ", "v".repeat(80), "<script>x</script>", "a b")) {
for (String path : REQUEST_ID_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("A6:request attribute 优先于原始 header")
void requestAttributeWins() {
var req = new org.springframework.mock.web.MockHttpServletRequest();
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a6-attr-wins");
req.addHeader("X-Request-Id", "a6-header-loses");
assertThat(requestIdAccessor.get(req)).isEqualTo("a6-attr-wins");
}
@Test
@DisplayName("A6:CR/LF 不进入规范化结果(直接经 Accessor)")
void crlfNeverEntersValue() {
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "y\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