diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 8337c17..6720a5a 100644 --- a/docs/backend-development-roadmap.md +++ b/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节为准,移出一期 | diff --git a/docs/r01-a6-final-request-context-migration-report.md b/docs/r01-a6-final-request-context-migration-report.md new file mode 100644 index 0000000..f4c6df9 --- /dev/null +++ b/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,头体同源 ✔ | +| 非法字符 ``、`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。** diff --git a/docs/r01-a6-independent-verification-20260917.md b/docs/r01-a6-independent-verification-20260917.md new file mode 100644 index 0000000..3691a2a --- /dev/null +++ b/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 → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 +git rev-parse HEAD: → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 +git ls-files -s → 100644 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 0 +git diff --quiet → 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 字符超长, `x`, `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提交后以独立纯测试提交修复。** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java index bc1b704..3a75f3c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java +++ b/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>> 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index 7d24ce8..3da68e2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/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 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>> 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> 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java index 338684d..884d662 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java +++ b/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>> 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index 826d96e..940a6d8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/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 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 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 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 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 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>> getFilters( HttpServletRequest request) { Map 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java index 99d24fb..90ee146 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java +++ b/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> 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> 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> 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> 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"); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringIndependentTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringIndependentTest.java new file mode 100644 index 0000000..1e81c01 --- /dev/null +++ b/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 独立复验:**接线变异检测**(不复用实施者断言)。 + * + *

把两个 Accessor 与相关 Service 全部替换为 mock,证明 5 个 Controller 确实通过 + * 既有 Accessor 取得 requestId(以及 FairnessMonitoring 的身份)。 + * 删除任一 Accessor 调用都会使本测试失败。

+ */ +@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 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(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java new file mode 100644 index 0000000..ccd94ce --- /dev/null +++ b/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。 + * + *

修复前,这 5 个 Controller 各自持有 {@code getRequestId}/{@code requestId} 直读 + * {@code X-Request-Id},FairnessMonitoring 还直接读 {@code SecurityContextHolder}; + * 因此 mock Accessor 的返回值不会出现在响应中 —— 本测试在修复前必然失败。

+ * + *

说明:FairnessMonitoring 的既有身份解析带有 {@code try/catch NumberFormatException}, + * 其行为本身是安全的(401 而非 500),故本测试以"未调用 Accessor"作为失败依据, + * **不编造 500**。

+ */ +@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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6IndependentVerificationTest.java new file mode 100644 index 0000000..a990ce3 --- /dev/null +++ b/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 语义、副作用与夹具反证。 + * + *

使用**真实** {@code CurrentUserAccessor} 与真实 SecurityFilterChain(不 mock Accessor)。

+ */ +@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 redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String FAIRNESS_DRIFT = "/admin/scoring/fairness/drift"; + private static final List REQUEST_ID_PATHS = + List.of("/", "/health", "/auth/me", "/jobs", FAIRNESS_DRIFT); + + private static final List 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), "x", "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 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>> snapshot() { + Map>> 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>> 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>> after = snapshot(); + for (String[] t : TABLES) { + assertThat(after.get(t[0])).as("%s 必须逐字段零变化", t[0]).isEqualTo(before.get(t[0])); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..6cfbf59 --- /dev/null +++ b/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 规范化。 + * + *

身份断言只针对 {@code /admin/scoring/fairness/*}(其余 4 个 Controller 的代表端点为 + * 公开端点,无身份解析,仅覆盖 requestId)。

+ */ +@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 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 REQUEST_ID_PATHS = + List.of(API_INDEX, HEALTH, AUTH_ME, JOBS, FAIRNESS_DRIFT); + + private static final List 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), "", "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"); + } + } +}