Browse Source

refactor: centralize role authorization and HTTP status mapping

master
Zhang Shun 1 month ago
parent
commit
70b9e1075a
  1. 2
      docs/backend-development-roadmap.md
  2. 497
      docs/r01-b-authorization-exception-mapping-report.md
  3. 617
      docs/r01-b-independent-verification-20260918.md
  4. 23
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java
  5. 15
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  6. 21
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java
  7. 13
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java
  8. 12
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java
  9. 32
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java
  10. 13
      mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java
  11. 261
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  12. 100
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  13. 21
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
  14. 13
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java
  15. 18
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java
  16. 12
      mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java
  17. 10
      mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java
  18. 10
      mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java
  19. 10
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java
  20. 13
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java
  21. 29
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java
  22. 16
      mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java
  23. 13
      mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java
  24. 142
      mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java
  25. 10
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java
  26. 12
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java
  27. 8
      mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java
  28. 8
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java
  29. 8
      mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java
  30. 11
      mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java
  31. 8
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java
  32. 5
      mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java
  33. 2
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java
  34. 2
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java
  35. 2
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java
  36. 12
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  37. 156
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  38. 188
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  39. 257
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  40. 299
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java
  41. 146
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java
  42. 254
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationGuardTest.java
  43. 207
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationIntegrationTest.java
  44. 69
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/UserRoleGuardTestSupport.java
  45. 8
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java
  46. 11
      mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java
  47. 8
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java
  48. 8
      mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java
  49. 8
      mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java
  50. 8
      mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java
  51. 8
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java
  52. 11
      mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java
  53. 8
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java
  54. 8
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java
  55. 11
      mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java
  56. 8
      mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java
  57. 169
      mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | 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 | | 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)已完成独立复验、提交并推送**(提交 `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 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`) | | 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 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已实施完成,待独立代码复验**(报告 `docs/r01-b-authorization-exception-mapping-report.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,52 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表;下一阶段为 R01-C |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

497
docs/r01-b-authorization-exception-mapping-report.md

@ -0,0 +1,497 @@
# R01-B 角色授权与异常映射收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交)
本轮性质:**实施与自动化验证** —— 未暂存、未提交、未推送,待独立代码复验
范围:**B1 角色授权重复收束 + B2 异常到 HTTP 状态映射收束**(不拆分子阶段,不进入 R01-C/E)
---
## 0. 结论
> **R01-B角色授权与异常映射收束完成,待独立代码复验。**
- 角色授权重复逻辑收束到唯一实现点 `security/UserRoleGuard`,**54 个调用点**改走公共 guard;
角色 guard 方法定义 **32 → 21**,`getRoles()` 角色比较 **46 处 / 31 文件 → 23 处 / 16 文件**;
- HTTP 状态映射收束到 `common/exception/ErrorHttpStatusRegistry`,`GlobalExceptionHandler`
不再持有映射表;**112 个 ErrorCode 的映射逐项保持不变**,未映射的 54 个仍回退 400;
- 收束过程中发现一处既有潜在缺陷(`ErrorCode` 166 个常量只有 165 个唯一数字码),
**已记录、未擅自修改**(见 §9.4)。
---
## 1. 前置 Git 状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` |
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` |
| `git diff --cached --name-only` | **空** |
| `git diff --check` | 仅 LF/CRLF 警告,**exit 0** |
| 与目标文件重叠的未提交修改 | **无**。`GlobalExceptionHandler.java` 在 `git status` 中显示 `M`,但 `git hash-object` = `git rev-parse HEAD:<file>` = `1fdc341e…`,`git diff --quiet` exit 0 —— 属 Windows stat 缓存假阳性,非真实修改 |
---
## 2. 角色授权库存(重构前)
搜索范围:`mydreamjob-server/src/main/java`。
重构前共 **32 个**角色 guard 方法定义 + **7 个** `private boolean hasRole(User, Role)` 助手。
**共同语义(近乎逐字相同的复制)**:
```
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean ok = user.getRoles().stream().anyMatch(r -> r == User.Role.X); // user_roles
if (!ok) throw new BusinessException(ErrorCode.FORBIDDEN);
```
| 项 | 统一事实 |
|---|---|
| 输入来源 | 已解析出的 `userId`(由 `CurrentUserAccessor` 或调用方传入),**没有任何一处从请求参数取角色** |
| 角色来源 | **`user_roles` 关联表**(`user.getRoles()`)—— 31 个文件、46 处 `getRoles()`;`users.role` 单值列**不**参与授权判定(它只用于 JWT 声明签发) |
| 失败错误码 | 用户不存在 → `USER_NOT_FOUND`(4001);角色不符 → `FORBIDDEN`(2002) |
| HTTP 状态 | 4001 → 404;2002 → 403(经 §3 的映射表) |
| 是否含资源归属 | **不含**。归属(如 `assertOwnerOrThrow`、`findByOwnerUserId`)由各业务 Service 独立执行 |
| 是否允许 ADMIN | **不允许**(无隐式超级权限);`requireAny` 由调用方显式列出 ADMIN 才放行 |
| 可否安全委托 | 纯 void 角色门禁 → **可**;返回 `Company` 实体的 `requireCompany` → **不可整体委托**(见 §7) |
---
## 3. HTTP 映射库存(重构前)
`GlobalExceptionHandler.buildBusinessHttpStatuses()`(第 71–136 行)+ `register()`(第 138–147 行)
+ `resolveHttpStatus()`(第 67–69 行),共 **112 个 `ErrorCode` 引用**:
| HttpStatus | 数量 | 备注 |
|---|---|---|
| CONFLICT | 77 | |
| NOT_FOUND | 27 | |
| FORBIDDEN | 5 | `FORBIDDEN`、`INTERVIEW_ACCESS_DENIED`、`APPLICATION_OWNER_MISMATCH`、`MATCH_REQUIRED_FOR_CONVERSATION`、`OFFER_COMPARE_NOT_OWNER` |
| TOO_MANY_REQUESTS | 3 | |
| **合计** | **112** | |
| 项 | 结果 |
|---|---|
| 重复键 | **0**(112 个 `ErrorCode` 常量互不相同) |
| 重复项映射一致性 | 不适用 |
| 未显式映射的 ErrorCode | **54 / 166** → 一律回退 **HTTP 400** |
| 关键既有语义 | `UNAUTHORIZED(2001)` **未显式映射 → 400**(不是 401);`SERVICE_UNAVAILABLE`、`RATE_LIMITED`、`INTERNAL_ERROR` 同为 **400** |
| 已有测试锁定状态码 | **有**:`GlobalExceptionHandlerStatusContractTest` 以 `@EnumSource(ErrorCode.class)` 对**全部 166 个**错误码锁定状态 + 业务码 + message + requestId |
**快照已保存**:`docs/acceptance/r01-b/b2-mapping-baseline.json`(112 条 `名称 → 状态`),
并逐条转写进 `GlobalExceptionHandlerMappingParityTest` 作为独立基线。
---
## 4. 修复前失败测试(红灯)
| 观察 | 说明 |
|---|---|
| 角色授权 | 重构前**没有**公共 guard:32 个方法各自实现,无任何调用点经过统一入口。新增的 `RoleAuthorizationIntegrationTest` 以 `@SpyBean UserRoleGuard` 断言"端点确实调用公共 guard"——在重构前该 bean 不存在,测试无法编译/通过 |
| HTTP 映射 | 重构前映射表在 `GlobalExceptionHandler` 内部,**无法在初始化期检出重复键**(`register()` 仅在"同码不同状态"时抛错,同码同状态被静默容忍);新增的 `ErrorHttpStatusRegistryTest` 直接断言"同码重复注册必须抛 `IllegalStateException`"——重构前无从表达 |
| 未改变任何既有业务期望 | `GlobalExceptionHandlerStatusContractTest` 的 4 个状态桶与全部断言**逐字保留**,仅把其反射调用私有方法的用例改为调用 `registry.resolve(int)` |
---
## 5. 公共角色 guard 设计
新增 `security/UserRoleGuard.java`(`@Component`,仅依赖 `UserRepository`)。
```java
void requireStudent(Long userId); void requireStudent(User user);
void requireCompany(Long userId); void requireCompany(User user);
void requireAdmin(Long userId); void requireAdmin(User user);
void requireAny(Long userId, User.Role... allowedRoles);
void requireAny(User user, User.Role... allowedRoles);
User requireUser(Long userId); // 存在性 + active 校验
void requireRole(User user, User.Role... allowedRoles);
```
| 要求 | 落实 |
|---|---|
| 角色来自权威数据库 | 从 `user_roles`(`user.getRoles()`)读取,与全部被替换的实现一致 |
| 校验存在且 active | `requireUser` 不存在 → `USER_NOT_FOUND`;**存在但 inactive → `FORBIDDEN`(失败关闭)** |
| `users.role` 与 `user_roles` 核对 | 授权**只**依据 `user_roles`;`users.role` 与之不一致时按 `user_roles` 判定 → 缺少所需角色即拒绝(失败关闭)。**混用两者会改变既有语义,故未采纳** |
| 复用现有 ErrorCode | 只用 `USER_NOT_FOUND` / `FORBIDDEN`,**未新增任何 ErrorCode** |
| 不解析 SecurityContext | 只接收 `userId`;结构断言验证其声明字段**只有** `UserRepository` |
| 不处理 requestId | 无 `HttpServletRequest`、无 `RequestIdAccessor` |
| 不处理资源归属 | 类中无任何 ownership 逻辑 |
| 不记录敏感信息 | 无日志、无审计写入,无副作用 |
| 无隐式 ADMIN | `requireStudent`/`requireCompany` 对 ADMIN 同样拒绝,测试已锁定 |
| 查询次数受控 | `requireXxx(Long)` 恰好 **1** 次 `findById`;`requireXxx(User)` **0** 次;**无跨请求缓存**(用户被禁用/注销后立即失效) |
检索确认:仓库中**原本不存在** `UserRoleGuard` 或等价的公共角色组件,故新增;
未新建重复角色枚举(复用既有 `User.Role`)。
---
## 6. 每个迁移调用点(54 处)
`userRoleGuard.requireStudent` **37** 处、`requireAdmin` **13** 处、`requireCompany` **4** 处,
覆盖 **21 个生产文件**。
| # | 文件 | 原方法 | 迁移后 |
|---|---|---|---|
| 1 | `ability/AbilityAttemptService` | `requireStudent`(私有) | 删除,6 处改调 guard |
| 2 | `assessment/AssessmentAttemptService` | `requireStudent`(私有) | 删除,5 处改调 guard |
| 3 | `bidirectionalmatch/MatchRuleService` | `requireStudent`(私有) | 删除,2 处改调 guard |
| 4 | `careerprogress/CareerProgressService` | `assertStudentRole`(私有) | 删除,1 处改调 guard |
| 5 | `competition/CompetitionRankingService` | `assertStudentRole`(私有) | 删除,1 处改调 guard |
| 6 | `job/JobFavoriteService` | `assertStudentRole`(私有) | 删除,3 处改调 guard |
| 7 | `match/MatchService` | `assertStudentRole`(私有) | 删除,1 处改调 guard |
| 8 | `review/JobReviewService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+3 处改调 guard |
| 9 | `talentdiscovery/TalentDiscoveryService` | `requireStudent`(私有) | 删除,2 处改调 guard |
| 10 | `talentinvitation/TalentInvitationService` | `requireStudent`(私有) | 删除,3 处改调 guard |
| 11 | `recommendation/JobRecommendationService` | `requireStudent`+`requireAdmin`(私有) | 删除,3+2 处改调 guard |
| 12 | `candidatescore/governance/CandidateScoringGovernanceService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+4 处改调 guard |
| 13 | `candidatescore/dashboard/CompanyScoringDashboardService` | `assertAdminRole`(私有) | 删除,1 处改调 guard |
| 14 | `application/ApplicationController` | `assertCompanyRole`(私有) | 删除,3 处改调 guard |
| 15 | `safety/AdminReportController` | `requireAdmin(HttpServletRequest)` | 保留方法(返回 userId),角色部分委托 guard,3 处调用不变 |
| 16 | `risk/AdminRiskController` | `requireAdmin(HttpServletRequest)` | 同上,7 处调用不变;随之移除已不需要的 `UserRepository` 字段与 import |
| 17 | `company/CompanyService` | `assertStudentRole`+`assertCompanyRole`(公开) | 保留签名,方法体改为一行委托 |
| 18 | `profile/ProfileService` | `assertStudentRole`(公开) | 保留签名,一行委托 |
| 19 | `preference/PreferenceService` | `assertStudentRole`(公开) | 保留签名,一行委托 |
| 20 | `privacy/PrivacySettingsService` | `assertStudentRole`(公开) | 保留签名,一行委托 |
| 21 | `verification/CompanyVerificationService` | `assertAdminRole`(公开) | 保留签名,一行委托 |
**公开方法为何保留签名**:它们已被跨包调用(如 `profileService.assertStudentRole` 被
`ResumeVersionService`、`ResumeExportService`、`ProfileController` 等调用;
`companyService.assertStudentRole` 被 `CandidateScoreCardController`、`CandidateScoringController`、
`StudentReviewWeightService` 调用)。保留签名、把**实现**收敛为一行委托,可在不扩大改动面的前提下
消除重复逻辑。**每个调用点的原错误码(`USER_NOT_FOUND`/`FORBIDDEN`)、原角色集合、
原调用顺序均未改变。**
---
## 7. 保留调用点及理由(非等价,未迁移)
| 类别 | 数量 | 保留理由 |
|---|---|---|
| `private Company requireCompany(Long)` | **7** —— `MatchRuleService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService`、`CompanyScoringDashboardService` | **不是纯角色门禁**:除角色判定外还承担实体查找与归属语义(`companyRepository.findByOwnerUserId(userId).orElseThrow(COMPANY_NOT_FOUND)`),返回值被业务逻辑使用。整体委托公共 guard 会改变查询次数与错误码(`COMPANY_NOT_FOUND` vs `USER_NOT_FOUND`),**按任务书要求不迁移不等价逻辑** |
| `private boolean hasRole(User, Role)` | **7** —— `InterviewService`、`InterviewScoreService`、`MessagingService`、`OfferService`、`TalentDiscoveryService`、`TalentInvitationService`、`CompanyVerificationService` | 用于**资源归属/可见性分支**(如 `hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`、`MessagingService` 中 ADMIN 可见性分支),不是"必须具有某角色"的门禁。**角色授权与资源归属是两件事**,迁移会改变语义 |
| `AdminReportController`/`AdminRiskController.requireAdmin(HttpServletRequest)` | 2 | 第 15/16 行已委托 guard;保留该方法是因为它还负责返回 `userId`,属 Controller 内的便捷封装 |
| `CompanyService` 等 5 个公开方法 | 5 | 见 §6 末尾说明 |
**未为"grep 为零"迁移任何不等价逻辑。**
---
## 8. 角色 / 资源归属边界
| 边界 | 事实 |
|---|---|
| 身份("我是谁") | 仍由 `CurrentUserAccessor` 负责;`UserRoleGuard` **不**解析 SecurityContext(结构测试断言其唯一依赖是 `UserRepository`) |
| 角色授权("我能不能") | 收束到 `UserRoleGuard` |
| 资源归属("这条数据是不是我的") | **仍由各业务 Service 执行**,未删除、未移动。`RoleAuthorizationIntegrationTest.ownershipRemainsWithBusinessService` 用"角色正确但无企业实体的 COMPANY 用户"访问
`/company/candidates/{id}` → 得到 **404**(业务层归属/存在性判定),证明 guard 既没有替代也没有跳过归属校验 |
| 403 / 404 分层 | 保持既有:角色不符 403(2002);资源不存在 404(如 `RISK_EVENT_NOT_FOUND`) |
---
## 9. B2 实现与逐项一致性
### 9.1 实现
新增 `common/exception/ErrorHttpStatusRegistry.java`:
```java
public HttpStatus resolve(ErrorCode errorCode);
public HttpStatus resolve(int errorCode); // BusinessException 只暴露数字 code
public HttpStatus defaultStatus(); // 400
public int explicitMappingCount(); // 112
public static Map<ErrorCode, HttpStatus> explicitMappingsView();
public static Map<Integer, HttpStatus> numericMappingsView(); // 111
```
| 要求 | 落实 |
|---|---|
| 不可变映射 | 构建后 `Map.copyOf(...)`,运行期 `put`/`remove` 抛 `UnsupportedOperationException`(测试锁定) |
| 键类型 | **`ErrorCode` 常量**(见 §9.4);数字入口由该映射派生 |
| 初始化禁止重复键 | `Builder.put` 用 `putIfAbsent`,**同码重复注册即抛 `IllegalStateException`**(无论目标状态是否相同) |
| 显式映射集中一处 | 只在 `explicitMappings()` 内,全部 `ErrorCode.X` 符号,**无手写数字** |
| 未映射默认 400 | `DEFAULT_STATUS = BAD_REQUEST`,与重构前 `getOrDefault(code, BAD_REQUEST)` 一致 |
| 不引入反射扫描 | 无反射 |
| 不使用可运行时修改的 Map | 静态 final + `Map.copyOf` |
| Handler 只调用 registry | `GlobalExceptionHandler` 删除 `BUSINESS_HTTP_STATUSES`、`buildBusinessHttpStatuses()`、`register()`、`resolveHttpStatus()`;`handleBusinessException` 改为 `errorHttpStatusRegistry.resolve(errorCode)` |
| 同码映射到不同状态 | **未发现**(唯一同数字码的两个常量映射到同一状态,见 §9.4) |
`ErrorHttpStatusRegistry` 通过 `@Import` 挂在 `GlobalExceptionHandler` 上,使 `@WebMvcTest` 切片
(`CompanyControllerMvcTest`)无需改动即可解析到该组件。
### 9.2 响应契约未变
- 响应 JSON 结构、字段、业务码、message 全部不变(`@EnumSource` 全量断言)
- requestId 仍来自 `RequestIdAccessor`,测试断言 `"r01b-contract"` / `"r01b-parity"`
- 日志级别未变(`handleBusinessException` 仍为 `log.warn`)
- 未知 code 仍回退 **400**
- `ErrorCode` 数字、枚举成员**零修改**(`git diff` 为空)
- 未新增接口、DTO、依赖
### 9.3 逐项一致性证明
`GlobalExceptionHandlerMappingParityTest`:
1. **显式映射集合** = 重构前快照的 112 个 `ErrorCode`(对**全部 166 个**错误码逐一核对:
快照中未显式映射的,重构后也不得显式映射);
2. **处理器实际返回状态** ≡ **registry 解析结果** ≡ **快照基线**(`@EnumSource` 166 参数化);
3. 未映射码(含 `UNAUTHORIZED(2001)`、`SERVICE_UNAVAILABLE`)保持 **400**;
4. `FORBIDDEN` 保持业务码 **2002** + HTTP **403**;
5. NOT_FOUND 类 404、CONFLICT 类 409、限流类 429;
6. 响应体不含异常类名、堆栈、内部路径。
### 9.4 收束过程中发现的既有潜在缺陷(已记录,未修改)
**`ErrorCode` 166 个常量只有 165 个唯一数字码**:`REVIEW_ALREADY_DECIDED(9103)` 与
`DUPLICATE_FEEDBACK(9103)` 共用同一数字码。
- 重构前该重复被 `register()` **静默容忍**(`putIfAbsent` 只在"同码不同状态"时抛错);
- 本轮的严格重复检测因此**在启动期暴露了它**;
- 二者当前都映射到 **CONFLICT**,故数字入口无歧义、行为与重构前完全一致;
- 按任务书"不修改 `ErrorCode` 数字、不新增 `ErrorCode`"的要求,**本轮不修改**,
仅把映射键改为 `ErrorCode` 常量(这也正是"键必须是 ErrorCode 或稳定错误码类型"的实践理由),
并以 `ErrorHttpStatusRegistryTest.distinctConstantsWithSharedNumericCodeAreAllowed` 锁定该事实。
**处置建议(留待后续独立提交)**:为 `DUPLICATE_FEEDBACK` 分配唯一数字码。若两个常量被映射到
**不同状态**,注册表的 `indexByCode` 会在初始化期直接抛错,不会静默任选其一。
---
### 9.5 量化结果(前后对比)
| 指标 | 重构前 | 重构后 | 说明 |
|---|---|---|---|
| 角色 guard 方法定义总数 | **32** | **21** | 其中 6 个是 `UserRoleGuard` 自身;6 个是保留签名的公开方法(方法体已是一行委托);8 个是实体返回型 `requireCompany`(非等价,保留);2 个是 Controller 的 `requireAdmin(request)`(已委托) |
| 重复 student guard 实现 | **16** | **1**(公共 guard) | 其余为公开委托方法/实体返回型 |
| 重复 company guard 实现 | **2**(纯角色门禁)+ 7(实体返回型) | **1**(公共 guard);7 个实体返回型保留 |
| 重复 admin guard 实现 | **7** | **1**(公共 guard) | |
| 已迁移调用点 | 0 | **54** | 37 student + 13 admin + 4 company |
| 明确保留的非等价调用点 | — | **14** | 7 个实体返回型 `requireCompany` + 7 个归属/可见性用 `hasRole` |
| `getRoles()` 角色比较 | **46 处 / 31 文件** | **23 处 / 16 文件** | 减少 50% |
| `GlobalExceptionHandler` 行数 | **232** | **146** | 净减 **86 行**;映射表、`register()`、`resolveHttpStatus()` 全部移出 |
| `resolveHttpStatus` 条件链 | 映射构建 66 行 + 注册 10 行 + 解析 3 行 | **0**(逻辑迁入 `ErrorHttpStatusRegistry`,261 行,含 javadoc 与显式 112 条映射) | |
| `ErrorCode.X.getCode()` 用于状态比较的次数 | 0(原为 `Map` 查找) | **0** | 状态解析无数字字面量比较 |
| 重复 HTTP 映射数 | 0(且**不可检测**) | **0**(且**初始化即拒绝**) | |
| 未映射 ErrorCode 数 | **54 / 166** → 400 | **54 / 166** → 400 | 逐项不变 |
| 角色授权 DB 查询次数 | 每处 1 次 `findById` | `requireXxx(Long)` **1 次**;`requireXxx(User)` **0 次**;无跨请求缓存 | |
---
## 10. 查询次数
| 场景 | 数据库查询次数 |
|---|---|
| `requireStudent/Company/Admin/Any(Long userId)` | **恰好 1** 次 `users.findById`(`RoleAuthorizationGuardTest.queryCountIsBounded` 以 Mockito `times(1)` 锁定) |
| `requireStudent/Company/Admin/Any(User user)` | **0** 次(供已加载实体的调用点使用) |
| 一个请求内相同用户重复角色检查 | 调用方已加载实体时应使用实体重载;现有调用点每个请求路径至多 1 次 guard 调用 |
| 跨请求缓存 | **无**(不缓存 active 状态,注销/禁用后立即失效) |
| Redis 依赖 | **无** |
| 事务边界 | **未改变**(guard 只读、无 `@Transactional`) |
| 单次授权的 N+1 | **无**:guard 为单条主键查询,`user_roles` 由 JPA 关联加载 |
---
## 11. 测试结果
日志目录:`docs/acceptance/r01-b/`
| # | 命令 | 结果 | 日志 |
|---|---|---|---|
| 1 | `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `b1b2-tests.log` |
| 2 | `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*Admin*Controller*Test,*Company*Controller*Test,*Student*Controller*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **448 中 6 项失败** —— 全部为**既有夹具缺陷**(§11.3),非本轮引入 | `b1-related.log` |
| 3 | `mvn test` | **2656 / 2656**,Failures=**0**,Errors=**0**,Skipped=**0**,BUILD SUCCESS ✔ | `b-full-mvntest.log` |
| 4 | `mvn clean verify` | 见 §11.4 | `b-clean-verify.log` |
| 5 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — |
### 11.1 新增测试逐类结果
| 测试类 | Tests | 覆盖 |
|---|---|---|
| `RoleAuthorizationGuardTest` | 16 | STUDENT/COMPANY/ADMIN 通过各自 guard;跨角色拒绝;ADMIN 无隐式超级权限;inactive 与 `active=null` 失败关闭;角色缺失;多角色;`users.role` 与 `user_roles` 不一致失败关闭;错误码/HTTP 契约;不解析 requestId/SecurityContext;查询次数 |
| `RoleAuthorizationIntegrationTest` | 10 | 三个真实端点(ADMIN `/admin/scoring/risk/overview`、STUDENT `/profile/me/career-progress`、COMPANY `/company/candidates`)经真实 SecurityFilterChain 验证 401/403/404 分层;接线变异(`@SpyBean` 断言 guard 确被调用);资源归属仍由业务层判定 |
| `ErrorHttpStatusRegistryTest` | 174 | 112 条显式映射、111 个唯一数字码、四状态白名单、逐 ErrorCode 自洽、未映射回退 400、重复键初始化拒绝、不可变、键必须为 ErrorCode、9103 共用数字码 |
| `GlobalExceptionHandlerMappingParityTest` | 171 | 显式映射集合 = 重构前快照;处理器返回 ≡ registry ≡ 快照(166 参数化);FORBIDDEN 2002/403;`UNAUTHORIZED(2001)` 保持 400;服务不可用保持 400;响应体无内部细节 |
### 11.2 测试总数
| 项 | 数值 |
|---|---|
| R01-B 前基线 | **2285** |
| 本轮新增 | **371** |
| 预期合计 | 2656 |
| 实测 (`mvn test`) | **2656** ✔ 与预期逐位一致 |
### 11.3 关联回归中暴露的**既有**夹具缺陷(与本轮无关,如实记录)
`mvn -Dtest="*Admin*Controller*Test,..."` 单独运行时,**既有**的
`AdminReviewControllerTest`(4 项)与 `CompanyReviewManagementControllerTest`(2 项)
出现 `expected 200/403/409 but was 401`。根因与 A6 独立复验 §9 定位的**同一缺陷**:
```
AuthSessionService - session validation rejected: user not usable
JwtAuthenticationFilter - JWT authentication rejected: session not active
```
- `AdminReviewControllerTest` 使用 `ADMIN_ID = 1L`;`CompanyReviewManagementControllerTest` 使用 `token(1L, "COMPANY")` / `token(2L, "STUDENT")`;
- `V1_1` 种子账号占用 `users.id = 1/2/3` 且被 `V43` 置为 `inactive`,而 `TestAuthTokens` 对"已存在但 inactive"的行不插入也不激活;
- 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清掉种子行,问题被掩盖 —— 故 §11.2 的全量结果是干净的;
- 日志中该警告恰好 6 次,分别落在上述两个类,**无一处落在本轮改动的代码路径**;
- 本轮**未修改**这两个测试类,也**未**把该失败写成通过。
> 该缺陷与 `FairnessMonitoringControllerTest` 属同一类,已在 A6 后以独立提交 `84fe9f5` 修复
> **其中一个类**;其余同类类别的隔离修复应另开独立纯测试提交,不并入 R01-B。
### 11.4 `mvn clean verify`
```
[INFO] Tests run: 2656, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 全量测试总数 | **2656**(不低于收束前 2285 ✔) |
| Failures / Errors / Skipped | **0 / 0 / 0** ✔ |
| 日志 | `docs/acceptance/r01-b/b-clean-verify.log` |
---
## 12. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔
---
## 13. 精确修改文件
**新增生产代码(2)**
```
mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
```
**修改生产代码(22)**
```
ability/AbilityAttemptService.java
application/ApplicationController.java
assessment/AssessmentAttemptService.java
bidirectionalmatch/MatchRuleService.java
candidatescore/dashboard/CompanyScoringDashboardService.java
candidatescore/governance/CandidateScoringGovernanceService.java
careerprogress/CareerProgressService.java
common/exception/GlobalExceptionHandler.java ← B2
company/CompanyService.java
competition/CompetitionRankingService.java
job/JobFavoriteService.java
match/MatchService.java
preference/PreferenceService.java
privacy/PrivacySettingsService.java
profile/ProfileService.java
recommendation/JobRecommendationService.java
review/JobReviewService.java
risk/AdminRiskController.java
safety/AdminReportController.java
talentdiscovery/TalentDiscoveryService.java
talentinvitation/TalentInvitationService.java
verification/CompanyVerificationService.java
```
**新增测试(5)**
```
common/r01b/RoleAuthorizationGuardTest.java
common/r01b/RoleAuthorizationIntegrationTest.java
common/r01b/ErrorHttpStatusRegistryTest.java
common/r01b/GlobalExceptionHandlerMappingParityTest.java
common/r01b/UserRoleGuardTestSupport.java ← 测试支持(非测试类)
```
**修改测试(21)** —— 仅接线,未放宽任何断言:
```
careerprogress/CareerProgressServiceTest.java ← 构造器参数补 guard(真实实例)
common/exception/GlobalExceptionHandler{AccountDeletion,HttpStatus,ReportBlock,StatusContract}Test.java ← 构造器补 registry
candidatescore/governance/CandidateScoringGovernanceServiceTest.java
profile/ResumeServiceTest.java
review/JobReviewDimensionServiceTest.java
assessment/AssessmentAttemptServiceTest.java
match/MatchServiceTest.java
privacy/PrivacySettingsServiceTest.java
review/JobReviewServiceTest.java
job/JobFavoriteServiceTest.java
company/CompanyServiceTest.java
preference/PreferenceServiceTest.java
competition/CompetitionRankingServiceTest.java
ability/AbilityAttemptServiceTest.java
bidirectionalmatch/MatchRuleServiceTest.java
verification/CompanyVerificationServiceTest.java
talentdiscovery/TalentDiscoveryServiceTest.java
recommendation/JobRecommendationServiceTest.java
```
> 这 16 个 `@InjectMocks` 单元测试类新增了 `@Mock UserRoleGuard` 并在 `@BeforeEach`
> 通过 `UserRoleGuardTestSupport.delegateToRealGuard(...)` 把 mock **委托给真实 guard**,
> 复用测试自身已 stub 的 `UserRepository`。这样"角色不符 → 403"的既有断言**语义不变**,
> 没有被削弱成"只验证调用了一次"。
**文档(2)**
```
docs/r01-b-authorization-exception-mapping-report.md (新增)
docs/backend-development-roadmap.md (更新:A 系列完成 / 84fe9f5 / R01-B 状态 / 下一阶段 R01-C)
```
---
## 14. Flyway / ErrorCode / 接口 / DTO 是否变化
| 项 | 结果 |
|---|---|
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 |
| `ErrorCode.java` | **未修改**(数字、成员、message 全部不变) |
| 接口(路径/方法/参数/响应结构) | **未修改**(未新增、未删除、未改签名) |
| DTO | **未修改** |
| `docs/openapi-rc.json` | **未修改** |
| 依赖(`pom.xml`) | **未修改** |
| 新依赖 | **未引入**(guard 只用 Spring Data JPA 与既有 `ErrorCode`) |
---
## 15. Git 状态
| 项 | 结果 |
|---|---|
| 分支 / HEAD | `refactor/r01-structure-performance` / `84fe9f5…`(未变) |
| `git diff --cached --name-only` | **空** |
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 |
| 已暂存 / 已提交 / 已推送 | **均无** |
| 历史未跟踪文件 | 未删除 |
---
## 16. 未暂存、未提交、未推送
**确认:未暂存、未提交、未推送。** 本轮未使用 `git add`、未创建提交、未推送;
未执行 `reset`/`restore`/`checkout`/`clean`;未操作 Docker;未修改 V1–V45。
---
## 17. 未进入 R01-C/E
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、
新接口与数据库迁移中的任何一项。
---
> **R01-B角色授权与异常映射收束完成,待独立代码复验。**

617
docs/r01-b-independent-verification-20260918.md

@ -0,0 +1,617 @@
# R01-B 独立代码复验报告(角色授权与异常映射收束)
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交,复验全程未变)
复验对象:`docs/r01-b-authorization-exception-mapping-report.md`
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送
---
## 0. 结论
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。**
- 角色授权:54 个调用点全部真实存在且仅经 HTTP 入口可达;保留的 14 个点**确为不等价**;
**未发现角色/归属/错误码/事务语义变化**。
- HTTP 映射:从 `HEAD` 独立提取的 **166 项基线逐项一致(0 处不一致)**;112 显式、54 默认 400;
`UNAUTHORIZED` 仍 400、`FORBIDDEN` 仍 403、`SERVICE_UNAVAILABLE` 仍 400。
- 9103 重复码:**运行时确实无法区分**(`BusinessException` 只保存 `int`)。未夸大为"已解决身份歧义";
已证明两者状态相同故 HTTP 行为一致,且"同数字不同状态"会初始化失败而非静默任选。
- 测试委托模式:**真实**。变异实验(把委托改为无条件下放行)令 63 个负向用例中 **50 个失败**。
- 仅有两个**既有**测试夹具缺陷(`AdminReviewControllerTest`、`CompanyReviewManagementControllerTest`),
已用证据证明其失败发生在安全链认证阶段、**未进入 R01-B 代码**。
- **一处需如实说明的环境偏差**:本轮 `mvn clean verify` 为 `2681 / F=0 / E=0 / Skipped=26`。
26 个跳过全部是 `@Testcontainers(disabledWithoutDocker = true)` 的 MySQL 用例,
因本机 **Docker 守护进程未运行**而按设计跳过;上一轮同代码全量为 `Skipped=0`
(这些用例执行并通过)。**不声称 Skipped=0**,原因与自洽性见 §14.1。
**不构成 R01-B 阻断项。**
---
## 1. 分支、HEAD 与 Git 状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` |
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | **exit 0**,无空白错误 ✔ |
| R01-B 改动位置 | 全部仍在工作区(22 生产 + 21 测试 + 1 文档) |
| R01-B 之外的新生产改动 | **无**(逐文件比对允许清单,无越界) |
---
## 2. 角色库存独立计数
独立复验者自行从 `HEAD` 与工作区分别提取(正则按方法签名匹配,不采信实施报告数字):
| 项 | HEAD | 工作区 |
|---|---|---|
| 角色 guard 方法定义总数 | **32** | **21** |
| `private boolean hasRole(User, Role)` 助手 | 7 | 7 |
| `getRoles()` 出现次数 / 文件数 | 46 / 31 | 23 / 17 |
| 调用公共 guard 的点位 | 0 | **54** |
工作区 21 个定义的构成(逐条列出于下):
| 类别 | 数量 | 明细 |
|---|---|---|
| `UserRoleGuard` 自身 | **6** | `requireStudent/Company/Admin` × (`Long`, `User`) |
| 公开兼容委托(方法体一行) | **6** | `CompanyService.assertCompanyRole`、`CompanyService.assertStudentRole`、`PreferenceService.assertStudentRole`、`PrivacySettingsService.assertStudentRole`、`ProfileService.assertStudentRole`、`CompanyVerificationService.assertAdminRole` |
| 实体返回型 `requireCompany`(非等价,保留) | **7** | `MatchRuleService`、`CompanyScoringDashboardService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService` |
| Controller 便捷包装(返回 userId,已委托 guard) | **2** | `AdminRiskController.requireAdmin(HttpServletRequest)`、`AdminReportController.requireAdmin(HttpServletRequest)` |
6 + 6 + 7 + 2 = **21** ✔ 与工作区实测一致。
> 与实施报告的一处**非语义差异**:实施报告 §9.5 写 `getRoles()` 为「23 处 / 16 文件」,
> 独立实测为「23 处 / **17** 文件」(出现次数一致,文件计数差 1)。
> 该差异不影响任何结论,仅记录以备核对。
---
## 3. 54 个迁移调用点对账
逐调用点清单(文件 / 方法 / 旧 guard / 新 guard / 角色 / 错误码 / 归属 / 查询次数)已落盘:
`docs/acceptance/r01-b/iv-role-callsites.tsv`(54 行 + 表头)。
按文件汇总:
| 文件 | 调用点 |
|---|---|
| `ability/AbilityAttemptService` | 6 |
| `assessment/AssessmentAttemptService` | 5 |
| `candidatescore/governance/CandidateScoringGovernanceService` | 7 |
| `review/JobReviewService` | 6 |
| `recommendation/JobRecommendationService` | 5 |
| `application/ApplicationController` | 3 |
| `job/JobFavoriteService` | 3 |
| `talentinvitation/TalentInvitationService` | 3 |
| `bidirectionalmatch/MatchRuleService` | 2 |
| `company/CompanyService` | 2 |
| `talentdiscovery/TalentDiscoveryService` | 2 |
| `candidatescore/dashboard/CompanyScoringDashboardService` | 1 |
| `careerprogress/CareerProgressService` | 1 |
| `competition/CompetitionRankingService` | 1 |
| `match/MatchService` | 1 |
| `preference/PreferenceService` | 1 |
| `privacy/PrivacySettingsService` | 1 |
| `profile/ProfileService` | 1 |
| `risk/AdminRiskController` | 1 |
| `safety/AdminReportController` | 1 |
| `verification/CompanyVerificationService` | 1 |
| **合计** | **54** |
| 独立核对项 | 结果 |
|---|---|
| 迁移调用点总数 | **54** ✔ |
| student / admin / company 分布 | **37 / 13 / 4** ✔(与实施报告一致) |
| 重复统计 | **无**(同一行不会被两个正则重复计入) |
| 32 → 21 方法数 | ✔ 逐方法差异已列出:HEAD 独有 17 个定义被删除,工作区新增 `UserRoleGuard` 的 6 个 |
| 是否删除角色检查 | **否** —— 被删方法的 HEAD 调用点数合计 **46**,对应迁移后 54 个调用点(含新增的 `MatchRuleService` 2 处与委托内调用),无一处丢失 |
| 是否扩大 ADMIN 权限 | **否** —— 未引入任何 `requireAny(..., ADMIN)`;ADMIN 仍被 STUDENT/COMPANY 门禁拒绝(HTTP 实测 403) |
| 是否把 404/业务不存在改成 403 | **否** —— `USER_NOT_FOUND` 仍映射 404;`COMPANY_NOT_FOUND` 等业务码未变;实测验端点返回 404 而非 403 |
---
## 4. 14 个保留点及其不等价性核验
| 类别 | 数量 | 独立核验结论 |
|---|---|---|
| `private Company requireCompany(Long)` | 7 | **确为不等价**:除角色判定外还执行 `companyRepository.findByOwnerUserId(...)` 并抛 `COMPANY_NOT_FOUND`,返回值被业务使用。用 void guard 替换会丢失实体与错误码语义,且改变查询次数 |
| `private boolean hasRole(User, Role)` | 7 | **确为不等价**:返回**布尔值**用于**分支选择**(如 `listMyInterviews` 按 COMPANY/STUDENT 走不同查询;`listOffers` 还要先取 company 实体),并含归属判定(`hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`)。void guard 无法表达"值" |
已逐处读取源码确认(例:`InterviewService.listMyInterviews`、`OfferService.listOffers` 的
`if (hasRole(...)) {...} if (hasRole(...)) {...} else 403` 分支结构)。
**14 与 21 的关系**:21 个方法 = 6(guard 自身)+ 6(一行委托)+ 7(实体返回型)+ 2(Controller 包装);
其中真正"不等价保留"的是 **7 个实体返回型 `requireCompany` + 7 个 `hasRole` 助手 = 14**。
**一处残留观察(非阻断)**:`ApplicationController.getCompanyCandidateDetail` 先调
`requireCompany(userId)`,随后调用的 `companyService.findCompanyByOwnerUserId(userId)` 内部又调
`assertCompanyRole(userId)` —— 同一请求 2 次角色检查。经与 `HEAD` 逐行比对,**该重复在收束前就存在**
(HEAD 的 `assertCompanyRole` 同样被 Controller 与 `findCompanyByOwnerUserId` 各调一次),
**非本轮引入**,本轮未改变查询次数。可作为后续独立优化点。
---
## 5. inactive 语义分析
| 项 | 结论 |
|---|---|
| 用户不存在 | `USER_NOT_FOUND`(4001) → 404(沿用既有错误码,未新增) |
| 用户存在但 inactive | **`FORBIDDEN`(2002) → 403**(新增的失败关闭;既有实现无此校验) |
| `active` 为 `null` | 同样拒绝(`isActive` 要求 `Boolean.TRUE`) |
| 角色不足 | `FORBIDDEN`(2002) → 403(与既有完全一致) |
| 与既有实现的差异面 | **仅"inactive 用户"这一种输入**:收束前会通过,收束后拒绝 |
**HTTP 入口的 inactive 用户在 guard 之前已被拒绝**(证据链,非推测):
1. `JwtAuthenticationFilter.authenticate` 在建立认证前调用
`authSessionService.isSessionActive(...)`;
2. `AuthSessionService.isSessionActive` 第一步 `isUserUsable(userId)` →
`user.isPresent() && isUsable(user)`,而 `isUsable` 只检查 `user.getActive()`;
3. 为 false 时 `SecurityContextHolder.clearContext()` 并直接返回,**请求根本不会进入 Controller**;
4. 因此对 HTTP 入口而言,guard 的 active 检查是**第二道**,观察不到行为差异。
---
## 6. 非 HTTP 入口分析
对全部 **54 个迁移调用点**逐个追溯调用链,结论:**全部仅经 HTTP 入口可达**。
| 入口类型 | 独立核验 |
|---|---|
| HTTP Controller 链路 | 54/54 调用点的宿主方法均由 `@RestController` 直达(21 个文件逐一确认) |
| 定时任务 | 全仓 `@Scheduled` 类共 8 个:`MatchNotificationScheduler`、`CandidateScoringRefreshScheduler`、`PersonalDataExportWorker`、`AccountDeletionService`、`NotificationConsumer`、`OfferExpiryScheduler`、`RiskEventDetector`、`RiskEventScanScheduler` —— **无一个注入或调用任何被迁移的服务或 `UserRoleGuard`** |
| Worker | 同上(`PersonalDataExportWorker`、`CandidateScoringRefreshWorker`、`AccountDeletionCompletionWorker` 的依赖已逐一列出核对) |
| 消息消费者 | `NotificationConsumer` 仅依赖 notification 三件套,未触碰 |
| 内部 Service 调用 | 非 Controller 的调用者仅 4 处:`ResumeVersionService`/`ResumeExportService`/`StudentReviewWeightService`(均只被 Controller 调用)、`MatchService.diagnose`(唯一调用者是 `MatchController`)。**均为 HTTP 链路** |
| 注销/导出/补偿流程 | `AccountDeletionService`、`PersonalDataExportWorker`、补偿扫描器均未触碰迁移点 |
| 管理后台任务 | `AdminRiskController`/`AdminReportController` 的 `requireAdmin` 只被各自 HTTP 端点调用 |
**因此:不存在"合法的历史数据处理、注销收尾或后台任务需要对 inactive 用户执行受保护动作"的场景。**
独立变量验证:`MatchService.diagnose` 的唯一调用方是 `MatchController`(`grep "\.diagnose("` 全仓仅 1 处)。
---
## 7. 查询次数
| 场景 | 独立实测 |
|---|---|
| `requireXxx(Long userId)` | **恰好 1** 次 `users.findById`(Mockito `times(1)` + `verifyNoMoreInteractions` 锁定) |
| `requireXxx(User user)` | **0** 次(`verifyNoMoreInteractions` 覆盖) |
| guard 写库 | **0**(`never()).save/deleteById/delete`) |
| 单次授权 N+1 | **无**(单条主键查询;`user_roles` 由 JPA 关联加载) |
| 跨请求缓存 | **无**(guard 无状态字段,唯一依赖 `UserRepository`;用户禁用后立即失效) |
| Redis / SecurityContext / requestId | **均未触碰**(结构断言 + 行为断言双重证明) |
| 每请求角色检查次数 | 多数路径 1 次;`/company/candidates/{id}` 为 2 次 —— **与 HEAD 相同,非回归**(见 §4) |
---
## 8. 测试委托模式是否真实(含变异验证)
### 8.1 静态核验
| 项 | 结果 |
|---|---|
| 使用 `delegateToRealGuard` 的测试类 | **16 个**(另 1 个为支持类本身) |
| 每个类是否同时声明 `@Mock private UserRoleGuard userRoleGuard;` | **是**(16/16,各 1 处) |
| 委托是否使用**真实** `UserRoleGuard` 实现 | **是**:`new UserRoleGuard(userRepository)` |
| 是否使用每个测试**自己 stub 的** `UserRepository` | **是**:`delegateToRealGuard(userRoleGuard, userRepository)` 传入该测试类的 `@Mock UserRepository` |
| 是否使用宽泛 `doNothing()` | **否**:`UserRoleGuardTestSupport` 内 **0 处** `doNothing()`;模块内 7 处均为 `lenient().doAnswer(...)` 显式转发 |
| 是否把所有 guard 调用默认放行 | **否**:四个方法均逐字转发到真实 guard;`lenient()` 只用于消除"未被该用例触发"的多余桩告警,不改变行为 |
| 本轮是否新增 `doNothing` | **否**:全仓 10 处 `doNothing()` 全部位于**未被 R01-B 改动**的文件(`git status` 为空),且目标是其他 mock(`favoriteRepository`、`messagingService`、被 mock 的 `companyService` 等) |
### 8.2 变异验证(决定性)
把 `UserRoleGuardTestSupport.delegateToRealGuard` 临时改为**空实现**(即 mock 的所有 void 方法无条件放行),
运行 5 个含负向断言的测试类:
```
mvn -Dtest="CompanyServiceTest,JobFavoriteServiceTest,PrivacySettingsServiceTest,MatchServiceTest,JobReviewServiceTest" test
→ Tests run: 63, Failures: 13, Errors: 37 ← 50/63 失败
```
失败样例:
```
CompanyServiceTest.findCompany_asStudent_shouldReturn403:71
Expected BusinessException to be thrown, but nothing was thrown.
CompanyServiceTest.updateCompany_asStudent_shouldReturn403:83
expected: <2002> but was: <5001>
JobFavoriteServiceTest.favorite_asCompany_shouldReturn403:177
Unexpected exception type thrown ... but was: java.lang.NullPointerException
MatchServiceTest.diagnose_asAdmin_shouldReturn403:91
Unexpected exception type thrown ... but was: java.lang.NullPointerException
```
**判据成立**:若负向测试在"无条件放行"下仍通过,说明测试未覆盖授权;实测 **50 个用例立即失败**,
证明这些测试**真的执行了角色判断**,委托模式未被削弱。
变异后已把文件**按原 SHA256 还原**(`02f25b74cf5d2192882a77930ec1c748a1049509323bf02668dc5b04822c82a1`,逐字节一致),
临时备份已删除。日志:`docs/acceptance/r01-b/iv-mutation.log`。
---
## 9. HTTP 映射 166 项对账(独立提取)
**基线来源**:`git show HEAD:…GlobalExceptionHandler.java` 的 `buildBusinessHttpStatuses()`,
独立解析后写入 `docs/acceptance/r01-b/reconcile.tsv`(166 行)与
`mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv`(测试资源)。
**未复用工作区实现的任何映射表**,不存在同源假证明。
| 项 | HEAD | 工作区 | 一致 |
|---|---|---|---|
| 枚举常量总数 | 166 | 166 | ✔ |
| 显式映射常量数 | 112 | 112 | ✔ |
| 显式映射覆盖的唯一数字码 | 111 | 111 | ✔ |
| 默认 400 项 | 54 | 54 | ✔ |
| **逐项状态不一致数** | — | — | **0** ✔ |
| HEAD 中重复注册 | 无 | 无 | ✔ |
关键项逐项核对:
| ErrorCode | 数字码 | HEAD | 工作区 |
|---|---|---|---|
| `UNAUTHORIZED` | 2001 | 400(未登记默认) | **400** ✔ |
| `FORBIDDEN` | 2002 | 403 | **403** ✔ |
| `SERVICE_UNAVAILABLE` | 9001 | 400(未登记默认) | **400** ✔ |
| `USER_NOT_FOUND` | 4001 | 404 | **404** ✔ |
| `APPLICATION_DUPLICATE` | 8001 | 409 | **409** ✔ |
| `ABILITY_RETAKE_LIMIT` | 9811 | 429 | **429** ✔ |
| `REVIEW_ALREADY_DECIDED` | 9103 | 409 | **409** ✔ |
| `DUPLICATE_FEEDBACK` | 9103 | 409 | **409** ✔ |
(上表数字码与状态均取自独立提取的 `docs/acceptance/r01-b/reconcile.tsv`。)
响应 JSON、requestId、日志行为未变:`GlobalExceptionHandler.handleBusinessException` 仍为
`log.warn`、仍用 `ApiResponse.error(errorCode, e.getMessage(), requestIdAccessor.get(request))`,
仅把状态解析改为 `errorHttpStatusRegistry.resolve(errorCode)`。
---
## 10. 112 显式 / 54 默认统计(独立复核)
| 项 | 实施报告 | 独立复核 |
|---|---|---|
| 显式映射 | 112 | **112** ✔ |
| 未映射(默认 400) | 54 | **54** ✔ |
| 唯一数字码 | 111(未声明) | **111** ✔(9103 共用) |
---
## 11. 9103 重复码的真实运行时语义
### 11.1 逐层查清
| 层 | 事实 |
|---|---|
| `BusinessException` 保存什么 | **`private final int errorCode;`** —— 只保存数字,**枚举身份在构造时即丢失**;无 `ErrorCode` 字段,无 `fromCode` 反查 |
| `GlobalExceptionHandler` 拿到什么 | `int errorCode = e.getErrorCode();` → **int** |
| `ErrorHttpStatusRegistry.resolve(...)` 以什么为键 | `resolve(ErrorCode)` 用**枚举键**表 `EXPLICIT`;**运行时路径 `resolve(int)` 用派生数字表 `BY_CODE`** |
| 是否使用 `ErrorCode.values()` / `fromCode()` / 首次匹配 | **全部没有**(registry 与 handler 中 `values()`/`valueOf`/`ordinal`/`stream()` 命中为 0;`ErrorCode` 亦无 `fromCode` 辅助方法) |
| 相同数字码不同枚举是否可能被错误归类 | **运行时不可能区分** —— 两者都是 9103,`resolve(9103)` 只有一个结果 |
| 映射是否依赖枚举声明顺序 | **不依赖**。`EXPLICIT` 按**注册顺序**构建(与枚举声明顺序无关),`BY_CODE` 由它派生并用 `putIfAbsent` 去重;顺序只影响"谁先写入",而冲突时**抛错**而非取先者 |
### 11.2 明确声明(不夸大)
> **运行时无法区分 `REVIEW_ALREADY_DECIDED` 与 `DUPLICATE_FEEDBACK`**:`BusinessException` 只携带
> `int 9103`,Handler 也只拿到该数字。因此**不能声称"以枚举为键已彻底解决身份歧义"**;
> 准确表述是:**两者当前都映射为 409 CONFLICT,故 HTTP 行为一致**;
> 枚举键表的价值在于**配置期可审计与可去重**,而不是运行期消歧。
### 11.3 独立测试对六项要求的覆盖
| # | 要求 | 覆盖用例 |
|---|---|---|
| 1 | 两个枚举常量都显式注册 | `bothSharedNumericCodeConstantsAreExplicitlyMappedTo409` |
| 2 | 两者分别解析为 409 | 同上 |
| 3 | `resolve(9103)` 行为明确 | `runtimeCannotDistinguishTheTwoSharedConstants`(= CONFLICT,并断言两个异常的业务码相同) |
| 4 | 改成不同状态时初始化/解析必须失败 | `conflictingStatusForSharedNumericCodeFailsFast`(构造 `9103 → CONFLICT` + `9103 → NOT_FOUND`,调用数字索引构建必须抛 `IllegalStateException`,消息含 `9103`;同时反证状态相同时允许) |
| 5 | 不依赖 `ErrorCode.values()` 顺序 | `mappingDoesNotDependOnEnumDeclarationOrder`(正序/逆序注册结果相同 + 逐项比对基线) |
| 6 | 未映射 code 仍为 400 | `unknownCodeFallsBackTo400AndResponseIsClean` |
---
## 12. 独立新增测试
| 测试类 | Tests | 结果 |
|---|---|---|
| `common/r01b/R01BIndependentRoleVerificationTest` | 11 | **通过** |
| `common/r01b/R01BIndependentHttpMappingParityTest` | 9 | **通过** |
| `common/r01b/R01BWiringMutationTest` | 5 | **通过** |
| **合计** | **25** | **25 / 25**,F=0 E=0 S=0 ✔ |
日志:`docs/acceptance/r01-b/iv-1-independent.log`
覆盖要点:
- **角色**:active 三角色各自通过;三类交叉全部拒绝;ADMIN 不自动通过 STUDENT/COMPANY;
用户不存在(`USER_NOT_FOUND`);inactive 与 `active=null`(`FORBIDDEN`);`roles` 为空;
多角色;`users.role` 与 `user_roles` 不一致时按 `user_roles` 失败关闭;
`requireXxx(Long)` 恰好 1 次查询、`requireXxx(User)` 零查询;无写库;
不触碰 SecurityContext(在上下文里放 ADMIN 仍按传入 userId 判定)/requestId/Redis;
三个真实 HTTP 端点验证 **401 / 403 / 404** 分层。
- **接线变异**:guard 拒绝 → 403 且**下游业务方法零调用**;guard 放行 → **STUDENT 也能通过 ADMIN 端点**
(若端点仍保留自有角色比较,STUDENT 不可能通过 → 该断言即变异检测器);
guard 收到正确 userId;归属失败仍由业务层返回 404。
- **映射**:166 项按**名称**逐项比对 HEAD 基线(不作数字去重)、112/54 计数、
9103 六项、未知码 400、响应体无堆栈与内部类名。
---
## 13. 两个 Controller 测试单跑结果(如实记录,未写成通过)
| 命令 | 结果 |
|---|---|
| `mvn -Dtest="AdminReviewControllerTest" test` | **11 run / 4 failures / 0 errors / 0 skipped**,BUILD FAILURE |
| `mvn -Dtest="CompanyReviewManagementControllerTest" test` | **3 run / 2 failures / 0 errors / 0 skipped**,BUILD FAILURE |
失败断言:`expected 200/400/409 but was 401`(6 项合计)。
**根因证据链(非按日志文本猜测)**:
| 证据 | AdminReview | CompanyReviewMgmt |
|---|---|---|
| `Handler: Type = null` 次数 | 4 | 2 |
| `session validation rejected: user not usable` | 4 | 2 |
| `JWT authentication rejected: session not active` | 4 | 2 |
| 401 响应体 | `{"code":401,"message":"not authenticated","timestamp":""}` | 同 |
| 测试使用的用户 ID | `ADMIN_ID = 1L` | `token(1L,"COMPANY")` / `token(2L,"STUDENT")` |
| 是否在 R01-B 改动清单内 | **否** | **否** |
**判定**:
1. `Handler: Type = null` 证明请求**从未进入任何 Controller**,`JobReviewService` /
`UserRoleGuard` / `GlobalExceptionHandler` **均未被调用** —— 因此**与 R01-B 新逻辑无关**;
2. 401 由 `SecurityConfig` 的认证入口产出(body 形状 `{"code":401,"message":"not authenticated"}`),
不是 `GlobalExceptionHandler` 的 `ApiResponse` 形状(后者会带业务码);
3. 拒绝发生在 `AuthSessionService.isUserUsable` —— 用户 id=1/2 正是 `V1_1` 种子账号、
被 `V43` 置为 inactive;
4. 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清除种子行,
问题被掩盖,故 §14 全量为绿。
**处置**:未修改这两个测试;未写成通过;不让它们阻断对 R01-B 生产代码的判断。
**R01-B 提交后须以独立纯测试提交修复**(与 A6 后 `84fe9f5` 修复 `FairnessMonitoringControllerTest` 同法)。
---
## 14. 全量测试
| 命令 | 结果 | 日志 |
|---|---|---|
| `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `iv-2-impl.log` |
| `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **425 / 425**,F=0 E=0 S=0 ✔(该命令不含两个已知夹具类,已单独记录于 §13) | `iv-3-related.log` |
| `mvn clean verify` | 见 §14.1 | `iv-4-clean-verify.log` |
### 14.1 `mvn clean verify`
```
[INFO] Tests run: 2681, Failures: 0, Errors: 0, Skipped: 26
[INFO] BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2681**(= 前次 2656 + 本轮新增独立测试 25)✔ 不低于 2656 |
| Failures | **0** ✔ |
| Errors | **0** ✔ |
| **Skipped** | **26 —— 未达"0"要求,原因如下(环境性,非代码回归)** |
#### Skipped=26 的准确原因(如实记录,不掩饰、不写成 0)
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 测试类:
| 测试类 | Skipped |
|---|---|
| `job.JobSearchSpecificationMySqlIntegrationTest` | 15 |
| `candidatescore.refresh.CandidateScoringRefreshMySqlIntegrationTest` | 6 |
| `job.JobSearchQuerySideEffectMySqlIntegrationTest` | 4 |
| `job.JobSearchQueryExplainMySqlIntegrationTest` | 1 |
| 合计 | **26** |
**根因**:这些类标注 `@Testcontainers(disabledWithoutDocker = true)`(见
`JobSearchSpecificationMySqlIntegrationTest:44` 等),Testcontainers 在 **Docker 守护进程不可用**时
按设计**环境性跳过**。本机实测:
```
docker info → failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine;
check if the path is correct and if the daemon is running
```
**为何与 R01-B 无关**:
1. 本轮全程**未操作 Docker**(任务书明令禁止),Docker 守护进程不在运行属环境状态;
2. **紧邻的上一次全量 `mvn clean verify`(R01-B 实施阶段)为 `2656 / F=0 / E=0 / Skipped=0`** ——
同一份生产代码下这 4 个类**执行并通过**,证明它们未被 R01-B 破坏;
3. 本轮相对该次运行,**生产代码零改动**(仅新增 3 个测试类 + 1 个测试资源 + 1 份文档);
4. 计数自洽:`2656 − 26(改为跳过)+ 25(本轮新增)= 2655 执行 + 26 跳过 = 2681`,
即**除这 26 个 Docker 条件用例外的全部测试仍执行且全部通过**。
**结论**:该偏差为**环境条件**,不构成 R01-B 的阻断项;但按任务书"跳过必须是 0"的字面要求,
本环境**无法满足**,故**不声称 Skipped=0**。若需在 Docker 可用环境下复核,
重新执行 `mvn clean verify` 即可(无需任何代码改动)。
---
## 15. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空** ✔
---
## 16. 精确文件范围
**R01-B 生产改动(22 修改 + 2 新增)**
```
修改: ability/AbilityAttemptService、application/ApplicationController、assessment/AssessmentAttemptService、
bidirectionalmatch/MatchRuleService、candidatescore/dashboard/CompanyScoringDashboardService、
candidatescore/governance/CandidateScoringGovernanceService、careerprogress/CareerProgressService、
common/exception/GlobalExceptionHandler、company/CompanyService、competition/CompetitionRankingService、
job/JobFavoriteService、match/MatchService、preference/PreferenceService、
privacy/PrivacySettingsService、profile/ProfileService、recommendation/JobRecommendationService、
review/JobReviewService、risk/AdminRiskController、safety/AdminReportController、
talentdiscovery/TalentDiscoveryService、talentinvitation/TalentInvitationService、
verification/CompanyVerificationService
新增: security/UserRoleGuard、common/exception/ErrorHttpStatusRegistry
```
**测试改动(21 修改 + 4 新增 + 1 支持类 + 1 基线资源)**、**文档(1 修改 + 1 新增)**。
本轮独立复验**仅新增**:
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java
mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv
docs/r01-b-independent-verification-20260918.md
docs/acceptance/r01-b/*(验收日志与对账产物,被 .gitignore 覆盖)
```
**未修改任何生产代码** ✔(`git diff` 生产文件数与复验开始前一致,均为 R01-B 实施者留下的改动)。
---
## 17. Git 边界
| 检查 | 结果 |
|---|---|
| `git diff --check` | **exit 0**,无空白错误 ✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --name-only -- …/db/migration` | **空** —— V1–V45 未改 ✔ |
| `git diff -- docs/openapi-rc.json` | **空** ✔ |
| `git diff -- mydreamjob-server/pom.xml` | **空** ✔ |
| `ErrorCode.java` | **未修改** ✔ |
| 接口 / DTO | **未修改** ✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
| Java / Maven 残留进程 | 无 ✔ |
| HEAD / upstream | `84fe9f5…` / **0 0** ✔ |
---
## 18. 是否存在阻断
**无阻断。**
| 复核项 | 结论 |
|---|---|
| 角色语义变化 | **无**(仅新增 inactive 失败关闭,且 HTTP 入口不可观测、非 HTTP 入口不存在) |
| 归属语义变化 | **无**(归属仍由原 Service 执行,实测 404 未被改写为 403) |
| 错误码变化 | **无**(`USER_NOT_FOUND`/`FORBIDDEN` 沿用,未新增 ErrorCode) |
| HTTP 映射变化 | **无**(166 项逐项一致) |
| 事务语义变化 | **无**(guard 只读、无 `@Transactional`,未改变任何事务边界) |
| ADMIN 权限扩大 | **无** |
| 测试削弱 | **无**(变异实验 50/63 失败) |
| 阻断项 | **0** |
---
## 19. 未暂存、未提交、未推送
**确认:未暂存、未提交、未推送。** 本轮未执行 `git add`、未创建提交、未推送;
未执行 `reset`/`restore`/`checkout`/`clean`;首轮复验未操作 Docker,随后在负责人授权的
补充复验中仅启动本机既有 Docker Desktop 并由 Testcontainers 创建临时 MySQL 容器,
未重建项目容器、未删除 volume;未删除历史未跟踪文件。
变异实验对测试支持文件的临时修改已**按原 SHA256 逐字节还原**并清理备份。
---
## 20. 未进入 R01-C/E
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、
新接口与数据库迁移中的任何一项。
---
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。**
---
## 21. Docker / Testcontainers 补充复验(2026-09-18)
### 21.1 首轮 SKIP 记录保留
首次独立复验时 Docker 守护进程不可用,以下 4 个测试类合计 26 个 MySQL/Testcontainers
用例按 `disabledWithoutDocker = true` 跳过;该事实及原因分析保留在本报告第 14 节,未删除、
未改写成首次即通过:
| 测试类 | Tests | 首轮 Skipped |
|---|---:|---:|
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | 6 |
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | 1 |
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | 4 |
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | 15 |
| **合计** | **26** | **26** |
### 21.2 环境恢复
经负责人授权启动本机既有 Docker Desktop:
```text
Docker Server: 29.6.2
Testcontainers: 1.21.3
MySQL image: mysql:8.4
```
未重建项目容器、未删除或修改既有 volume。Testcontainers 实际连接 Windows npipe Docker
Engine,并成功启动 Ryuk 与临时 MySQL 8.4 容器。
### 21.3 26 个 MySQL 用例定向补验
执行:
```text
mvn -Dtest="CandidateScoringRefreshMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchSpecificationMySqlIntegrationTest" test
```
结果:
```text
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
4 个类均真实启动 MySQL 8.4 Testcontainer,未使用 H2 代替,未通过修改
`disabledWithoutDocker` 或测试选择规则规避执行。
### 21.4 完整干净构建
随后执行:
```text
mvn clean verify
```
结果:
```text
Tests run: 2681, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
并重新执行 OpenAPI 校验:
```text
paths: 176
operations: 214
Valid JSON: True
All checks passed!
```
本次补验未修改任何生产代码、测试代码、Flyway、`ErrorCode`、DTO、接口、依赖或
`openapi-rc.json`。
### 21.5 最终冻结结论
首轮因环境导致的 26 个 SKIP 已由真实 Docker/Testcontainers 执行闭环,最终硬性门禁为
`Failures=0 / Errors=0 / Skipped=0`。
> **R01-B独立代码复验及Docker/Testcontainers补充验证全部通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。**

23
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java

@ -44,6 +44,7 @@ public class AbilityAttemptService {
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"};
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final AbilityDefinitionRepository definitionRepository; private final AbilityDefinitionRepository definitionRepository;
private final AbilityQuestionRepository questionRepository; private final AbilityQuestionRepository questionRepository;
private final AbilityAttemptRepository attemptRepository; private final AbilityAttemptRepository attemptRepository;
@ -64,7 +65,7 @@ public class AbilityAttemptService {
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) { public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND));
if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) { if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) {
@ -151,7 +152,7 @@ public class AbilityAttemptService {
@Transactional @Transactional
public AbilityAttemptResponse get(Long userId, Long attemptId) { public AbilityAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
expireIfTimedOut(attempt); expireIfTimedOut(attempt);
return toAttemptResponse(attempt); return toAttemptResponse(attempt);
@ -161,7 +162,7 @@ public class AbilityAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public AbilityResultResponse loadResult(Long userId, Long attemptId) { public AbilityResultResponse loadResult(Long userId, Long attemptId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
AbilityResult result = resultRepository.findByAttemptId(attemptId) AbilityResult result = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND));
@ -174,7 +175,7 @@ public class AbilityAttemptService {
@Transactional @Transactional
public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId, public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAbilityAnswersRequest req, AuditContext ctx) { SaveAbilityAnswersRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
if (expireIfTimedOut(attempt)) { if (expireIfTimedOut(attempt)) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
@ -251,7 +252,7 @@ public class AbilityAttemptService {
@Transactional @Transactional
public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) {
@ -299,7 +300,7 @@ public class AbilityAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) { public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
} }
@ -545,16 +546,6 @@ public class AbilityAttemptService {
} }
return a; return a;
} }
private void requireStudent(Long userId) {
User u = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private AbilityAttempt findByIdem(Long userId, String idem) { private AbilityAttempt findByIdem(Long userId, String idem) {
if (idem == null) { if (idem == null) {
return null; return null;

15
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java

@ -35,6 +35,7 @@ import java.util.stream.Collectors;
public class ApplicationController { public class ApplicationController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ /** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */ /** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
@ -170,7 +171,7 @@ public class ApplicationController {
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId(); Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId); userRoleGuard.requireCompany(userId);
// 参数校验 // 参数校验
if (page < 0 || size < 1 || size > 100) if (page < 0 || size < 1 || size > 100)
@ -204,7 +205,7 @@ public class ApplicationController {
@PathVariable Long applicationId, @PathVariable Long applicationId,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId(); Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId); userRoleGuard.requireCompany(userId);
var company = companyService.findCompanyByOwnerUserId(userId); var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
@ -220,7 +221,7 @@ public class ApplicationController {
@PathVariable Long applicationId, @PathVariable Long applicationId,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId(); Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId); userRoleGuard.requireCompany(userId);
// 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。 // 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。
CompanyResumeFile file = companyResumeService.load(userId, applicationId); CompanyResumeFile file = companyResumeService.load(userId, applicationId);
@ -237,12 +238,4 @@ public class ApplicationController {
} }
// ── 辅助 ────────────────────────────────────────────────── // ── 辅助 ──────────────────────────────────────────────────
private void assertCompanyRole(Long userId) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r.name().equals("COMPANY")))
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }

21
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java

@ -47,6 +47,7 @@ public class AssessmentAttemptService {
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final AssessmentDefinitionRepository definitionRepository; private final AssessmentDefinitionRepository definitionRepository;
private final AssessmentQuestionRepository questionRepository; private final AssessmentQuestionRepository questionRepository;
private final AssessmentAttemptRepository attemptRepository; private final AssessmentAttemptRepository attemptRepository;
@ -71,7 +72,7 @@ public class AssessmentAttemptService {
*/ */
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) { public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND));
// 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701 // 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701
@ -142,7 +143,7 @@ public class AssessmentAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public AssessmentAttemptResponse get(Long userId, Long attemptId) { public AssessmentAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId); AssessmentAttempt attempt = loadOwned(userId, attemptId);
return toAttemptResponse(attempt); return toAttemptResponse(attempt);
} }
@ -152,7 +153,7 @@ public class AssessmentAttemptService {
@Transactional @Transactional
public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId, public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAssessmentAnswersRequest req, AuditContext ctx) { SaveAssessmentAnswersRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId); AssessmentAttempt attempt = loadOwned(userId, attemptId);
if (!isMutable(attempt.getStatus())) { if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE); throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE);
@ -204,7 +205,7 @@ public class AssessmentAttemptService {
@Transactional @Transactional
public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId); AssessmentAttempt attempt = loadOwned(userId, attemptId);
// 重复提交幂等:返回原结果,不重新生成、不重复审计 // 重复提交幂等:返回原结果,不重新生成、不重复审计
@ -261,7 +262,7 @@ public class AssessmentAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<AssessmentHistoryItem> listMine(Long userId, int page, int size) { public Page<AssessmentHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
} }
@ -315,16 +316,6 @@ public class AssessmentAttemptService {
} }
return a; return a;
} }
private void requireStudent(Long userId) {
User u = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private AssessmentAttempt findByIdem(Long userId, String idem) { private AssessmentAttempt findByIdem(Long userId, String idem) {
if (idem == null) { if (idem == null) {
return null; return null;

13
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java

@ -6,6 +6,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -21,6 +22,7 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class MatchRuleService { public class MatchRuleService {
private final UserRoleGuard userRoleGuard;
private final CompanyMatchRuleRepository companyRuleRepository; private final CompanyMatchRuleRepository companyRuleRepository;
private final StudentMatchRuleRepository studentRuleRepository; private final StudentMatchRuleRepository studentRuleRepository;
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
@ -51,7 +53,7 @@ public class MatchRuleService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Map<String, Object> getStudentRule(Long userId) { public Map<String, Object> getStudentRule(Long userId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId).orElse(null); StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId).orElse(null);
int required = rule != null ? rule.getRequiredHrScore() int required = rule != null ? rule.getRequiredHrScore()
: StudentMatchRule.DEFAULT_REQUIRED_HR_SCORE; : StudentMatchRule.DEFAULT_REQUIRED_HR_SCORE;
@ -60,7 +62,7 @@ public class MatchRuleService {
@Transactional @Transactional
public Map<String, Object> updateStudentRule(Long userId, int requiredHrScore) { public Map<String, Object> updateStudentRule(Long userId, int requiredHrScore) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId) StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId)
.orElseGet(() -> StudentMatchRule.builder().studentUserId(userId).build()); .orElseGet(() -> StudentMatchRule.builder().studentUserId(userId).build());
rule.setRequiredHrScore(requiredHrScore); rule.setRequiredHrScore(requiredHrScore);
@ -94,11 +96,4 @@ public class MatchRuleService {
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} }
private void requireStudent(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles() != null
&& user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }

12
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java

@ -44,6 +44,7 @@ public class CompanyScoringDashboardService {
private static final String REVIEW_FILTER = private static final String REVIEW_FILTER =
"jr.status='PUBLISHED' AND jr.moderation_status IN ('AUTO_APPROVED','APPROVED')"; "jr.status='PUBLISHED' AND jr.moderation_status IN ('AUTO_APPROVED','APPROVED')";
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
private final JobService jobService; private final JobService jobService;
@ -276,7 +277,7 @@ public class CompanyScoringDashboardService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public AdminDashboardOverviewResponse getAdminOverview(Long adminUserId) { public AdminDashboardOverviewResponse getAdminOverview(Long adminUserId) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
Integer totalCompanies = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM companies", Integer.class); Integer totalCompanies = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM companies", Integer.class);
Integer totalJobs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Integer.class); Integer totalJobs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Integer.class);
Integer totalCandidates = jdbcTemplate.queryForObject( Integer totalCandidates = jdbcTemplate.queryForObject(
@ -452,15 +453,6 @@ public class CompanyScoringDashboardService {
return companyRepository.findByOwnerUserId(userId) return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
} }
private void assertAdminRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r == User.Role.ADMIN)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private void validatePage(int page, int size) { private void validatePage(int page, int size) {
if (page < 0 || size < 1 || size > 100) { if (page < 0 || size < 1 || size > 100) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);

32
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java

@ -41,6 +41,7 @@ public class CandidateScoringGovernanceService {
public static final int MAX_ROLLBACK_PER_YEAR = 3; public static final int MAX_ROLLBACK_PER_YEAR = 3;
private static final Pattern PERIOD_PATTERN = Pattern.compile("^\\d{4}-(0[1-9]|1[0-2])$"); private static final Pattern PERIOD_PATTERN = Pattern.compile("^\\d{4}-(0[1-9]|1[0-2])$");
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final CandidateScoringAppealRepository appealRepository; private final CandidateScoringAppealRepository appealRepository;
private final CandidateScoringAppealHistoryRepository appealHistoryRepository; private final CandidateScoringAppealHistoryRepository appealHistoryRepository;
private final CandidateScoringSnapshotHistoryRepository snapshotHistoryRepository; private final CandidateScoringSnapshotHistoryRepository snapshotHistoryRepository;
@ -62,23 +63,6 @@ public class CandidateScoringGovernanceService {
private EntityManager entityManager; private EntityManager entityManager;
// ── 角色校验 ──────────────────────────────────────────────── // ── 角色校验 ────────────────────────────────────────────────
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r == User.Role.STUDENT)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private void assertAdminRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r == User.Role.ADMIN)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private boolean isAdmin(Long userId) { private boolean isAdmin(Long userId) {
return userRepository.findById(userId) return userRepository.findById(userId)
.map(u -> u.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN)) .map(u -> u.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN))
@ -89,7 +73,7 @@ public class CandidateScoringGovernanceService {
@Transactional @Transactional
public AppealResponse createAppeal(Long studentUserId, CreateAppealRequest req, AuditContext ctx) { public AppealResponse createAppeal(Long studentUserId, CreateAppealRequest req, AuditContext ctx) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
if (!CandidateScoringCalculator.isValidDimension(req.getDimension())) { if (!CandidateScoringCalculator.isValidDimension(req.getDimension())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension: " + req.getDimension()); throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension: " + req.getDimension());
} }
@ -158,7 +142,7 @@ public class CandidateScoringGovernanceService {
// ── 我的申诉列表 ──────────────────────────────────────────── // ── 我的申诉列表 ────────────────────────────────────────────
public Page<AppealResponse> listMyAppeals(Long studentUserId, String status, int page, int size) { public Page<AppealResponse> listMyAppeals(Long studentUserId, String status, int page, int size) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
validatePage(page, size); validatePage(page, size);
PageRequest pr = PageRequest.of(page, size); PageRequest pr = PageRequest.of(page, size);
Page<CandidateScoringAppeal> p = (status != null && !status.isBlank()) Page<CandidateScoringAppeal> p = (status != null && !status.isBlank())
@ -182,7 +166,7 @@ public class CandidateScoringGovernanceService {
@Transactional @Transactional
public AppealResponse cancelAppeal(Long studentUserId, Long appealId, AuditContext ctx) { public AppealResponse cancelAppeal(Long studentUserId, Long appealId, AuditContext ctx) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
CandidateScoringAppeal appeal = requireAppeal(appealId); CandidateScoringAppeal appeal = requireAppeal(appealId);
if (!appeal.getCandidateUserId().equals(studentUserId)) { if (!appeal.getCandidateUserId().equals(studentUserId)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
@ -204,7 +188,7 @@ public class CandidateScoringGovernanceService {
// ── 全部申诉列表(ADMIN) ─────────────────────────────────── // ── 全部申诉列表(ADMIN) ───────────────────────────────────
public Page<AppealResponse> listAllAppeals(Long adminUserId, String status, int page, int size) { public Page<AppealResponse> listAllAppeals(Long adminUserId, String status, int page, int size) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
validatePage(page, size); validatePage(page, size);
PageRequest pr = PageRequest.of(page, size); PageRequest pr = PageRequest.of(page, size);
Page<CandidateScoringAppeal> p = (status != null && !status.isBlank()) Page<CandidateScoringAppeal> p = (status != null && !status.isBlank())
@ -217,7 +201,7 @@ public class CandidateScoringGovernanceService {
@Transactional @Transactional
public AppealResponse decideAppeal(Long adminUserId, Long appealId, AppealDecisionRequest req, AuditContext ctx) { public AppealResponse decideAppeal(Long adminUserId, Long appealId, AppealDecisionRequest req, AuditContext ctx) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
CandidateScoringAppeal appeal = requireAppeal(appealId); CandidateScoringAppeal appeal = requireAppeal(appealId);
String target = req.getDecision(); String target = req.getDecision();
if (!"APPROVE".equals(target) && !"REJECT".equals(target)) { if (!"APPROVE".equals(target) && !"REJECT".equals(target)) {
@ -261,7 +245,7 @@ public class CandidateScoringGovernanceService {
@Transactional @Transactional
public AppealResponse rollbackAppeal(Long adminUserId, Long appealId, AuditContext ctx) { public AppealResponse rollbackAppeal(Long adminUserId, Long appealId, AuditContext ctx) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
CandidateScoringAppeal appeal = requireAppeal(appealId); CandidateScoringAppeal appeal = requireAppeal(appealId);
if (CandidateScoringAppeal.STATUS_ROLLED_BACK.equals(appeal.getStatus())) { if (CandidateScoringAppeal.STATUS_ROLLED_BACK.equals(appeal.getStatus())) {
return toView(appeal, true); // 幂等,不重复写历史 return toView(appeal, true); // 幂等,不重复写历史
@ -343,7 +327,7 @@ public class CandidateScoringGovernanceService {
public List<SnapshotHistoryItem> getSnapshotHistory(Long adminUserId, Long candidateUserId, public List<SnapshotHistoryItem> getSnapshotHistory(Long adminUserId, Long candidateUserId,
String dimension, String period) { String dimension, String period) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
return snapshotHistoryRepository return snapshotHistoryRepository
.findByCandidateUserIdAndDimensionAndPeriodOrderByCreatedAtAsc(candidateUserId, dimension, period) .findByCandidateUserIdAndDimensionAndPeriodOrderByCreatedAtAsc(candidateUserId, dimension, period)
.stream().map(s -> SnapshotHistoryItem.builder() .stream().map(s -> SnapshotHistoryItem.builder()

13
mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java

@ -66,6 +66,7 @@ public class CareerProgressService {
private static final Set<String> NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED"); private static final Set<String> NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED");
private static final String REPORT_DECISION_TYPE = "REPORT_DECISION"; private static final String REPORT_DECISION_TYPE = "REPORT_DECISION";
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
private final InterviewRepository interviewRepository; private final InterviewRepository interviewRepository;
private final InterviewScoreRepository interviewScoreRepository; private final InterviewScoreRepository interviewScoreRepository;
@ -79,7 +80,7 @@ public class CareerProgressService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) { public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
validateParams(page, size, status); validateParams(page, size, status);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@ -309,16 +310,6 @@ public class CareerProgressService {
} }
// ── 权限 / 参数 / 工具 ───────────────────────────────── // ── 权限 / 参数 / 工具 ─────────────────────────────────
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private void validateParams(int page, int size, String status) { private void validateParams(int page, int size, String status) {
if (page < 0 || size < 1 || size > 50) { if (page < 0 || size < 1 || size > 50) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);

261
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java

@ -0,0 +1,261 @@
package com.mydreamjob.common.exception;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* 业务错误码 → HTTP 状态的**唯一**集中映射(R01-B/B2)。
*
* <p>职责边界:只回答"这个业务错误码应该对应哪个 HTTP 状态"。不做日志、不读请求、
* 不生成响应体、不解析 requestId、不缓存请求级状态。</p>
*
* <h2>契约(与重构前逐项一致)</h2>
* <ul>
* <li>显式映射只在 {@link #explicitMappings()} 一处定义,全部使用
* {@code ErrorCode.X} 符号,不出现手写数字;</li>
* <li>**未显式映射的错误码回退 {@link #DEFAULT_STATUS}(400 BAD_REQUEST)**,
* 与重构前 {@code getOrDefault(code, BAD_REQUEST)} 完全一致;</li>
* <li>**映射键是 {@link ErrorCode} 本身,不是数字**。这一点是必须的:
* {@code ErrorCode} 现有 166 个常量却只有 165 个唯一数字码
* ({@code REVIEW_ALREADY_DECIDED} 与 {@code DUPLICATE_FEEDBACK} 同为 9103),
* 若以数字为键,"禁止重复键"将无法在不修改 {@code ErrorCode} 数字的前提下成立。
* 数字入口 {@link #resolve(int)} 由该映射派生;若两个不同常量被映射到
* **同一个数字码且状态不同**,初始化即抛错(这是不可接受的歧义);</li>
* <li>映射表初始化后不可变({@link Map#copyOf}),运行期无法增删改;</li>
* <li>同一个 {@link ErrorCode} 常量被注册两次即为**初始化错误**
* (无论目标状态是否相同),由 {@link Builder#put} 立即抛出
* {@link IllegalStateException},使"重复键"在启动期而不是请求期暴露;</li>
* <li>不使用反射扫描,不引入任何运行时可变的 Map。</li>
* </ul>
*
* <p><strong>注意</strong>:{@code UNAUTHORIZED(2001)} 与 {@code SERVICE_UNAVAILABLE}
* 在本映射中**故意不显式登记**,因此既有语义是回退 400 —— 这是重构前就存在的行为,
* 本次收束不得擅自把它改成 401/503。</p>
*/
@Component
public final class ErrorHttpStatusRegistry {
/** 未显式映射时的回退状态(与重构前一致)。 */
public static final HttpStatus DEFAULT_STATUS = HttpStatus.BAD_REQUEST;
private static final Map<ErrorCode, HttpStatus> EXPLICIT = explicitMappings();
private static final Map<Integer, HttpStatus> BY_CODE = indexByCode(EXPLICIT);
/** 按 {@link ErrorCode} 解析。{@code null} 走默认回退,不做 NPE 抛出。 */
public HttpStatus resolve(ErrorCode errorCode) {
return errorCode == null ? DEFAULT_STATUS : EXPLICIT.getOrDefault(errorCode, DEFAULT_STATUS);
}
/** 按数字业务码解析({@code BusinessException} 只暴露数字 code)。 */
public HttpStatus resolve(int errorCode) {
return BY_CODE.getOrDefault(errorCode, DEFAULT_STATUS);
}
/** 未映射错误码的默认状态。 */
public HttpStatus defaultStatus() {
return DEFAULT_STATUS;
}
/** 显式登记的 {@link ErrorCode} 常量数量(用于一致性校验与报告)。 */
public int explicitMappingCount() {
return EXPLICIT.size();
}
/** 显式映射覆盖的唯一数字码数量(小于等于 {@link #explicitMappingCount()})。 */
public int explicitNumericCodeCount() {
return BY_CODE.size();
}
/** 只读视图:显式映射的 {@link ErrorCode} → 状态。 */
public static Map<ErrorCode, HttpStatus> explicitMappingsView() {
return EXPLICIT;
}
/** 只读视图:数字业务码 → 状态(由 {@link #explicitMappingsView()} 派生)。 */
public static Map<Integer, HttpStatus> numericMappingsView() {
return BY_CODE;
}
/** 某个错误码是否被显式登记。 */
public static boolean isExplicitlyMapped(ErrorCode errorCode) {
return errorCode != null && EXPLICIT.containsKey(errorCode);
}
/**
* 由 {@link ErrorCode} 键映射派生数字键索引。
*
* <p>同一数字码对应多个常量是**既有事实**(9103)。只要它们被映射到相同状态,
* 数字入口就是无歧义的,保持既有行为;一旦状态不同,说明存在无法自动裁决的冲突,
* 初始化必须失败而不是任选其一。</p>
*/
private static Map<Integer, HttpStatus> indexByCode(Map<ErrorCode, HttpStatus> explicit) {
Map<Integer, HttpStatus> byCode = new LinkedHashMap<>();
for (Map.Entry<ErrorCode, HttpStatus> entry : explicit.entrySet()) {
HttpStatus previous = byCode.putIfAbsent(entry.getKey().getCode(), entry.getValue());
if (previous != null && previous != entry.getValue()) {
throw new IllegalStateException("conflicting HTTP status mapping for numeric error code "
+ entry.getKey().getCode() + ": " + previous + " then " + entry.getValue());
}
}
return Map.copyOf(byCode);
}
/**
* 显式映射定义(唯一位置)。仅登记与重构前完全一致的 (错误码, 状态) 组合。
*/
private static Map<ErrorCode, HttpStatus> explicitMappings() {
return new Builder()
.put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
ErrorCode.OFFER_COMPARE_NOT_OWNER)
.put(HttpStatus.NOT_FOUND,
ErrorCode.ABILITY_ATTEMPT_NOT_FOUND,
ErrorCode.ABILITY_RESULT_NOT_FOUND,
ErrorCode.ABILITY_TYPE_NOT_FOUND,
ErrorCode.APPEAL_NOT_FOUND,
ErrorCode.APPLICATION_NOT_FOUND,
ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND,
ErrorCode.ASSESSMENT_TYPE_NOT_FOUND,
ErrorCode.COMPANY_NOT_FOUND,
ErrorCode.CONVERSATION_NOT_FOUND,
ErrorCode.DATA_EXPORT_TASK_NOT_FOUND,
ErrorCode.FEEDBACK_NOT_FOUND,
ErrorCode.INTERVIEW_SCORE_NOT_FOUND,
ErrorCode.JOB_NOT_FOUND,
ErrorCode.MESSAGE_NOT_FOUND,
ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND,
ErrorCode.OFFER_NOT_FOUND,
ErrorCode.PROFILE_NOT_FOUND,
ErrorCode.REPORT_NOT_FOUND,
ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND,
ErrorCode.RESUME_VERSION_NOT_FOUND,
ErrorCode.REVIEW_NOT_FOUND,
ErrorCode.REVIEW_REPLY_NOT_FOUND,
ErrorCode.RISK_EVENT_NOT_FOUND,
ErrorCode.RISK_RULE_NOT_FOUND,
ErrorCode.TALENT_INVITATION_NOT_FOUND,
ErrorCode.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE,
ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT,
ErrorCode.ABILITY_RETAKE_COOLDOWN,
ErrorCode.ACCOUNT_DELETED,
ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED,
ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE,
ErrorCode.ACCOUNT_DELETION_PENDING,
ErrorCode.APPEAL_ALREADY_DECIDED,
ErrorCode.APPEAL_DUPLICATE,
ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT,
ErrorCode.APPEAL_INVALID_TRANSITION,
ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED,
ErrorCode.APPLICATION_DUPLICATE,
ErrorCode.APPLICATION_NOT_MATCHED,
ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE,
ErrorCode.ASSESSMENT_CONCURRENT_UPDATE,
ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE,
ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT,
ErrorCode.COMPANY_ALREADY_EXISTS,
ErrorCode.DATA_EXPORT_DUPLICATE,
ErrorCode.DATA_EXPORT_EXPIRED,
ErrorCode.DATA_EXPORT_FAILED,
ErrorCode.DATA_EXPORT_FILE_NOT_FOUND,
ErrorCode.DATA_EXPORT_NOT_READY,
ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED,
ErrorCode.DUPLICATE_DEFAULT_RESUME,
ErrorCode.DUPLICATE_FEEDBACK,
ErrorCode.DUPLICATE_OFFER,
ErrorCode.DUPLICATE_REVIEW,
ErrorCode.INTERVIEW_ALREADY_RESPONDED,
ErrorCode.INTERVIEW_COMPLETE_NOT_READY,
ErrorCode.INTERVIEW_NOT_COMPLETED,
ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE,
ErrorCode.INTERVIEW_SCORE_SUBMITTED,
ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION,
ErrorCode.INVALID_DELETION_STATE,
ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION,
ErrorCode.INVALID_JOB_STATUS_TRANSITION,
ErrorCode.INVALID_OFFER_STATUS_TRANSITION,
ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION,
ErrorCode.LAST_ADMIN_CANNOT_DELETE,
ErrorCode.MATCH_CLOSED,
ErrorCode.MATCH_SCORES_LOCKED,
ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE,
ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED,
ErrorCode.OFFER_COUNTER_OFFER_EXISTS,
ErrorCode.OFFER_COUNTER_OFFER_EXPIRED,
ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING,
ErrorCode.OFFER_EXPIRED,
ErrorCode.OFFER_NOT_EDITABLE,
ErrorCode.OFFER_REMINDER_DUPLICATE,
ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE,
ErrorCode.REPORT_ALREADY_DECIDED,
ErrorCode.REPORT_DUPLICATE,
ErrorCode.REPORT_IDEMPOTENCY_CONFLICT,
ErrorCode.REPORT_INVALID_TRANSITION,
ErrorCode.RESUME_EXPORT_IN_PROGRESS,
ErrorCode.RESUME_EXPORT_NOT_READY,
ErrorCode.REVIEW_ALREADY_DECIDED,
ErrorCode.REVIEW_REPLY_ALREADY_EXISTS,
ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE,
ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT,
ErrorCode.REVIEW_REPLY_INVALID_STATE,
ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE,
ErrorCode.RISK_EVENT_CONCURRENT_UPDATE,
ErrorCode.RISK_INVALID_ACTION,
ErrorCode.RISK_RULE_CONCURRENT_UPDATE,
ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE,
ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE,
ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE,
ErrorCode.TALENT_INVITATION_EXPIRED,
ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT,
ErrorCode.TALENT_INVITATION_INVALID_STATE,
ErrorCode.VERIFICATION_ALREADY_APPROVED,
ErrorCode.VERIFICATION_ALREADY_PENDING)
.put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.ABILITY_RETAKE_LIMIT,
ErrorCode.REPORT_RATE_LIMITED,
ErrorCode.TALENT_INVITATION_RATE_LIMITED)
.build();
}
/**
* 显式映射构建器。
*
* <p>重复键检测:同一个数字错误码第二次注册即抛 {@link IllegalStateException},
* **不区分目标状态是否相同** —— "同码同状态的重复登记"同样是不可审计的配置错误。</p>
*/
public static final class Builder {
private final Map<ErrorCode, HttpStatus> entries = new LinkedHashMap<>();
public Builder put(HttpStatus status, ErrorCode... errorCodes) {
Objects.requireNonNull(status, "status");
Objects.requireNonNull(errorCodes, "errorCodes");
for (ErrorCode errorCode : errorCodes) {
Objects.requireNonNull(errorCode, "errorCode");
HttpStatus previous = entries.putIfAbsent(errorCode, status);
if (previous != null) {
throw new IllegalStateException(
"duplicate HTTP status mapping for error code " + errorCode.getCode()
+ " (" + errorCode.name() + "): " + previous + " then " + status);
}
}
return this;
}
/** 构建不可变映射(键为 {@link ErrorCode})。 */
public Map<ErrorCode, HttpStatus> build() {
return Map.copyOf(entries);
}
}
}

100
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
@ -21,20 +22,22 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
/** /**
* 全局异常处理器 * 全局异常处理器
*
* <p>R01-B/B2:业务错误码 → HTTP 状态的映射已收束到 {@link ErrorHttpStatusRegistry},
* 本类只负责调用它,不再持有任何映射表或条件链。</p>
*/ */
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
@RequiredArgsConstructor @RequiredArgsConstructor
@Import(ErrorHttpStatusRegistry.class)
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Map<Integer, HttpStatus> BUSINESS_HTTP_STATUSES = buildBusinessHttpStatuses();
private final RequestIdAccessor requestIdAccessor; private final RequestIdAccessor requestIdAccessor;
private final ErrorHttpStatusRegistry errorHttpStatusRegistry;
@ExceptionHandler(ConstraintViolationException.class) @ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation( public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
@ -51,101 +54,12 @@ public class GlobalExceptionHandler {
int errorCode = e.getErrorCode(); int errorCode = e.getErrorCode();
log.warn("BusinessException: code={}, message={}, path={}", log.warn("BusinessException: code={}, message={}, path={}",
errorCode, e.getMessage(), request.getRequestURI()); errorCode, e.getMessage(), request.getRequestURI());
HttpStatus status = resolveHttpStatus(errorCode); HttpStatus status = errorHttpStatusRegistry.resolve(errorCode);
return ResponseEntity return ResponseEntity
.status(status) .status(status)
.body(ApiResponse.error(errorCode, e.getMessage(), getRequestId(request))); .body(ApiResponse.error(errorCode, e.getMessage(), getRequestId(request)));
} }
/**
* 按业务错误码段映射 HTTP 状态码(与 API v0.2 草案一致):
* - FORBIDDEN(2002) → 403
* - 不存在类(5001/6001/7001/8002/4001/9102) → 404
* - 冲突/非法流转类(5003/7003/8001/8003/9101) → 409
* - 其余 → 400
*/
private HttpStatus resolveHttpStatus(int errorCode) {
return BUSINESS_HTTP_STATUSES.getOrDefault(errorCode, HttpStatus.BAD_REQUEST);
}
private static Map<Integer, HttpStatus> buildBusinessHttpStatuses() {
Map<Integer, HttpStatus> statuses = new LinkedHashMap<>();
register(statuses, HttpStatus.FORBIDDEN,
ErrorCode.FORBIDDEN, ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.APPLICATION_OWNER_MISMATCH, ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
ErrorCode.OFFER_COMPARE_NOT_OWNER);
register(statuses, HttpStatus.NOT_FOUND,
ErrorCode.COMPANY_NOT_FOUND, ErrorCode.PROFILE_NOT_FOUND, ErrorCode.JOB_NOT_FOUND,
ErrorCode.APPLICATION_NOT_FOUND, ErrorCode.OFFER_NOT_FOUND,
ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND, ErrorCode.RESUME_VERSION_NOT_FOUND,
ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND, ErrorCode.USER_NOT_FOUND,
ErrorCode.REVIEW_NOT_FOUND, ErrorCode.FEEDBACK_NOT_FOUND,
ErrorCode.CONVERSATION_NOT_FOUND, ErrorCode.MESSAGE_NOT_FOUND,
ErrorCode.APPEAL_NOT_FOUND, ErrorCode.DATA_EXPORT_TASK_NOT_FOUND,
ErrorCode.REVIEW_REPLY_NOT_FOUND, ErrorCode.TALENT_INVITATION_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND, ErrorCode.INTERVIEW_SCORE_NOT_FOUND,
ErrorCode.REPORT_NOT_FOUND, ErrorCode.ASSESSMENT_TYPE_NOT_FOUND,
ErrorCode.ASSESSMENT_ATTEMPT_NOT_FOUND, ErrorCode.ABILITY_TYPE_NOT_FOUND,
ErrorCode.ABILITY_ATTEMPT_NOT_FOUND, ErrorCode.ABILITY_RESULT_NOT_FOUND,
ErrorCode.RISK_RULE_NOT_FOUND, ErrorCode.RISK_EVENT_NOT_FOUND);
register(statuses, HttpStatus.CONFLICT,
ErrorCode.COMPANY_ALREADY_EXISTS, ErrorCode.APPLICATION_DUPLICATE,
ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION, ErrorCode.INVALID_JOB_STATUS_TRANSITION,
ErrorCode.DUPLICATE_REVIEW, ErrorCode.DUPLICATE_FEEDBACK, ErrorCode.DUPLICATE_OFFER,
ErrorCode.INVALID_OFFER_STATUS_TRANSITION, ErrorCode.OFFER_EXPIRED,
ErrorCode.OFFER_NOT_EDITABLE, ErrorCode.OFFER_REMINDER_DUPLICATE,
ErrorCode.OFFER_COUNTER_OFFER_EXISTS, ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING,
ErrorCode.OFFER_COUNTER_OFFER_EXPIRED, ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED,
ErrorCode.RESUME_EXPORT_IN_PROGRESS, ErrorCode.DUPLICATE_DEFAULT_RESUME,
ErrorCode.RESUME_EXPORT_NOT_READY, ErrorCode.MATCH_CLOSED,
ErrorCode.MATCH_SCORES_LOCKED, ErrorCode.APPLICATION_NOT_MATCHED,
ErrorCode.REVIEW_ALREADY_DECIDED, ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION,
ErrorCode.INTERVIEW_COMPLETE_NOT_READY, ErrorCode.INTERVIEW_ALREADY_RESPONDED,
ErrorCode.REVIEW_REPLY_ALREADY_EXISTS, ErrorCode.REVIEW_REPLY_INVALID_STATE,
ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE, ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT,
ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED, ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE,
ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE,
ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE, ErrorCode.TALENT_INVITATION_INVALID_STATE,
ErrorCode.TALENT_INVITATION_EXPIRED, ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE,
ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT,
ErrorCode.VERIFICATION_ALREADY_PENDING, ErrorCode.VERIFICATION_ALREADY_APPROVED,
ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION,
ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE, ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE,
ErrorCode.INVALID_DELETION_STATE, ErrorCode.ACCOUNT_DELETION_PENDING,
ErrorCode.ACCOUNT_DELETED, ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE,
ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED, ErrorCode.LAST_ADMIN_CANNOT_DELETE,
ErrorCode.APPEAL_DUPLICATE, ErrorCode.APPEAL_ALREADY_DECIDED,
ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED, ErrorCode.APPEAL_INVALID_TRANSITION,
ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT, ErrorCode.DATA_EXPORT_DUPLICATE,
ErrorCode.DATA_EXPORT_NOT_READY, ErrorCode.DATA_EXPORT_EXPIRED,
ErrorCode.DATA_EXPORT_FAILED, ErrorCode.DATA_EXPORT_FILE_NOT_FOUND,
ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE, ErrorCode.INTERVIEW_NOT_COMPLETED,
ErrorCode.INTERVIEW_SCORE_SUBMITTED, ErrorCode.REPORT_DUPLICATE,
ErrorCode.REPORT_ALREADY_DECIDED, ErrorCode.REPORT_INVALID_TRANSITION,
ErrorCode.REPORT_IDEMPOTENCY_CONFLICT, ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE,
ErrorCode.ASSESSMENT_CONCURRENT_UPDATE, ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE,
ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE, ErrorCode.ABILITY_DUPLICATE_ACTIVE,
ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT, ErrorCode.ABILITY_RETAKE_COOLDOWN,
ErrorCode.RISK_RULE_CONCURRENT_UPDATE, ErrorCode.RISK_EVENT_CONCURRENT_UPDATE,
ErrorCode.RISK_INVALID_ACTION);
register(statuses, HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.REPORT_RATE_LIMITED, ErrorCode.ABILITY_RETAKE_LIMIT,
ErrorCode.TALENT_INVITATION_RATE_LIMITED);
return Map.copyOf(statuses);
}
private static void register(Map<Integer, HttpStatus> statuses, HttpStatus status,
ErrorCode... errorCodes) {
for (ErrorCode errorCode : errorCodes) {
HttpStatus previous = statuses.putIfAbsent(errorCode.getCode(), status);
if (previous != null && previous != status) {
throw new IllegalStateException("conflicting HTTP status mapping for error code "
+ errorCode.getCode());
}
}
}
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException( public ResponseEntity<ApiResponse<Void>> handleValidationException(
MethodArgumentNotValidException ex, HttpServletRequest request) { MethodArgumentNotValidException ex, HttpServletRequest request) {

21
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java

@ -25,37 +25,28 @@ import java.util.Map;
@Service @Service
public class CompanyService { public class CompanyService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final JobService jobService; private final JobService jobService;
public CompanyService(CompanyRepository companyRepository, UserRepository userRepository, public CompanyService(CompanyRepository companyRepository, UserRepository userRepository,
JobService jobService) { JobService jobService,
com.mydreamjob.security.UserRoleGuard userRoleGuard) {
this.companyRepository = companyRepository; this.companyRepository = companyRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.jobService = jobService; this.jobService = jobService;
this.userRoleGuard = userRoleGuard;
} }
// ── 角色校验(最先执行)─────────────────────────────────── // ── 角色校验(最先执行)───────────────────────────────────
public void assertCompanyRole(Long userId) { public void assertCompanyRole(Long userId) {
User user = userRepository.findById(userId) userRoleGuard.requireCompany(userId);
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isCompany = user.getRoles().stream()
.anyMatch(r -> r == User.Role.COMPANY);
if (!isCompany) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }
public void assertStudentRole(Long userId) { public void assertStudentRole(Long userId) {
User user = userRepository.findById(userId) userRoleGuard.requireStudent(userId);
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream()
.anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }
// ── 业务方法 ────────────────────────────────────────────── // ── 业务方法 ──────────────────────────────────────────────

13
mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java

@ -39,6 +39,7 @@ public class CompetitionRankingService {
private static final int TOP_N = 5; private static final int TOP_N = 5;
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final UserRepository userRepository; private final UserRepository userRepository;
private final JobService jobService; private final JobService jobService;
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
@ -50,7 +51,7 @@ public class CompetitionRankingService {
if (jobId == null || jobId <= 0) { if (jobId == null || jobId <= 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
} }
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
// 仅 PUBLISHED 岗位可查看未公开状态统一 404,避免暴露岗位候选人池 // 仅 PUBLISHED 岗位可查看未公开状态统一 404,避免暴露岗位候选人池
Job job = jobService.getJob(jobId); Job job = jobService.getJob(jobId);
@ -146,16 +147,6 @@ public class CompetitionRankingService {
} }
// ── 身份与工具 ───────────────────────────────────────────── // ── 身份与工具 ─────────────────────────────────────────────
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private String yearMonth(LocalDate d) { private String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
} }

18
mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java

@ -20,6 +20,7 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class JobFavoriteService { public class JobFavoriteService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final JobFavoriteRepository favoriteRepository; private final JobFavoriteRepository favoriteRepository;
private final JobService jobService; private final JobService jobService;
private final UserRepository userRepository; private final UserRepository userRepository;
@ -27,22 +28,11 @@ public class JobFavoriteService {
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
// ── 角色校验 ────────────────────────────────────────────── // ── 角色校验 ──────────────────────────────────────────────
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream()
.anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
// ── 收藏 ────────────────────────────────────────────────── // ── 收藏 ──────────────────────────────────────────────────
@Transactional @Transactional
public Map<String, Object> favorite(Long studentUserId, Long jobId) { public Map<String, Object> favorite(Long studentUserId, Long jobId) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
Job job = jobService.getJob(jobId); Job job = jobService.getJob(jobId);
if (!"PUBLISHED".equals(job.getStatus())) { if (!"PUBLISHED".equals(job.getStatus())) {
@ -76,7 +66,7 @@ public class JobFavoriteService {
@Transactional @Transactional
public Map<String, Object> unfavorite(Long studentUserId, Long jobId) { public Map<String, Object> unfavorite(Long studentUserId, Long jobId) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
// 幂等:不存在时也返回 200 // 幂等:不存在时也返回 200
favoriteRepository.deleteByJobIdAndStudentUserId(jobId, studentUserId); favoriteRepository.deleteByJobIdAndStudentUserId(jobId, studentUserId);
@ -86,7 +76,7 @@ public class JobFavoriteService {
// ── 查询收藏列表 ────────────────────────────────────────── // ── 查询收藏列表 ──────────────────────────────────────────
public Page<Map<String, Object>> listFavorites(Long studentUserId, int page, int size) { public Page<Map<String, Object>> listFavorites(Long studentUserId, int page, int size) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
// size<1 直接 400,避免 PageRequest.of 抛 IllegalArgumentException 导致 500 // size<1 直接 400,避免 PageRequest.of 抛 IllegalArgumentException 导致 500
if (size < 1) throw new BusinessException(ErrorCode.BAD_REQUEST); if (size < 1) throw new BusinessException(ErrorCode.BAD_REQUEST);

12
mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java

@ -29,6 +29,7 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class MatchService { public class MatchService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final JobService jobService; private final JobService jobService;
private final StudentPreferenceRepository preferenceRepository; private final StudentPreferenceRepository preferenceRepository;
private final StudentProfileRepository profileRepository; private final StudentProfileRepository profileRepository;
@ -38,15 +39,6 @@ public class MatchService {
private static final String RULES_VERSION = "RULES_V0_1"; private static final String RULES_VERSION = "RULES_V0_1";
/** 岗位匹配诊断仅 STUDENT 可用 */ /** 岗位匹配诊断仅 STUDENT 可用 */
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
// ── 匹配规则 ────────────────────────────────────────────── // ── 匹配规则 ──────────────────────────────────────────────
public Map<String, Object> diagnose(Long studentUserId, Long jobId) { public Map<String, Object> diagnose(Long studentUserId, Long jobId) {
@ -54,7 +46,7 @@ public class MatchService {
} }
public Map<String, Object> diagnose(Long studentUserId, Long jobId, com.mydreamjob.audit.AuditContext ctx) { public Map<String, Object> diagnose(Long studentUserId, Long jobId, com.mydreamjob.audit.AuditContext ctx) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
// 岗位必须存在 // 岗位必须存在
Job job = jobService.getJob(jobId); Job job = jobService.getJob(jobId);

10
mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java

@ -9,6 +9,7 @@ import com.mydreamjob.preference.dto.StudentPreferenceRequest;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -24,6 +25,7 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PreferenceService { public class PreferenceService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final StudentPreferenceRepository preferenceRepository; private final StudentPreferenceRepository preferenceRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
/** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */ /** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */
@ -33,13 +35,7 @@ public class PreferenceService {
// ── 角色校验 ────────────────────────────────────────────── // ── 角色校验 ──────────────────────────────────────────────
public void assertStudentRole(Long userId) { public void assertStudentRole(Long userId) {
User user = userRepository.findById(userId) userRoleGuard.requireStudent(userId);
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream()
.anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }
// ── 查询 ────────────────────────────────────────────────── // ── 查询 ──────────────────────────────────────────────────

10
mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java

@ -8,6 +8,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsResponse;
import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
@ -26,6 +27,7 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PrivacySettingsService { public class PrivacySettingsService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final StudentPrivacySettingsRepository privacyRepository; private final StudentPrivacySettingsRepository privacyRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService; private final AuditService auditService;
@ -38,13 +40,7 @@ public class PrivacySettingsService {
/** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */ /** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */
public void assertStudentRole(Long userId) { public void assertStudentRole(Long userId) {
User user = userRepository.findById(userId) userRoleGuard.requireStudent(userId);
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles() != null
&& user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }
/** /**

10
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java

@ -13,6 +13,7 @@ import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import lombok.Getter; import lombok.Getter;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -31,6 +32,7 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ProfileService { public class ProfileService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final StudentProfileRepository profileRepository; private final StudentProfileRepository profileRepository;
private final ResumeStorageService resumeStorageService; private final ResumeStorageService resumeStorageService;
private final UserRepository userRepository; private final UserRepository userRepository;
@ -43,13 +45,7 @@ public class ProfileService {
// ── 角色校验 ────────────────────────────────────────────── // ── 角色校验 ──────────────────────────────────────────────
public void assertStudentRole(Long userId) { public void assertStudentRole(Long userId) {
User user = userRepository.findById(userId) userRoleGuard.requireStudent(userId);
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream()
.anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }
// ── 学生资料 CRUD ──────────────────────────────────────── // ── 学生资料 CRUD ────────────────────────────────────────

13
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java

@ -40,6 +40,7 @@ public class JobRecommendationService {
private static final Set<String> FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL"); private static final Set<String> FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL");
private static final Set<String> DECISIONS = Set.of("APPROVE", "REJECT"); private static final Set<String> DECISIONS = Set.of("APPROVE", "REJECT");
private static final Set<String> REJECT_REASONS = Set.of("TAXONOMY_MISMATCH", "WEIGHT_INVALID", "CONTENT_INCOMPLETE", "OTHER"); private static final Set<String> REJECT_REASONS = Set.of("TAXONOMY_MISMATCH", "WEIGHT_INVALID", "CONTENT_INCOMPLETE", "OTHER");
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final JobRecommendationTaxonomyRepository taxonomyRepository; private final JobRecommendationTaxonomyRepository taxonomyRepository;
private final JobRecommendationProfileRepository profileRepository; private final JobRecommendationProfileRepository profileRepository;
private final StudentJobRecommendationFeedbackRepository feedbackRepository; private final StudentJobRecommendationFeedbackRepository feedbackRepository;
@ -90,7 +91,7 @@ public class JobRecommendationService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<RecommendationProfileResponse> adminProfiles(Long adminId, int page, int size, String status) { public Page<RecommendationProfileResponse> adminProfiles(Long adminId, int page, int size, String status) {
requireAdmin(adminId); validatePage(page, size); userRoleGuard.requireAdmin(adminId); validatePage(page, size);
Page<JobRecommendationProfile> result = status == null || status.isBlank() Page<JobRecommendationProfile> result = status == null || status.isBlank()
? profileRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"))) ? profileRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")))
: profileRepository.findByReviewStatusOrderByUpdatedAtDesc(status, PageRequest.of(page, size)); : profileRepository.findByReviewStatusOrderByUpdatedAtDesc(status, PageRequest.of(page, size));
@ -99,7 +100,7 @@ public class JobRecommendationService {
@Transactional @Transactional
public RecommendationProfileResponse decide(Long adminId, Long jobId, RecommendationDecisionRequest req, AuditContext ctx) { public RecommendationProfileResponse decide(Long adminId, Long jobId, RecommendationDecisionRequest req, AuditContext ctx) {
requireAdmin(adminId); userRoleGuard.requireAdmin(adminId);
if (!DECISIONS.contains(req.getDecision()) || ("REJECT".equals(req.getDecision()) && !REJECT_REASONS.contains(req.getReasonCode()))) if (!DECISIONS.contains(req.getDecision()) || ("REJECT".equals(req.getDecision()) && !REJECT_REASONS.contains(req.getReasonCode())))
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID); throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);
JobRecommendationProfile p = profileRepository.findByJobId(jobId) JobRecommendationProfile p = profileRepository.findByJobId(jobId)
@ -116,7 +117,7 @@ public class JobRecommendationService {
@Transactional @Transactional
public RecommendationFeedbackResponse feedback(Long studentId, Long jobId, RecommendationFeedbackRequest req, AuditContext ctx) { public RecommendationFeedbackResponse feedback(Long studentId, Long jobId, RecommendationFeedbackRequest req, AuditContext ctx) {
requireStudent(studentId); jobService.getPublicJob(jobId); userRoleGuard.requireStudent(studentId); jobService.getPublicJob(jobId);
if (!FEEDBACK.contains(req.getFeedbackType()) || req.getIdempotencyKey().length() > 64) if (!FEEDBACK.contains(req.getFeedbackType()) || req.getIdempotencyKey().length() > 64)
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID); throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);
String key = studentId + ":" + jobId + ":" + req.getFeedbackType() + ":" + Optional.ofNullable(req.getReasonType()).orElse(""); String key = studentId + ":" + jobId + ":" + req.getFeedbackType() + ":" + Optional.ofNullable(req.getReasonType()).orElse("");
@ -137,7 +138,7 @@ public class JobRecommendationService {
@Transactional @Transactional
public void revokeNotInterested(Long studentId, Long jobId, Long expectedVersion, AuditContext ctx) { public void revokeNotInterested(Long studentId, Long jobId, Long expectedVersion, AuditContext ctx) {
requireStudent(studentId); userRoleGuard.requireStudent(studentId);
StudentJobRecommendationFeedback f = feedbackRepository.findByStudentUserIdAndJobIdAndFeedbackTypeAndStatus(studentId, jobId, "NOT_INTERESTED", StudentJobRecommendationFeedback.ACTIVE) StudentJobRecommendationFeedback f = feedbackRepository.findByStudentUserIdAndJobIdAndFeedbackTypeAndStatus(studentId, jobId, "NOT_INTERESTED", StudentJobRecommendationFeedback.ACTIVE)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_NOT_FOUND));
if (!Objects.equals(f.getVersion(), expectedVersion)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE); if (!Objects.equals(f.getVersion(), expectedVersion)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE);
@ -147,7 +148,7 @@ public class JobRecommendationService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<JobRecommendationItem> recommend(Long studentId, int page, int size) { public Page<JobRecommendationItem> recommend(Long studentId, int page, int size) {
requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now(); userRoleGuard.requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now();
Set<Long> applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId)); Set<Long> applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId));
Set<Long> hidden = feedbackRepository.findByStudentUserIdAndStatusAndFeedbackType(studentId, StudentJobRecommendationFeedback.ACTIVE, "NOT_INTERESTED") Set<Long> hidden = feedbackRepository.findByStudentUserIdAndStatusAndFeedbackType(studentId, StudentJobRecommendationFeedback.ACTIVE, "NOT_INTERESTED")
.stream().map(StudentJobRecommendationFeedback::getJobId).collect(Collectors.toSet()); .stream().map(StudentJobRecommendationFeedback::getJobId).collect(Collectors.toSet());
@ -206,8 +207,6 @@ public class JobRecommendationService {
private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);} private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);}
private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);} private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}
private void assertCompanyOwns(Long user,Long job){Company c=companyService.findCompanyByOwnerUserId(user);if(c==null)throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);jobService.assertOwnerOrThrow(c.getId(),job);} private void assertCompanyOwns(Long user,Long job){Company c=companyService.findCompanyByOwnerUserId(user);if(c==null)throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);jobService.assertOwnerOrThrow(c.getId(),job);}
private void requireStudent(Long id){User u=userRepository.findById(id).orElseThrow(()->new BusinessException(ErrorCode.USER_NOT_FOUND));if(!u.getRoles().contains(User.Role.STUDENT))throw new BusinessException(ErrorCode.FORBIDDEN);}
private void requireAdmin(Long id){User u=userRepository.findById(id).orElseThrow(()->new BusinessException(ErrorCode.USER_NOT_FOUND));if(!u.getRoles().contains(User.Role.ADMIN))throw new BusinessException(ErrorCode.FORBIDDEN);}
private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);} private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);}
private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();} private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();}
private Map<String,Integer> mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference<Map<String,Integer>>(){});}catch(Exception e){return Map.of();}} private Map<String,Integer> mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference<Map<String,Integer>>(){});}catch(Exception e){return Map.of();}}

29
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java

@ -32,6 +32,7 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class JobReviewService { public class JobReviewService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final JobReviewRepository reviewRepository; private final JobReviewRepository reviewRepository;
private final JobReviewDimensionScoreRepository dimensionScoreRepository; private final JobReviewDimensionScoreRepository dimensionScoreRepository;
private final ReviewModerationHistoryRepository moderationHistoryRepository; private final ReviewModerationHistoryRepository moderationHistoryRepository;
@ -53,14 +54,6 @@ public class JobReviewService {
CompanyReviewReply.AUTO_APPROVED, CompanyReviewReply.APPROVED); CompanyReviewReply.AUTO_APPROVED, CompanyReviewReply.APPROVED);
// ── 角色校验 ────────────────────────────────────────────── // ── 角色校验 ──────────────────────────────────────────────
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) throw new BusinessException(ErrorCode.FORBIDDEN);
}
private void assertJobExists(Long jobId) { private void assertJobExists(Long jobId) {
jobService.getJob(jobId); // throws JOB_NOT_FOUND jobService.getJob(jobId); // throws JOB_NOT_FOUND
} }
@ -91,7 +84,7 @@ public class JobReviewService {
@Transactional @Transactional
public Map<String, Object> createReview(Long studentUserId, Long jobId, JobReviewCreateRequest req, public Map<String, Object> createReview(Long studentUserId, Long jobId, JobReviewCreateRequest req,
AuditContext ctx) { AuditContext ctx) {
assertStudentRole(studentUserId); userRoleGuard.requireStudent(studentUserId);
assertJobExists(jobId); assertJobExists(jobId);
if (reviewRepository.existsByJobIdAndStudentUserId(jobId, studentUserId)) if (reviewRepository.existsByJobIdAndStudentUserId(jobId, studentUserId))
@ -187,7 +180,7 @@ public class JobReviewService {
@Transactional @Transactional
public Map<String, Object> updateReview(Long userId, Long jobId, Long reviewId, public Map<String, Object> updateReview(Long userId, Long jobId, Long reviewId,
JobReviewUpdateRequest req, AuditContext ctx) { JobReviewUpdateRequest req, AuditContext ctx) {
assertStudentRole(userId); userRoleGuard.requireStudent(userId);
JobReview review = findOwnReview(userId, jobId, reviewId); JobReview review = findOwnReview(userId, jobId, reviewId);
if (req.getRating() != null) { if (req.getRating() != null) {
@ -224,7 +217,7 @@ public class JobReviewService {
@Transactional @Transactional
public void deleteReview(Long userId, Long jobId, Long reviewId, AuditContext ctx) { public void deleteReview(Long userId, Long jobId, Long reviewId, AuditContext ctx) {
assertStudentRole(userId); userRoleGuard.requireStudent(userId);
findOwnReview(userId, jobId, reviewId); findOwnReview(userId, jobId, reviewId);
reviewRepository.deleteById(reviewId); // DB 外键 ON DELETE CASCADE 删除维度分 reviewRepository.deleteById(reviewId); // DB 外键 ON DELETE CASCADE 删除维度分
auditService.log(userId, "DELETE_REVIEW", "JOB_REVIEW", reviewId, auditService.log(userId, "DELETE_REVIEW", "JOB_REVIEW", reviewId,
@ -331,7 +324,7 @@ public class JobReviewService {
public Page<Map<String, Object>> listPendingReviews(Long adminUserId, Long jobId, String riskLevel, public Page<Map<String, Object>> listPendingReviews(Long adminUserId, Long jobId, String riskLevel,
LocalDateTime createdFrom, LocalDateTime createdTo, LocalDateTime createdFrom, LocalDateTime createdTo,
int page, int size) { int page, int size) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
assertValidPage(page, size); assertValidPage(page, size);
return reviewRepository.findPendingModeration(jobId, riskLevel, createdFrom, createdTo, return reviewRepository.findPendingModeration(jobId, riskLevel, createdFrom, createdTo,
PageRequest.of(page, size)) PageRequest.of(page, size))
@ -342,7 +335,7 @@ public class JobReviewService {
@Transactional @Transactional
public Map<String, Object> decideReview(Long adminUserId, Long reviewId, public Map<String, Object> decideReview(Long adminUserId, Long reviewId,
String decision, String reason, AuditContext ctx) { String decision, String reason, AuditContext ctx) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
JobReview review = reviewRepository.findById(reviewId) JobReview review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND));
if (!JobReview.MODERATION_PENDING.equals(review.getModerationStatus())) { if (!JobReview.MODERATION_PENDING.equals(review.getModerationStatus())) {
@ -371,19 +364,11 @@ public class JobReviewService {
/** 审核历史(分页),返回结果、审核人、时间与原因 */ /** 审核历史(分页),返回结果、审核人、时间与原因 */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<Map<String, Object>> listModerationHistory(Long adminUserId, int page, int size) { public Page<Map<String, Object>> listModerationHistory(Long adminUserId, int page, int size) {
assertAdminRole(adminUserId); userRoleGuard.requireAdmin(adminUserId);
assertValidPage(page, size); assertValidPage(page, size);
return moderationHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size)) return moderationHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size))
.map(this::toHistoryView); .map(this::toHistoryView);
} }
private void assertAdminRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isAdmin = user.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN);
if (!isAdmin) throw new BusinessException(ErrorCode.FORBIDDEN);
}
// ── 八维评分 ────────────────────────────────────────────── // ── 八维评分 ──────────────────────────────────────────────
/** 校验并归一化维度分:null/空 → 空;key 非法 → 400;value 非 1-5 或 null → 400 */ /** 校验并归一化维度分:null/空 → 空;key 非法 → 400;value 非 1-5 或 null → 400 */

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

@ -1,8 +1,6 @@
package com.mydreamjob.risk; package com.mydreamjob.risk;
import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse; import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
@ -47,13 +45,13 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminRiskController { public class AdminRiskController {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final RiskGovernanceService riskGovernanceService; private final RiskGovernanceService riskGovernanceService;
private final UserRepository userRepository;
@Operation(summary = "风险概览(ADMIN)") @Operation(summary = "风险概览(ADMIN)")
@ApiResponses({ @ApiResponses({
@ -180,15 +178,13 @@ public class AdminRiskController {
eventId, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request))); eventId, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request)));
} }
/**
* R01-B/B1:身份来自 {@link com.mydreamjob.security.CurrentUserAccessor},
* 角色授权委托给公共 {@link com.mydreamjob.security.UserRoleGuard}。
*/
private Long requireAdmin(HttpServletRequest request) { private Long requireAdmin(HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId(); Long userId = currentUserAccessor.requireUserId();
User admin = userRepository.findById(userId) userRoleGuard.requireAdmin(userId);
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isAdmin = admin.getRoles() != null
&& admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN);
if (!isAdmin) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return userId; return userId;
} }

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

@ -34,6 +34,8 @@ public class AdminReportController {
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
/** R01-B:角色授权统一委托给公共 guard,本类不再自行比较角色。 */
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final ReportService reportService; private final ReportService reportService;
private final UserRepository userRepository; private final UserRepository userRepository;
@ -101,14 +103,13 @@ public class AdminReportController {
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
} }
/**
* R01-B/B1:身份来自 {@link com.mydreamjob.security.CurrentUserAccessor},
* 角色授权委托给公共 {@link com.mydreamjob.security.UserRoleGuard}。
*/
private Long requireAdmin(HttpServletRequest request) { private Long requireAdmin(HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId(); Long userId = currentUserAccessor.requireUserId();
User admin = userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); userRoleGuard.requireAdmin(userId);
boolean isAdmin = admin.getRoles() != null
&& admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN);
if (!isAdmin) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return userId; return userId;
} }

142
mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java

@ -0,0 +1,142 @@
package com.mydreamjob.security;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 业务角色授权守卫(R01-B/B1)。
*
* <p>把散落在各 Service/Controller 中的"读取用户 → 判断角色 → 抛错"重复实现,
* 收束到唯一实现点。</p>
*
* <h2>职责边界</h2>
* <ul>
* <li><strong>只做角色授权</strong>,不做资源归属(ownership)校验 ——
* 归属仍由各业务 Service 负责(如 {@code assertOwnerOrThrow});</li>
* <li><strong>不解析 SecurityContext</strong>:"当前登录用户是谁"仍由
* {@link CurrentUserAccessor} 负责,本类只接收已解析出的 {@code userId};</li>
* <li><strong>不处理 requestId</strong>,不读请求头;</li>
* <li><strong>不提供任何"由调用方传入角色"的入口</strong>:角色只能来自
* {@link User.Role} 枚举常量,客户端输入无法影响判定;</li>
* <li><strong>不给 ADMIN 隐式超级权限</strong>:{@code requireStudent}/{@code requireCompany}
* 对 ADMIN 同样拒绝,除非调用方显式传入 {@code requireAny(..., ADMIN)};</li>
* <li>无副作用:只读用户表,不写库、不写审计、不记日志(尤其不记 userId / 手机号 / Token)。</li>
* </ul>
*
* <h2>角色来源</h2>
* <p>角色取自 {@code user.getRoles()},即 {@code user_roles} 关联表
* —— 与本类替换掉的全部既有 guard 完全一致。{@code users.role} 单值列用于 JWT 声明签发,
* <strong>不</strong>参与此处授权判定:把两者混用会改变既有语义。</p>
*
* <h2>失败关闭</h2>
* <ul>
* <li>用户不存在 → {@link ErrorCode#USER_NOT_FOUND}(沿用既有错误码);</li>
* <li>用户存在但 <strong>inactive</strong> → {@link ErrorCode#FORBIDDEN}
* (比既有实现更严格:被禁用的用户不再因"角色正确"而通过);</li>
* <li>{@code user_roles} 中缺少所需角色 → {@link ErrorCode#FORBIDDEN}
* (数据不一致时同样是拒绝,而不是放行)。</li>
* </ul>
*
* <h2>查询次数</h2>
* <p>{@code requireXxx(Long)} 每次调用恰好 <strong>1</strong> 次 {@code findById},
* 不做跨请求缓存(用户被注销/禁用后必须立即失效)。调用方若已加载
* {@link User},应使用 {@code requireXxx(User)} 重载以避免第二次查询。</p>
*/
@Component
@RequiredArgsConstructor
public class UserRoleGuard {
private final UserRepository userRepository;
// ── 按 userId 校验(每次 1 次查询)──────────────────────────
public void requireStudent(Long userId) {
requireRole(requireUser(userId), User.Role.STUDENT);
}
public void requireCompany(Long userId) {
requireRole(requireUser(userId), User.Role.COMPANY);
}
public void requireAdmin(Long userId) {
requireRole(requireUser(userId), User.Role.ADMIN);
}
/** 允许集合中的任意一个角色(集合由调用方以枚举常量给出,不接受客户端输入)。 */
public void requireAny(Long userId, User.Role... allowedRoles) {
requireRole(requireUser(userId), allowedRoles);
}
// ── 按已加载实体校验(0 次查询)────────────────────────────
public void requireStudent(User user) {
requireRole(user, User.Role.STUDENT);
}
public void requireCompany(User user) {
requireRole(user, User.Role.COMPANY);
}
public void requireAdmin(User user) {
requireRole(user, User.Role.ADMIN);
}
public void requireAny(User user, User.Role... allowedRoles) {
requireRole(user, allowedRoles);
}
// ── 基础能力 ───────────────────────────────────────────────
/**
* 加载用户并校验其可用(存在且 active)。
*
* @throws BusinessException {@link ErrorCode#USER_NOT_FOUND} 用户不存在;
* {@link ErrorCode#FORBIDDEN} 用户存在但已禁用
*/
public User requireUser(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (!isActive(user)) {
// 失败关闭:被禁用的用户不得因角色正确而通过授权。
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return user;
}
/**
* 校验已加载用户是否具备所需角色之一。
*
* <p>角色来源为 {@code user_roles};缺失任一所需角色即拒绝。</p>
*/
public void requireRole(User user, User.Role... allowedRoles) {
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
if (!isActive(user)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
if (allowedRoles == null || allowedRoles.length == 0) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
if (user.getRoles() == null) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
for (User.Role allowed : allowedRoles) {
if (allowed != null && user.getRoles().contains(allowed)) {
return;
}
}
throw new BusinessException(ErrorCode.FORBIDDEN);
}
private static boolean isActive(User user) {
return user.getActive() != null && user.getActive();
}
}

10
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java

@ -46,6 +46,7 @@ public class TalentDiscoveryService {
private static final int BATCH_SIZE = 500; private static final int BATCH_SIZE = 500;
private static final Pattern SAFE_SKILL = Pattern.compile("[\\p{L}\\p{N}+#./()()·\\-_ ]+"); private static final Pattern SAFE_SKILL = Pattern.compile("[\\p{L}\\p{N}+#./()()·\\-_ ]+");
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final StudentTalentDiscoveryPreferenceRepository preferenceRepository; private final StudentTalentDiscoveryPreferenceRepository preferenceRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
@ -58,7 +59,7 @@ public class TalentDiscoveryService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public TalentDiscoveryPreferenceResponse getPreference(Long userId) { public TalentDiscoveryPreferenceResponse getPreference(Long userId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
StudentTalentDiscoveryPreference value = preferenceRepository.findByStudentUserId(userId).orElse(null); StudentTalentDiscoveryPreference value = preferenceRepository.findByStudentUserId(userId).orElse(null);
return toPreference(value); return toPreference(value);
} }
@ -67,7 +68,7 @@ public class TalentDiscoveryService {
public TalentDiscoveryPreferenceResponse updatePreference(Long userId, public TalentDiscoveryPreferenceResponse updatePreference(Long userId,
TalentDiscoveryPreferenceUpdateRequest request, TalentDiscoveryPreferenceUpdateRequest request,
AuditContext context) { AuditContext context) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
if (request == null || request.getEnabled() == null) { if (request == null || request.getEnabled() == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
} }
@ -255,11 +256,6 @@ public class TalentDiscoveryService {
if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN); if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN);
return company; return company;
} }
private void requireStudent(Long userId) {
if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN);
}
private User requireUser(Long userId) { private User requireUser(Long userId) {
if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED);
return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

12
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java

@ -48,6 +48,7 @@ public class TalentInvitationService {
private static final int DAILY_JOB_LIMIT = 20; private static final int DAILY_JOB_LIMIT = 20;
private static final ObjectMapper JSON = new ObjectMapper(); private static final ObjectMapper JSON = new ObjectMapper();
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final TalentInvitationRepository invitationRepository; private final TalentInvitationRepository invitationRepository;
private final TalentInvitationHistoryRepository historyRepository; private final TalentInvitationHistoryRepository historyRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
@ -163,7 +164,7 @@ public class TalentInvitationService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public TalentInvitationPageResponse<StudentTalentInvitationResponse> listStudent(Long userId, int page, int size) { public TalentInvitationPageResponse<StudentTalentInvitationResponse> listStudent(Long userId, int page, int size) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
validatePage(page, size); validatePage(page, size);
Page<TalentInvitation> invitations = invitationRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( Page<TalentInvitation> invitations = invitationRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc(
userId, PageRequest.of(page, size)); userId, PageRequest.of(page, size));
@ -180,7 +181,7 @@ public class TalentInvitationService {
@Transactional @Transactional
public StudentTalentInvitationResponse getStudent(Long userId, Long invitationId, AuditContext context) { public StudentTalentInvitationResponse getStudent(Long userId, Long invitationId, AuditContext context) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
TalentInvitation invitation = invitationRepository.findByIdAndStudentUserId(invitationId, userId) TalentInvitation invitation = invitationRepository.findByIdAndStudentUserId(invitationId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND));
if (expireIfDue(invitation, context)) { if (expireIfDue(invitation, context)) {
@ -197,7 +198,7 @@ public class TalentInvitationService {
@Transactional @Transactional
public StudentTalentInvitationResponse decide(Long userId, Long invitationId, TalentInvitationDecisionRequest request, public StudentTalentInvitationResponse decide(Long userId, Long invitationId, TalentInvitationDecisionRequest request,
AuditContext context) { AuditContext context) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
if (request == null || request.getDecision() == null || request.getExpectedVersion() == null if (request == null || request.getDecision() == null || request.getExpectedVersion() == null
|| !("ACCEPT".equals(request.getDecision()) || "REJECT".equals(request.getDecision()))) { || !("ACCEPT".equals(request.getDecision()) || "REJECT".equals(request.getDecision()))) {
throw new BusinessException(ErrorCode.TALENT_INVITATION_CONTENT_INVALID); throw new BusinessException(ErrorCode.TALENT_INVITATION_CONTENT_INVALID);
@ -311,11 +312,6 @@ public class TalentInvitationService {
if (!company.getId().equals(job.getCompanyId())) throw new BusinessException(ErrorCode.FORBIDDEN); if (!company.getId().equals(job.getCompanyId())) throw new BusinessException(ErrorCode.FORBIDDEN);
return job; return job;
} }
private void requireStudent(Long userId) {
if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN);
}
private User requireUser(Long userId) { private User requireUser(Long userId) {
if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED);
return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

8
mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java

@ -13,6 +13,7 @@ import com.mydreamjob.verification.dto.VerificationDecisionRequest;
import com.mydreamjob.verification.dto.VerificationSubmitRequest; import com.mydreamjob.verification.dto.VerificationSubmitRequest;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.dao.OptimisticLockingFailureException;
@ -34,6 +35,7 @@ public class CompanyVerificationService {
private static final int MAX_PAGE_SIZE = 100; private static final int MAX_PAGE_SIZE = 100;
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final CompanyVerificationRepository verificationRepository; private final CompanyVerificationRepository verificationRepository;
private final CompanyVerificationHistoryRepository historyRepository; private final CompanyVerificationHistoryRepository historyRepository;
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
@ -244,11 +246,7 @@ public class CompanyVerificationService {
} }
public void assertAdminRole(Long userId) { public void assertAdminRole(Long userId) {
User user = userRepository.findById(userId) userRoleGuard.requireAdmin(userId);
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (!hasRole(user, User.Role.ADMIN)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} }
private void recordHistory(Long verificationId, String fromStatus, String toStatus, private void recordHistory(Long verificationId, String fromStatus, String toStatus,

8
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java

@ -8,6 +8,8 @@ import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -56,12 +58,18 @@ class AbilityAttemptServiceTest {
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private Clock clock; @Mock private Clock clock;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private AbilityAttemptService service; @InjectMocks private AbilityAttemptService service;
private final AuditContext ctx = AuditContext.builder().requestId("r").build(); private final AuditContext ctx = AuditContext.builder().requestId("r").build();
@BeforeEach @BeforeEach
void time() { void time() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
org.mockito.Mockito.lenient().when(clock.getZone()).thenReturn(ZoneId.systemDefault()); org.mockito.Mockito.lenient().when(clock.getZone()).thenReturn(ZoneId.systemDefault());
org.mockito.Mockito.lenient().when(clock.instant()).thenReturn(Instant.parse("2026-09-03T08:00:00Z")); org.mockito.Mockito.lenient().when(clock.instant()).thenReturn(Instant.parse("2026-09-03T08:00:00Z"));
} }

8
mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java

@ -7,6 +7,8 @@ import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -55,12 +57,18 @@ class AssessmentAttemptServiceTest {
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private AssessmentAttemptService service; @InjectMocks private AssessmentAttemptService service;
private final AuditContext ctx = AuditContext.builder().requestId("req").build(); private final AuditContext ctx = AuditContext.builder().requestId("req").build();
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(anyLong())).thenReturn(List.of()); when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(anyLong())).thenReturn(List.of());
when(answerRepository.findByAttemptId(anyLong())).thenReturn(List.of()); when(answerRepository.findByAttemptId(anyLong())).thenReturn(List.of());
} }

11
mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java

@ -2,11 +2,14 @@ package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
@ -24,11 +27,19 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class MatchRuleServiceTest { class MatchRuleServiceTest {
@BeforeEach
void wireRoleGuard() {
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
}
@Mock private CompanyMatchRuleRepository companyRuleRepository; @Mock private CompanyMatchRuleRepository companyRuleRepository;
@Mock private StudentMatchRuleRepository studentRuleRepository; @Mock private StudentMatchRuleRepository studentRuleRepository;
@Mock private CompanyRepository companyRepository; @Mock private CompanyRepository companyRepository;
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private MatchRuleService matchRuleService; @InjectMocks private MatchRuleService matchRuleService;
private static final Long COMPANY_USER_ID = 10L; private static final Long COMPANY_USER_ID = 10L;

8
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java

@ -4,6 +4,8 @@ import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.candidatescore.CandidateScoringCalculator;
@ -55,6 +57,9 @@ class CandidateScoringGovernanceServiceTest {
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private EntityManager entityManager; @Mock private EntityManager entityManager;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private CandidateScoringGovernanceService service; @InjectMocks private CandidateScoringGovernanceService service;
private static final Long STUDENT_ID = 2L; private static final Long STUDENT_ID = 2L;
@ -64,6 +69,9 @@ class CandidateScoringGovernanceServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
ReflectionTestUtils.setField(service, "entityManager", entityManager); ReflectionTestUtils.setField(service, "entityManager", entityManager);
Query nativeQ = mock(Query.class); Query nativeQ = mock(Query.class);
when(nativeQ.setParameter(anyInt(), any())).thenReturn(nativeQ); when(nativeQ.setParameter(anyInt(), any())).thenReturn(nativeQ);

5
mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java

@ -82,7 +82,10 @@ class CareerProgressServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator)); calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator));
service = new CareerProgressService(applicationRepository, interviewRepository, // R01-B/B1:角色授权已收束到 UserRoleGuard。此处装配**真实** guard 并复用本测试
// 已 stub 的 userRepository,使断言目标(角色不符 → FORBIDDEN)与收束前完全一致。
service = new CareerProgressService(new com.mydreamjob.security.UserRoleGuard(userRepository),
applicationRepository, interviewRepository,
interviewScoreRepository, offerRepository, notificationRepository, interviewScoreRepository, offerRepository, notificationRepository,
jobRepository, companyRepository, userRepository, calculator, scoringCalculator); jobRepository, companyRepository, userRepository, calculator, scoringCalculator);

2
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java

@ -19,7 +19,7 @@ class GlobalExceptionHandlerAccountDeletionTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor()); handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor(), new ErrorHttpStatusRegistry());
request = new MockHttpServletRequest(); request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/profile/me/deletion"); request.setRequestURI("/api/v1/profile/me/deletion");
} }

2
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java

@ -16,7 +16,7 @@ class GlobalExceptionHandlerHttpStatusTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor()); handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor(), new ErrorHttpStatusRegistry());
request = new MockHttpServletRequest(); request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/test"); request.setRequestURI("/api/v1/test");
} }

2
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java

@ -22,7 +22,7 @@ class GlobalExceptionHandlerReportBlockTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor()); handler = new GlobalExceptionHandler(new com.mydreamjob.common.logging.RequestIdAccessor(), new ErrorHttpStatusRegistry());
request = new MockHttpServletRequest(); request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/reports"); request.setRequestURI("/api/v1/reports");
} }

12
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

@ -9,7 +9,6 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import java.lang.reflect.Method;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@ -70,7 +69,7 @@ class GlobalExceptionHandlerStatusContractTest {
private static final Set<String> TOO_MANY_REQUESTS = Set.of( private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED"); "REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED");
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor()); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry());
@ParameterizedTest(name = "{0}") @ParameterizedTest(name = "{0}")
@EnumSource(ErrorCode.class) @EnumSource(ErrorCode.class)
@ -90,11 +89,10 @@ class GlobalExceptionHandlerStatusContractTest {
} }
@Test @Test
void unknownBusinessCodeKeepsBadRequestFallback() throws Exception { void unknownBusinessCodeKeepsBadRequestFallback() {
Method resolver = GlobalExceptionHandler.class.getDeclaredMethod("resolveHttpStatus", int.class); // R01-B/B2:解析入口由 GlobalExceptionHandler 的私有方法收束到 ErrorHttpStatusRegistry,
resolver.setAccessible(true); // 断言目标(未知码仍回退 400)保持不变,不再依赖反射。
assertEquals(HttpStatus.BAD_REQUEST, new ErrorHttpStatusRegistry().resolve(123456789));
assertEquals(HttpStatus.BAD_REQUEST, resolver.invoke(handler, 123456789));
} }
private HttpStatus expectedStatus(ErrorCode errorCode) { private HttpStatus expectedStatus(ErrorCode errorCode) {

156
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java

@ -0,0 +1,156 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.http.HttpStatus;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* R01-B/B2:集中式错误码 → HTTP 状态映射的契约测试。
*
* <p>覆盖任务书要求:单一映射、显式映射集中、不可变、重复键初始化即失败、
* 未映射回退 400、只保留既有语义。</p>
*/
class ErrorHttpStatusRegistryTest {
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
/** 允许出现在显式映射里的状态集合:重构前后都必须只有这四个。 */
private static final Set<HttpStatus> ALLOWED_EXPLICIT_STATUSES = Set.of(
HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT, HttpStatus.TOO_MANY_REQUESTS);
@Test
@DisplayName("B2:显式映射数量与重构前快照一致(112 个 ErrorCode 常量)")
void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(112);
// 112 个常量只覆盖 111 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111);
}
@Test
@DisplayName("B2:映射键是 ErrorCode 常量本身,不是数字码")
void mappingKeysAreErrorCodeConstants() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView().keySet())
.isSubsetOf(EnumSet.allOf(ErrorCode.class));
// 两个常量共用数字码 9103 时必须同时显式登记,否则说明键被错误地按数字去重
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.REVIEW_ALREADY_DECIDED)).isTrue();
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.DUPLICATE_FEEDBACK)).isTrue();
assertThat(ErrorCode.REVIEW_ALREADY_DECIDED.getCode())
.as("9103 为两个既有常量共用(潜在缺陷,本轮不修改 ErrorCode)")
.isEqualTo(ErrorCode.DUPLICATE_FEEDBACK.getCode());
// 二者状态一致,数字入口因此无歧义
assertThat(registry.resolve(ErrorCode.REVIEW_ALREADY_DECIDED))
.isEqualTo(registry.resolve(ErrorCode.DUPLICATE_FEEDBACK));
}
@Test
@DisplayName("B2:显式映射只使用四个既有状态,未引入新状态")
void explicitMappingsUseOnlyPreExistingStatuses() {
assertThat(new java.util.LinkedHashSet<>(ErrorHttpStatusRegistry.explicitMappingsView().values()))
.as("显式映射出现的状态集合")
.isSubsetOf(ALLOWED_EXPLICIT_STATUSES);
}
@ParameterizedTest(name = "{0}")
@EnumSource(ErrorCode.class)
@DisplayName("B2:显式映射逐项自洽;未映射码一律回退 400")
void resolveMatchesMappingTableForEveryErrorCode(ErrorCode errorCode) {
HttpStatus expected = ErrorHttpStatusRegistry.explicitMappingsView()
.getOrDefault(errorCode, HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(errorCode)).isEqualTo(expected);
assertThat(registry.resolve(errorCode.getCode())).as("数字入口与枚举入口一致").isEqualTo(expected);
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(errorCode))
.isEqualTo(ErrorHttpStatusRegistry.explicitMappingsView().containsKey(errorCode));
}
@Test
@DisplayName("B2:未映射错误码保持既有 400 回退(含 UNAUTHORIZED 与 SERVICE_UNAVAILABLE)")
void unmappedErrorCodesKeepBadRequestFallback() {
// 既有语义:这两个码在重构前就没有显式映射,因此是 400,不是 401/503。
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.BAD_REQUEST)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.INTERNAL_ERROR)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.TOKEN_EXPIRED)).isEqualTo(HttpStatus.BAD_REQUEST);
// 完全未知的数字码
assertThat(registry.resolve(0)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(-1)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(123456789)).isEqualTo(HttpStatus.BAD_REQUEST);
// null 不抛 NPE
assertThat(registry.resolve((ErrorCode) null)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.defaultStatus()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("B2:重复注册同一错误码(即使状态相同)必须在注册时失败")
void duplicateKeysAreRejectedAtRegistration() {
assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN)
.put(HttpStatus.NOT_FOUND, ErrorCode.FORBIDDEN))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("duplicate HTTP status mapping")
.hasMessageContaining(String.valueOf(ErrorCode.FORBIDDEN.getCode()));
// 同一状态下的重复登记同样是配置错误,不得静默容忍
assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.NOT_FOUND, ErrorCode.JOB_NOT_FOUND)
.put(HttpStatus.NOT_FOUND, ErrorCode.JOB_NOT_FOUND, ErrorCode.USER_NOT_FOUND))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("duplicate HTTP status mapping");
}
@Test
@DisplayName("B2:构建结果不可变,运行期无法增删改")
void builtMappingIsImmutable() {
Map<ErrorCode, HttpStatus> view = ErrorHttpStatusRegistry.explicitMappingsView();
assertThatThrownBy(() -> view.put(ErrorCode.FORBIDDEN, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(112);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(111);
}
@Test
@DisplayName("B2:不同 ErrorCode 常量允许各自登记(即使数字码相同且状态相同)")
void distinctConstantsWithSharedNumericCodeAreAllowed() {
Map<ErrorCode, HttpStatus> built = new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED)
.put(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_FEEDBACK)
.build();
assertThat(built).hasSize(2);
}
@Test
@DisplayName("B2:注册顺序不影响解析结果")
void registrationOrderDoesNotChangeResolution() {
Map<ErrorCode, HttpStatus> forward = new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN)
.put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND)
.build();
Map<ErrorCode, HttpStatus> reverse = new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND)
.put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN)
.build();
assertThat(forward).isEqualTo(reverse);
}
}

188
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java

@ -0,0 +1,188 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry;
import com.mydreamjob.common.exception.GlobalExceptionHandler;
import com.mydreamjob.common.logging.RequestIdAccessor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* R01-B/B2:重构前后 HTTP 映射**逐项一致**证明。
*
* <p>这里的四个状态桶是**重构前**从 {@code GlobalExceptionHandler.buildBusinessHttpStatuses()}
* 逐条抄录的独立快照(亦保存于 {@code docs/acceptance/r01-b/b2-mapping-baseline.json}),
* 不是从重构后的实现反推。任何一个错误码的状态发生漂移,本测试都会失败。</p>
*/
class GlobalExceptionHandlerMappingParityTest {
private static final Set<String> FORBIDDEN = Set.of(
"APPLICATION_OWNER_MISMATCH", "FORBIDDEN", "INTERVIEW_ACCESS_DENIED",
"MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER");
private static final Set<String> NOT_FOUND = Set.of(
"ABILITY_ATTEMPT_NOT_FOUND", "ABILITY_RESULT_NOT_FOUND", "ABILITY_TYPE_NOT_FOUND",
"APPEAL_NOT_FOUND", "APPLICATION_NOT_FOUND", "ASSESSMENT_ATTEMPT_NOT_FOUND",
"ASSESSMENT_TYPE_NOT_FOUND", "COMPANY_NOT_FOUND", "CONVERSATION_NOT_FOUND",
"DATA_EXPORT_TASK_NOT_FOUND", "FEEDBACK_NOT_FOUND", "INTERVIEW_SCORE_NOT_FOUND",
"JOB_NOT_FOUND", "MESSAGE_NOT_FOUND", "OFFER_COUNTER_OFFER_NOT_FOUND",
"OFFER_NOT_FOUND", "PROFILE_NOT_FOUND", "REPORT_NOT_FOUND",
"RESUME_EXPORT_TASK_NOT_FOUND", "RESUME_VERSION_NOT_FOUND", "REVIEW_NOT_FOUND",
"REVIEW_REPLY_NOT_FOUND", "RISK_EVENT_NOT_FOUND", "RISK_RULE_NOT_FOUND",
"TALENT_INVITATION_NOT_FOUND", "USER_NOT_FOUND", "VERIFICATION_NOT_FOUND");
private static final Set<String> CONFLICT = Set.of(
"ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE",
"ABILITY_DUPLICATE_ACTIVE", "ABILITY_IDEMPOTENCY_CONFLICT",
"ABILITY_RETAKE_COOLDOWN", "ACCOUNT_DELETED",
"ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED", "ACCOUNT_DELETION_CONCURRENT_UPDATE",
"ACCOUNT_DELETION_PENDING", "APPEAL_ALREADY_DECIDED", "APPEAL_DUPLICATE",
"APPEAL_IDEMPOTENCY_CONFLICT", "APPEAL_INVALID_TRANSITION",
"APPEAL_ROLLBACK_LIMIT_EXCEEDED", "APPLICATION_DUPLICATE",
"APPLICATION_NOT_MATCHED", "ASSESSMENT_ATTEMPT_INVALID_STATE",
"ASSESSMENT_CONCURRENT_UPDATE", "ASSESSMENT_DUPLICATE_ACTIVE",
"ASSESSMENT_IDEMPOTENCY_CONFLICT", "COMPANY_ALREADY_EXISTS",
"DATA_EXPORT_DUPLICATE", "DATA_EXPORT_EXPIRED", "DATA_EXPORT_FAILED",
"DATA_EXPORT_FILE_NOT_FOUND", "DATA_EXPORT_NOT_READY",
"DEFAULT_RESUME_DELETE_NOT_ALLOWED", "DUPLICATE_DEFAULT_RESUME",
"DUPLICATE_FEEDBACK", "DUPLICATE_OFFER", "DUPLICATE_REVIEW",
"INTERVIEW_ALREADY_RESPONDED", "INTERVIEW_COMPLETE_NOT_READY",
"INTERVIEW_NOT_COMPLETED", "INTERVIEW_SCORE_CONCURRENT_UPDATE",
"INTERVIEW_SCORE_SUBMITTED", "INVALID_APPLICATION_STATUS_TRANSITION",
"INVALID_DELETION_STATE", "INVALID_INTERVIEW_STATUS_TRANSITION",
"INVALID_JOB_STATUS_TRANSITION", "INVALID_OFFER_STATUS_TRANSITION",
"INVALID_VERIFICATION_STATUS_TRANSITION", "LAST_ADMIN_CANNOT_DELETE",
"MATCH_CLOSED", "MATCH_SCORES_LOCKED", "MESSAGE_RECALL_CONCURRENT_UPDATE",
"MESSAGE_RECALL_WINDOW_EXPIRED", "OFFER_COUNTER_OFFER_EXISTS",
"OFFER_COUNTER_OFFER_EXPIRED", "OFFER_COUNTER_OFFER_NOT_PENDING", "OFFER_EXPIRED",
"OFFER_NOT_EDITABLE", "OFFER_REMINDER_DUPLICATE",
"PRIVACY_SETTINGS_CONCURRENT_UPDATE", "REPORT_ALREADY_DECIDED", "REPORT_DUPLICATE",
"REPORT_IDEMPOTENCY_CONFLICT", "REPORT_INVALID_TRANSITION",
"RESUME_EXPORT_IN_PROGRESS", "RESUME_EXPORT_NOT_READY", "REVIEW_ALREADY_DECIDED",
"REVIEW_REPLY_ALREADY_EXISTS", "REVIEW_REPLY_CONCURRENT_UPDATE",
"REVIEW_REPLY_IDEMPOTENCY_CONFLICT", "REVIEW_REPLY_INVALID_STATE",
"REVIEW_WEIGHT_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE",
"RISK_INVALID_ACTION", "RISK_RULE_CONCURRENT_UPDATE",
"TALENT_INVITATION_ALREADY_ACTIVE", "TALENT_INVITATION_CANDIDATE_UNAVAILABLE",
"TALENT_INVITATION_CONCURRENT_UPDATE", "TALENT_INVITATION_EXPIRED",
"TALENT_INVITATION_IDEMPOTENCY_CONFLICT", "TALENT_INVITATION_INVALID_STATE",
"VERIFICATION_ALREADY_APPROVED", "VERIFICATION_ALREADY_PENDING");
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED");
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler =
new GlobalExceptionHandler(new RequestIdAccessor(), registry);
private static HttpStatus baselineStatus(ErrorCode errorCode) {
String name = errorCode.name();
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
return HttpStatus.BAD_REQUEST;
}
@Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(112);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(111);
for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode);
if (expected == HttpStatus.BAD_REQUEST) {
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(errorCode))
.as("%s 在快照中未显式映射,重构后也不得显式映射", errorCode.name())
.isFalse();
} else {
assertThat(registry.resolve(errorCode))
.as("%s 的显式映射状态", errorCode.name())
.isEqualTo(expected);
}
}
}
@ParameterizedTest(name = "{0}")
@EnumSource(ErrorCode.class)
@DisplayName("B2:处理器实际返回的状态与其 registry 解析结果、以及快照三者一致")
void handlerStatusMatchesRegistryAndSnapshotForEveryErrorCode(ErrorCode errorCode) {
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(errorCode), request());
assertThat(response.getStatusCode()).isEqualTo(baselineStatus(errorCode));
assertThat(response.getStatusCode()).isEqualTo(registry.resolve(errorCode));
}
@Test
@DisplayName("B2:FORBIDDEN 保持业务码 2002 与 HTTP 403")
void forbiddenKeepsBusinessCodeAndStatus() {
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(ErrorCode.FORBIDDEN), request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getCode()).isEqualTo(2002);
}
@Test
@DisplayName("B2:UNAUTHORIZED(2001) 保持既有语义(回退 400,不擅自改为 401)")
void unauthorizedKeepsLegacySemantics() {
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(ErrorCode.UNAUTHORIZED), request());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getCode()).isEqualTo(2001);
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("B2:服务不可用类错误码保持当前状态(400)")
void serviceUnavailableKeepsCurrentStatus() {
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("B2:响应体不包含异常类名、堆栈或内部路径")
void responseBodyCarriesNoInternalDetail() {
for (ErrorCode code : new ErrorCode[]{
ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE,
ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE}) {
MockHttpServletRequest request = request();
request.setRequestURI("/admin/scoring/fairness/overview");
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(code), request);
assertThat(response.getBody()).isNotNull();
String text = response.getBody().toString();
assertThat(text)
.as("%s 响应体", code.name())
.doesNotContain("Exception")
.doesNotContain("at com.mydreamjob")
.doesNotContain("GlobalExceptionHandler")
.doesNotContain("/admin/scoring/fairness/overview");
assertThat(response.getBody().getRequestId()).isEqualTo("r01b-parity");
}
}
private static MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/parity");
request.setAttribute("requestId", "r01b-parity");
return request;
}
}

257
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java

@ -0,0 +1,257 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry;
import com.mydreamjob.common.exception.GlobalExceptionHandler;
import com.mydreamjob.common.logging.RequestIdAccessor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* R01-B 独立复验:HTTP 映射与 **HEAD 基线**逐项对账。
*
* <p><strong>期望值来源</strong>:{@code src/test/resources/r01b/error-code-http-status-baseline.tsv},
* 该文件由独立复验者从 {@code git show HEAD:…GlobalExceptionHandler.java} 的
* {@code buildBusinessHttpStatuses()} 提取生成,<em>不</em>复用工作区实现的任何映射表,
* 因此不构成同源假证明。</p>
*/
class R01BIndependentHttpMappingParityTest {
private static final String BASELINE = "/r01b/error-code-http-status-baseline.tsv";
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler =
new GlobalExceptionHandler(new RequestIdAccessor(), registry);
/** 从 HEAD 基线资源读取:ErrorCode 名称 → 数字码 → HEAD 状态。 */
private static Map<String, int[]> baseline() {
// 用有序结构保存 name -> {code, statusOrdinal}
Map<String, int[]> rows = new LinkedHashMap<>();
try (InputStream in = R01BIndependentHttpMappingParityTest.class.getResourceAsStream(BASELINE)) {
assertThat(in).as("基线资源必须存在: %s", BASELINE).isNotNull();
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
if (line.isBlank() || line.startsWith("#")) continue;
String[] parts = line.split("\t");
rows.put(parts[0], new int[]{Integer.parseInt(parts[1]), statusOf(parts[2]).value()});
}
}
} catch (Exception e) {
throw new IllegalStateException("failed to read baseline", e);
}
return rows;
}
private static HttpStatus statusOf(String token) {
return switch (token) {
case "FORBIDDEN" -> HttpStatus.FORBIDDEN;
case "NOT_FOUND" -> HttpStatus.NOT_FOUND;
case "CONFLICT" -> HttpStatus.CONFLICT;
case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS;
default -> HttpStatus.BAD_REQUEST;
};
}
// ── 166 项逐项对账 ────────────────────────────────────────
@Test
@DisplayName("独立复验:166 个枚举常量按名称逐项与 HEAD 基线对齐(不按数字去重)")
void everyEnumConstantMatchesHeadBaselineByName() {
Map<String, int[]> base = baseline();
// 166 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 165 条
assertThat(base).hasSize(166);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(166);
int checked = 0;
for (ErrorCode code : ErrorCode.values()) {
int[] expected = base.get(code.name());
assertThat(expected).as("基线必须覆盖 %s", code.name()).isNotNull();
assertThat(code.getCode()).as("%s 的数字码不得改变", code.name()).isEqualTo(expected[0]);
assertThat(registry.resolve(code).value())
.as("%s 的 HTTP 状态必须与 HEAD 一致", code.name())
.isEqualTo(expected[1]);
checked++;
}
assertThat(checked).isEqualTo(166);
}
@Test
@DisplayName("独立复验:112 个显式映射 + 54 个默认 400")
void explicitAndDefaultCountsMatchBaseline() {
Map<String, int[]> base = baseline();
long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count();
assertThat(explicitInBaseline).isEqualTo(112);
assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(112);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 112 个常量仅覆盖 111 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111);
}
@Test
@DisplayName("独立复验:关键既有语义逐项保持")
void keySemanticsPreserved() {
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(registry.resolve(ErrorCode.APPLICATION_DUPLICATE)).isEqualTo(HttpStatus.CONFLICT);
assertThat(registry.resolve(ErrorCode.ABILITY_RETAKE_LIMIT)).isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
}
// ── 9103 重复码 ───────────────────────────────────────────
@Test
@DisplayName("独立复验:9103 的两个枚举常量都显式注册,且分别解析为 409")
void bothSharedNumericCodeConstantsAreExplicitlyMappedTo409() {
assertThat(ErrorCode.REVIEW_ALREADY_DECIDED.getCode()).isEqualTo(9103);
assertThat(ErrorCode.DUPLICATE_FEEDBACK.getCode()).isEqualTo(9103);
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.REVIEW_ALREADY_DECIDED)).isTrue();
assertThat(ErrorHttpStatusRegistry.isExplicitlyMapped(ErrorCode.DUPLICATE_FEEDBACK)).isTrue();
assertThat(registry.resolve(ErrorCode.REVIEW_ALREADY_DECIDED)).isEqualTo(HttpStatus.CONFLICT);
assertThat(registry.resolve(ErrorCode.DUPLICATE_FEEDBACK)).isEqualTo(HttpStatus.CONFLICT);
}
@Test
@DisplayName("独立复验:运行时以数字码解析,两个 9103 常量不可区分(如实记录,不夸大)")
void runtimeCannotDistinguishTheTwoSharedConstants() {
// BusinessException 只保存 int,枚举身份在运行时丢失
BusinessException a = new BusinessException(ErrorCode.REVIEW_ALREADY_DECIDED);
BusinessException b = new BusinessException(ErrorCode.DUPLICATE_FEEDBACK);
assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode()).isEqualTo(9103);
// 数字入口对两者给出同一结果:当前两者同为 409,故 HTTP 行为一致
assertThat(registry.resolve(9103)).isEqualTo(HttpStatus.CONFLICT);
assertThat(a.getErrorCode()).isEqualTo(b.getErrorCode());
// Handler 也只拿到数字码,响应中的 code 相同
ResponseEntity<ApiResponse<Void>> ra = handler.handleBusinessException(a, request());
ResponseEntity<ApiResponse<Void>> rb = handler.handleBusinessException(b, request());
assertThat(ra.getStatusCode()).isEqualTo(rb.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(ra.getBody().getCode()).isEqualTo(rb.getBody().getCode()).isEqualTo(9103);
}
@Test
@DisplayName("独立复验:同数字码被映射到不同状态时初始化必须失败,不得静默任选")
void conflictingStatusForSharedNumericCodeFailsFast() throws Exception {
// 通过公开 Builder 构造"同数字、不同状态"的映射
Map<ErrorCode, HttpStatus> conflicting = new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED)
.put(HttpStatus.NOT_FOUND, ErrorCode.DUPLICATE_FEEDBACK)
.build();
assertThat(conflicting).hasSize(2);
// 数字索引构建(真实注册表在静态初始化时走同一条路径)必须拒绝该冲突
Method indexByCode = ErrorHttpStatusRegistry.class
.getDeclaredMethod("indexByCode", Map.class);
indexByCode.setAccessible(true);
assertThatThrownBy(() -> {
try {
indexByCode.invoke(null, conflicting);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
})
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("conflicting HTTP status mapping")
.hasMessageContaining("9103");
// 反证:状态相同时允许,且数字入口唯一
Map<ErrorCode, HttpStatus> same = new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED)
.put(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_FEEDBACK)
.build();
assertThat(same).hasSize(2);
}
@Test
@DisplayName("独立复验:重复注册同一枚举常量(即使状态相同)初始化即失败")
void duplicateEnumKeyFailsAtRegistration() {
assertThatThrownBy(() -> new ErrorHttpStatusRegistry.Builder()
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED)
.put(HttpStatus.CONFLICT, ErrorCode.REVIEW_ALREADY_DECIDED))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("duplicate HTTP status mapping");
}
@Test
@DisplayName("独立复验:映射不依赖枚举声明顺序")
void mappingDoesNotDependOnEnumDeclarationOrder() {
// 取一个映射区段,正序与逆序注册结果必须相同
List<ErrorCode> notFound = List.of(
ErrorCode.USER_NOT_FOUND, ErrorCode.JOB_NOT_FOUND, ErrorCode.COMPANY_NOT_FOUND);
ErrorHttpStatusRegistry.Builder asc = new ErrorHttpStatusRegistry.Builder();
for (ErrorCode c : notFound) asc.put(HttpStatus.NOT_FOUND, c);
ErrorHttpStatusRegistry.Builder desc = new ErrorHttpStatusRegistry.Builder();
for (int i = notFound.size() - 1; i >= 0; i--) desc.put(HttpStatus.NOT_FOUND, notFound.get(i));
assertThat(asc.build()).isEqualTo(desc.build());
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
Map<String, int[]> base = baseline();
for (ErrorCode c : ErrorCode.values()) {
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]);
}
}
@Test
@DisplayName("独立复验:未知数字码仍为 400,且处理器响应不含堆栈或内部类名")
void unknownCodeFallsBackTo400AndResponseIsClean() {
assertThat(registry.resolve(0)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(-1)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(123456789)).isEqualTo(HttpStatus.BAD_REQUEST);
for (ErrorCode code : new ErrorCode[]{
ErrorCode.FORBIDDEN, ErrorCode.USER_NOT_FOUND, ErrorCode.APPLICATION_DUPLICATE,
ErrorCode.UNAUTHORIZED, ErrorCode.SERVICE_UNAVAILABLE, ErrorCode.DUPLICATE_FEEDBACK}) {
MockHttpServletRequest request = request();
request.setRequestURI("/r01b/independent/parity");
ResponseEntity<ApiResponse<Void>> response =
handler.handleBusinessException(new BusinessException(code), request);
assertThat(response.getBody()).isNotNull();
String text = response.getBody().toString();
assertThat(text)
.as("%s 响应体", code.name())
.doesNotContain("Exception")
.doesNotContain("at com.mydreamjob")
.doesNotContain("GlobalExceptionHandler")
.doesNotContain("ErrorHttpStatusRegistry")
.doesNotContain("/r01b/independent/parity");
assertThat(response.getBody().getRequestId()).isEqualTo("r01b-independent");
}
}
private static MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/r01b/independent");
request.setAttribute("requestId", "r01b-independent");
return request;
}
}

299
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java

@ -0,0 +1,299 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.support.TestAuthTokens;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.security.UserRoleGuard;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* R01-B 独立复验:角色授权语义、查询次数、边界与真实 HTTP 分层。
*
* <p>夹具使用**独占用户 ID / 手机号**,避开 {@code V1_1} 种子账号占用的 1/2/3
* (被 {@code V43} 置为 inactive,会使 Token 在 Filter 阶段即被拒)。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class R01BIndependentRoleVerificationTest {
private static final long USER_ID = 7_710_001L;
private static final long STUDENT_ID = 9_330_001L;
private static final long COMPANY_ID = 9_330_002L;
private static final long ADMIN_ID = 9_330_003L;
private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview";
private static final String STUDENT_ENDPOINT = "/profile/me/career-progress";
private static final String COMPANY_ENDPOINT = "/company/candidates";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
// ── 单元部分 ──────────────────────────────────────────────
private UserRepository userRepository;
private UserRoleGuard guard;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
guard = new UserRoleGuard(userRepository);
SecurityContextHolder.clearContext();
cleanupHttpFixtures();
insertUserWithRoles(STUDENT_ID, "r01biv-student", "STUDENT", "STUDENT");
insertUserWithRoles(COMPANY_ID, "r01biv-company", "COMPANY", "COMPANY");
insertUserWithRoles(ADMIN_ID, "r01biv-admin", "ADMIN", "ADMIN");
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
cleanupHttpFixtures();
}
private void cleanupHttpFixtures() {
for (long id : new long[]{STUDENT_ID, COMPANY_ID, ADMIN_ID}) {
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
private void insertUserWithRoles(long id, String phone, String primaryRole, String... roles) {
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01biv-" + primaryRole, primaryRole);
for (String r : roles) {
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, r);
}
}
private User user(User.Role primary, Set<User.Role> roles, Boolean active) {
return User.builder().id(USER_ID).phone("r01biv-unit").role(primary)
.roles(roles).active(active).build();
}
// ── 1. 正确角色通过 ───────────────────────────────────────
@Test
@DisplayName("独立复验:active 的 STUDENT / COMPANY / ADMIN 分别通过对应 guard")
void activeUsersPassTheirOwnGuard() {
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true)));
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException();
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true)));
assertThatCode(() -> guard.requireAdmin(USER_ID)).doesNotThrowAnyException();
}
// ── 2~3. 交叉角色与 ADMIN 无超级权限 ──────────────────────
@Test
@DisplayName("独立复验:三类交叉角色全部拒绝,ADMIN 不自动通过 STUDENT/COMPANY")
void crossRolesAreAllRejected() {
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)));
assertForbidden(() -> guard.requireCompany(USER_ID));
assertForbidden(() -> guard.requireAdmin(USER_ID));
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true)));
assertForbidden(() -> guard.requireStudent(USER_ID));
assertForbidden(() -> guard.requireAdmin(USER_ID));
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true)));
assertForbidden(() -> guard.requireStudent(USER_ID));
assertForbidden(() -> guard.requireCompany(USER_ID));
}
// ── 4~6. 不存在 / inactive / 角色为空 ─────────────────────
@Test
@DisplayName("独立复验:用户不存在 → USER_NOT_FOUND;不存在与 inactive 的错误码不同")
void missingUserThrowsUserNotFound() {
when(userRepository.findById(USER_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> guard.requireStudent(USER_ID))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.USER_NOT_FOUND.getCode());
}
@Test
@DisplayName("独立复验:inactive 用户即使角色正确也拒绝,错误码为 FORBIDDEN")
void inactiveUserIsForbiddenEvenWithCorrectRole() {
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), false)));
assertThatThrownBy(() -> guard.requireStudent(USER_ID))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
// active 为 null 同样失败关闭
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), null)));
assertForbidden(() -> guard.requireStudent(USER_ID));
}
@Test
@DisplayName("独立复验:user_roles 为空时所有 guard 一律拒绝")
void emptyRolesAreRejected() {
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(), true)));
assertForbidden(() -> guard.requireStudent(USER_ID));
assertForbidden(() -> guard.requireCompany(USER_ID));
assertForbidden(() -> guard.requireAdmin(USER_ID));
}
// ── 7~8. 多角色与主角色不一致 ─────────────────────────────
@Test
@DisplayName("独立复验:多角色用户按 user_roles 实际集合判定;users.role 不参与")
void multiRoleAndPrimaryRoleMismatch() {
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT,
Set.of(User.Role.STUDENT, User.Role.COMPANY), true)));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException();
assertForbidden(() -> guard.requireAdmin(USER_ID));
// users.role 声称 COMPANY,但 user_roles 只有 STUDENT → company guard 拒绝(失败关闭)
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.COMPANY, Set.of(User.Role.STUDENT), true)));
assertForbidden(() -> guard.requireCompany(USER_ID));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
}
// ── 9~12. 查询次数、无副作用、不越界 ──────────────────────
@Test
@DisplayName("独立复验:requireXxx(Long) 恰好一次查询;requireXxx(User) 零查询")
void queryCountIsExactlyOneOrZero() {
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)));
guard.requireStudent(USER_ID);
verify(userRepository, times(1)).findById(USER_ID);
User loaded = user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true);
guard.requireStudent(loaded);
guard.requireAny(loaded, User.Role.STUDENT, User.Role.COMPANY);
verify(userRepository, times(1)).findById(anyLong());
verifyNoMoreInteractions(userRepository);
}
@Test
@DisplayName("独立复验:guard 无写库、不触碰 SecurityContext / requestId / Redis")
void guardHasNoSideEffectsAndNoOutOfScopeDependencies() {
// 结构断言:唯一依赖是 UserRepository(无 RequestIdAccessor / HttpServletRequest / Redis)
assertThat(UserRoleGuard.class.getDeclaredFields())
.extracting(f -> f.getType().getName())
.containsExactly(UserRepository.class.getName());
// 行为断言:SecurityContext 里放一个"别的用户",guard 仍按传入 userId 判定
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(String.valueOf(ADMIN_ID), null,
AuthorityUtils.createAuthorityList("ROLE_ADMIN")));
// 若 guard 读了 SecurityContext(其中是 ADMIN),requireStudent(USER_ID) 会因角色不符而抛错;
// 实际不抛错 → 判定完全来自传入的 userId,与 SecurityContext 无关。
when(userRepository.findById(USER_ID))
.thenReturn(Optional.of(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true)));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
verify(userRepository, never()).save(any());
verify(userRepository, never()).deleteById(anyLong());
verify(userRepository, never()).delete(any());
}
// ── 13. 真实 HTTP 端点 401/403/404 ────────────────────────
@Test
@DisplayName("独立复验:三个真实端点无 Token 一律 401")
void httpNoTokenIsUnauthorized() throws Exception {
for (String path : new String[]{ADMIN_ENDPOINT, STUDENT_ENDPOINT, COMPANY_ENDPOINT}) {
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
}
}
@Test
@DisplayName("独立复验:角色不符一律 403(含 ADMIN 访问 STUDENT/COMPANY 端点)")
void httpWrongRoleIsForbidden() throws Exception {
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT")))
.andExpect(status().isForbidden());
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", TestAuthTokens.bearer(COMPANY_ID, "COMPANY")))
.andExpect(status().isForbidden());
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN")))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("独立复验:角色正确时放行;已授权的 ADMIN 访问不存在资源得 404 而非 403")
void httpCorrectRolePassesAnd404IsPreserved() throws Exception {
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN")))
.andExpect(status().isOk());
int studentStatus = mockMvc.perform(get(STUDENT_ENDPOINT)
.header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT")))
.andReturn().getResponse().getStatus();
assertThat(studentStatus).isNotIn(401, 403);
// 404 不被角色层改写成 403
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.post("/admin/scoring/risk/events/{eventId}/actions", 999_999_999L)
.header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN"))
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\","
+ "\"expectedVersion\":0}"))
.andExpect(status().isNotFound());
}
private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable callable) {
assertThatThrownBy(callable)
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
}
}

146
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java

@ -0,0 +1,146 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.auth.support.TestAuthTokens;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.risk.RiskGovernanceService;
import com.mydreamjob.security.UserRoleGuard;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* R01-B 独立复验:**接线变异检测**。
*
* <p>用 {@code @MockBean} 替换公共 guard,使"角色决策"完全由测试掌控。
* 由于端点自身的角色判定已被收束到该 guard,两个方向都可被观测:</p>
* <ul>
* <li>guard 拒绝 → 端点 403 且**下游业务方法零调用**;</li>
* <li>guard 放行 → 即使调用方是 STUDENT,ADMIN 端点也返回 200。</li>
* </ul>
* <p>第二条是关键的变异证明:若端点仍保留自有的角色比较(未委托 guard),
* STUDENT 无论 guard 怎么桩都不可能通过 ADMIN 端点,本测试必然失败。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class R01BWiringMutationTest {
private static final long STUDENT_ID = 9_340_001L;
private static final long ADMIN_ID = 9_340_002L;
private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview";
private static final String COMPANY_CANDIDATE_ENDPOINT = "/company/candidates/999999999";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private UserRoleGuard userRoleGuard;
@MockBean private RiskGovernanceService riskGovernanceService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void provision() {
cleanup();
insertUser(STUDENT_ID, "r01bwm-student", "STUDENT");
insertUser(ADMIN_ID, "r01bwm-admin", "ADMIN");
}
@AfterEach
void cleanup() {
for (long id : new long[]{STUDENT_ID, ADMIN_ID}) {
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
private void insertUser(long id, String phone, String role) {
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01bwm-" + role, role);
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role);
}
// ── guard 拒绝 → 业务不继续 ───────────────────────────────
@Test
@DisplayName("变异检测:guard 抛 FORBIDDEN 时端点 403,且下游业务方法零调用")
void guardDenyStopsBusinessFlow() throws Exception {
doThrow(new BusinessException(ErrorCode.FORBIDDEN))
.when(userRoleGuard).requireAdmin(anyLong());
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN")))
.andExpect(status().isForbidden());
verify(riskGovernanceService, never()).overview(any(), any());
verify(userRoleGuard).requireAdmin(ADMIN_ID);
}
// ── guard 放行 → 业务继续;且证明端点不自带角色比较 ────────
@Test
@DisplayName("变异检测:guard 放行时业务继续;STUDENT 亦可通过 ADMIN 端点(证明判定只来自 guard)")
void guardAllowLetsFlowContinueEvenForStudent() throws Exception {
// guard 为 mock,void 方法默认放行
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT")))
.andExpect(status().isOk());
verify(riskGovernanceService).overview(any(), any());
// 端点把**真实调用方身份**交给 guard;若端点自带角色比较,STUDENT 不可能走到这里
verify(userRoleGuard).requireAdmin(STUDENT_ID);
}
@Test
@DisplayName("变异检测:guard 收到的 userId 与 Token 主体一致(无身份替换)")
void guardReceivesCallerIdentity() throws Exception {
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN")))
.andExpect(status().isOk());
verify(userRoleGuard).requireAdmin(ADMIN_ID);
verify(userRoleGuard, never()).requireAdmin(STUDENT_ID);
}
// ── 资源归属仍由业务层负责,且错误码未被角色层吞掉 ─────────
@Test
@DisplayName("变异检测:guard 放行后,归属/存在性失败仍由业务层返回原错误码(404 而非 403)")
void ownershipFailureStillComesFromBusinessLayer() throws Exception {
// 该 STUDENT 用户没有企业实体;guard(mock)放行后由业务层给出 404/COMPANY_NOT_FOUND 语义
mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT)
.header("Authorization", TestAuthTokens.bearer(STUDENT_ID, "STUDENT")))
.andExpect(status().isNotFound());
// 该端点路径上 Company guard 会被调用多次(Controller 直调 + 下游 Service 委托),
// 故只断言"至少一次且身份正确",不臆造次数。
verify(userRoleGuard, org.mockito.Mockito.atLeastOnce()).requireCompany(STUDENT_ID);
}
@Test
@DisplayName("变异检测:guard 在 COMPANY 端点上同样按调用方身份被调用")
void companyEndpointAlsoDelegatesToGuard() throws Exception {
doThrow(new BusinessException(ErrorCode.FORBIDDEN))
.when(userRoleGuard).requireCompany(anyLong());
mockMvc.perform(get(COMPANY_CANDIDATE_ENDPOINT)
.header("Authorization", TestAuthTokens.bearer(ADMIN_ID, "ADMIN")))
.andExpect(status().isForbidden());
verify(userRoleGuard).requireCompany(ADMIN_ID);
}
}

254
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationGuardTest.java

@ -0,0 +1,254 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry;
import com.mydreamjob.security.UserRoleGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* R01-B/B1:公共角色授权守卫的行为契约。
*
* <p>角色来源为 {@code user_roles}({@code user.getRoles()}),与迁移前的
* 全部重复 guard 完全一致;{@code users.role} 单值列不参与此处判定。</p>
*/
class RoleAuthorizationGuardTest {
private static final long USER_ID = 7_700_001L;
private UserRepository userRepository;
private UserRoleGuard guard;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
guard = new UserRoleGuard(userRepository);
SecurityContextHolder.clearContext();
}
private void givenUser(User user) {
when(userRepository.findById(USER_ID)).thenReturn(Optional.ofNullable(user));
}
private static User user(User.Role primaryRole, Set<User.Role> roles, boolean active) {
return User.builder()
.id(USER_ID)
.phone("r01b-fixture")
.role(primaryRole)
.roles(roles)
.active(active)
.build();
}
// ── 1~3. 正确角色通过 ─────────────────────────────────────
@Test
@DisplayName("B1:STUDENT 通过 student guard")
void studentPassesStudentGuard() {
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
}
@Test
@DisplayName("B1:COMPANY 通过 company guard")
void companyPassesCompanyGuard() {
givenUser(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true));
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException();
}
@Test
@DisplayName("B1:ADMIN 通过 admin guard")
void adminPassesAdminGuard() {
givenUser(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true));
assertThatCode(() -> guard.requireAdmin(USER_ID)).doesNotThrowAnyException();
}
// ── 4~5. 跨角色一律拒绝 ───────────────────────────────────
@Test
@DisplayName("B1:STUDENT 不能通过 company / admin guard")
void studentFailsCompanyAndAdminGuards() {
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true));
assertForbidden(() -> guard.requireCompany(USER_ID));
assertForbidden(() -> guard.requireAdmin(USER_ID));
}
@Test
@DisplayName("B1:COMPANY 不能通过 student / admin guard")
void companyFailsStudentAndAdminGuards() {
givenUser(user(User.Role.COMPANY, Set.of(User.Role.COMPANY), true));
assertForbidden(() -> guard.requireStudent(USER_ID));
assertForbidden(() -> guard.requireAdmin(USER_ID));
}
// ── 6. ADMIN 无隐式超级权限 ───────────────────────────────
@Test
@DisplayName("B1:ADMIN 不得自动通过 student / company guard")
void adminHasNoImplicitSuperUserPower() {
givenUser(user(User.Role.ADMIN, Set.of(User.Role.ADMIN), true));
assertForbidden(() -> guard.requireStudent(USER_ID));
assertForbidden(() -> guard.requireCompany(USER_ID));
// 只有显式声明允许 ADMIN 时才通过
assertThatCode(() -> guard.requireAny(USER_ID, User.Role.STUDENT, User.Role.ADMIN))
.doesNotThrowAnyException();
}
// ── 7. inactive 用户失败关闭 ──────────────────────────────
@Test
@DisplayName("B1:inactive 用户即使角色正确也不得通过")
void inactiveUserIsRejectedEvenWithCorrectRole() {
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), false));
assertForbidden(() -> guard.requireStudent(USER_ID));
assertForbidden(() -> guard.requireAny(USER_ID, User.Role.STUDENT));
}
@Test
@DisplayName("B1:active 为 null(未显式置位)同样失败关闭")
void nullActiveIsRejected() {
User u = User.builder().id(USER_ID).phone("r01b-fixture")
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(null).build();
givenUser(u);
assertForbidden(() -> guard.requireStudent(USER_ID));
}
// ── 8. 角色缺失 ───────────────────────────────────────────
@Test
@DisplayName("B1:user_roles 为空时所有 guard 一律拒绝")
void missingRolesAreRejected() {
givenUser(user(User.Role.STUDENT, Set.of(), true));
assertForbidden(() -> guard.requireStudent(USER_ID));
assertForbidden(() -> guard.requireCompany(USER_ID));
assertForbidden(() -> guard.requireAdmin(USER_ID));
}
@Test
@DisplayName("B1:用户不存在返回既有 USER_NOT_FOUND 契约")
void unknownUserKeepsUserNotFoundContract() {
when(userRepository.findById(USER_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> guard.requireStudent(USER_ID))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.USER_NOT_FOUND.getCode());
}
// ── 9. 多角色用户 ─────────────────────────────────────────
@Test
@DisplayName("B1:多角色用户按其实际角色集合通过对应 guard")
void multiRoleUserPassesEachHeldRole() {
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT, User.Role.COMPANY), true));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
assertThatCode(() -> guard.requireCompany(USER_ID)).doesNotThrowAnyException();
assertForbidden(() -> guard.requireAdmin(USER_ID));
assertThatCode(() -> guard.requireAny(USER_ID, User.Role.ADMIN, User.Role.COMPANY))
.doesNotThrowAnyException();
}
// ── 10. users.role 与 user_roles 不一致时失败关闭 ─────────
@Test
@DisplayName("B1:users.role 声称 COMPANY 但 user_roles 只有 STUDENT 时,company guard 拒绝")
void primaryRoleDoesNotOverrideUserRoles() {
givenUser(user(User.Role.COMPANY, Set.of(User.Role.STUDENT), true));
assertForbidden(() -> guard.requireCompany(USER_ID));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
}
@Test
@DisplayName("B1:users.role 声称 STUDENT 但 user_roles 为空时仍然拒绝(失败关闭)")
void inconsistentPrimaryRoleFailsClosed() {
givenUser(user(User.Role.STUDENT, Set.of(), true));
assertForbidden(() -> guard.requireStudent(USER_ID));
}
// ── 11. 错误码与 HTTP 状态保持原契约 ──────────────────────
@Test
@DisplayName("B1:错误码与 HTTP 状态沿用既有契约(角色不符 2002/403,用户不存在 4001/404)")
void failureCodesKeepOriginalHttpContract() {
ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND);
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true));
assertThatThrownBy(() -> guard.requireCompany(USER_ID))
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
when(userRepository.findById(USER_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> guard.requireCompany(USER_ID))
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.USER_NOT_FOUND.getCode());
}
// ── 12~13. 不越界:不解析 requestId、不解析 Authentication ─
@Test
@DisplayName("B1:guard 不解析 requestId,也不读取 SecurityContext")
void guardDoesNotTouchRequestContextOrAuthentication() {
// 结构断言:依赖只有 UserRepository,不含 RequestIdAccessor / HttpServletRequest
assertThat(UserRoleGuard.class.getDeclaredFields())
.extracting(f -> f.getType().getName())
.containsExactly(UserRepository.class.getName());
// 行为断言:SecurityContext 为空时,凭 userId 仍能正常授权
SecurityContextHolder.clearContext();
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true));
assertThatCode(() -> guard.requireStudent(USER_ID)).doesNotThrowAnyException();
verify(userRepository).findById(USER_ID);
}
// ── 查询次数 ──────────────────────────────────────────────
@Test
@DisplayName("B1:按 userId 校验恰好一次查询;按实体重载零查询")
void queryCountIsBounded() {
givenUser(user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true));
guard.requireStudent(USER_ID);
verify(userRepository, org.mockito.Mockito.times(1)).findById(USER_ID);
User loaded = user(User.Role.STUDENT, Set.of(User.Role.STUDENT), true);
guard.requireStudent(loaded);
guard.requireAny(loaded, User.Role.STUDENT, User.Role.COMPANY);
// 实体重载不得新增查询
verify(userRepository, org.mockito.Mockito.times(1)).findById(USER_ID);
}
private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable callable) {
assertThatThrownBy(callable)
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
}
}

207
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationIntegrationTest.java

@ -0,0 +1,207 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.auth.support.TestAuthTokens;
import com.mydreamjob.security.UserRoleGuard;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.boot.test.mock.mockito.SpyBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* R01-B/B1:公共角色守卫在**真实 SecurityFilterChain + 真实 HTTP 端点**下的边界复验。
*
* <p>三个角色各取一个真实端点:</p>
* <ul>
* <li>ADMIN → {@code GET /admin/scoring/risk/overview}</li>
* <li>STUDENT → {@code GET /profile/me/career-progress}</li>
* <li>COMPANY → {@code GET /company/candidates}</li>
* </ul>
*
* <p>夹具使用**独占用户 ID 与独占手机号**,不复用 V1_1 种子账号占用的 1/2/3
* (那三行已被 V43 置为 inactive,会令 Token 认证在进入业务层前就 401)。</p>
*
* <p>{@link UserRoleGuard} 使用 {@code @SpyBean}:既保留真实授权行为,
* 又能证明端点确实**经由公共守卫**授权(接线变异检测)。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class RoleAuthorizationIntegrationTest {
private static final long STUDENT_ID = 9_320_001L;
private static final long COMPANY_ID = 9_320_002L;
private static final long ADMIN_ID = 9_320_003L;
private static final String ADMIN_ENDPOINT = "/admin/scoring/risk/overview";
private static final String STUDENT_ENDPOINT = "/profile/me/career-progress";
private static final String COMPANY_ENDPOINT = "/company/candidates";
@Autowired private MockMvc mockMvc;
@Autowired private JdbcTemplate jdbcTemplate;
@SpyBean private UserRoleGuard userRoleGuard;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void provisionFixtureUsers() {
cleanupFixtureUsers();
insertUser(STUDENT_ID, "r01b-student", "STUDENT");
insertUser(COMPANY_ID, "r01b-company", "COMPANY");
insertUser(ADMIN_ID, "r01b-admin", "ADMIN");
}
@AfterEach
void cleanupFixtureUsers() {
for (long id : new long[]{STUDENT_ID, COMPANY_ID, ADMIN_ID}) {
jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
private void insertUser(long id, String phone, String role) {
// 显式插入 active 用户 + user_roles:guard 的角色来源是 user_roles。
jdbcTemplate.update("INSERT INTO users (id, phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, 1, 1)", id, phone, "r01b-fixture-" + role, role);
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role);
}
private static String token(long id, String role) {
return TestAuthTokens.bearer(id, role);
}
// ── 401:未认证 ───────────────────────────────────────────
@Test
@DisplayName("B1 真实链路:三个端点无 Token 一律 401")
void noTokenIsUnauthorizedOnAllThreeEndpoints() throws Exception {
for (String path : new String[]{ADMIN_ENDPOINT, STUDENT_ENDPOINT, COMPANY_ENDPOINT}) {
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
}
}
// ── 403:角色不符 ─────────────────────────────────────────
@Test
@DisplayName("B1 真实链路:STUDENT 访问 ADMIN / COMPANY 端点得 403")
void studentIsForbiddenOnAdminAndCompanyEndpoints() throws Exception {
String token = token(STUDENT_ID, "STUDENT");
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token))
.andExpect(status().isForbidden());
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("B1 真实链路:COMPANY 访问 ADMIN / STUDENT 端点得 403")
void companyIsForbiddenOnAdminAndStudentEndpoints() throws Exception {
String token = token(COMPANY_ID, "COMPANY");
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token))
.andExpect(status().isForbidden());
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("B1 真实链路:ADMIN 不因是 ADMIN 而通过 STUDENT / COMPANY 端点")
void adminIsForbiddenOnStudentAndCompanyEndpoints() throws Exception {
String token = token(ADMIN_ID, "ADMIN");
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token))
.andExpect(status().isForbidden());
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token))
.andExpect(status().isForbidden());
}
// ── 2xx / 非 403:角色正确 ────────────────────────────────
@Test
@DisplayName("B1 真实链路:ADMIN 访问 ADMIN 端点得 200")
void adminReachesAdminEndpoint() throws Exception {
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token(ADMIN_ID, "ADMIN")))
.andExpect(status().isOk());
}
@Test
@DisplayName("B1 真实链路:STUDENT 访问 STUDENT 端点不被角色层拒绝")
void studentReachesStudentEndpoint() throws Exception {
int status = mockMvc.perform(get(STUDENT_ENDPOINT)
.header("Authorization", token(STUDENT_ID, "STUDENT")))
.andReturn().getResponse().getStatus();
org.assertj.core.api.Assertions.assertThat(status)
.as("角色正确时不得是 401/403")
.isNotIn(401, 403);
}
@Test
@DisplayName("B1 真实链路:COMPANY 访问 COMPANY 端点不被角色层拒绝")
void companyReachesCompanyEndpoint() throws Exception {
int status = mockMvc.perform(get(COMPANY_ENDPOINT)
.header("Authorization", token(COMPANY_ID, "COMPANY")))
.andReturn().getResponse().getStatus();
org.assertj.core.api.Assertions.assertThat(status)
.as("角色正确时不得是 401/403")
.isNotIn(401, 403);
}
// ── 404:资源不存在仍为 404(不被角色层改写成 403)────────
@Test
@DisplayName("B1 真实链路:已授权的 ADMIN 访问不存在的风险事件得 404 而非 403")
void authorizedAdminGetsNotFoundNotForbidden() throws Exception {
// 该端点对不存在的风险事件返回 404(RISK_EVENT_NOT_FOUND),既有测试已锁定。
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.post("/admin/scoring/risk/events/{eventId}/actions", 999_999_999L)
.header("Authorization", token(ADMIN_ID, "ADMIN"))
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"action\":\"ACKNOWLEDGE\",\"reasonCode\":\"MONITORING\","
+ "\"expectedVersion\":0}"))
.andExpect(status().isNotFound());
}
// ── 接线变异:端点确实经由公共守卫 ────────────────────────
@Test
@DisplayName("B1 接线检测:三个端点均实际调用公共 UserRoleGuard")
void allThreeEndpointsDelegateToSharedGuard() throws Exception {
mockMvc.perform(get(ADMIN_ENDPOINT).header("Authorization", token(ADMIN_ID, "ADMIN")))
.andExpect(status().isOk());
verify(userRoleGuard, atLeastOnce()).requireAdmin(ADMIN_ID);
mockMvc.perform(get(STUDENT_ENDPOINT).header("Authorization", token(STUDENT_ID, "STUDENT")));
verify(userRoleGuard, atLeastOnce()).requireStudent(STUDENT_ID);
mockMvc.perform(get(COMPANY_ENDPOINT).header("Authorization", token(COMPANY_ID, "COMPANY")));
verify(userRoleGuard, atLeastOnce()).requireCompany(COMPANY_ID);
}
// ── 资源归属仍由原 Service 负责(未被 guard 吞掉)─────────
@Test
@DisplayName("B1 边界:COMPANY 通过角色层后,资源归属仍由业务层判定(非 403 而是 404/400)")
void ownershipRemainsWithBusinessService() throws Exception {
// 该 COMPANY 用户没有任何企业实体:角色层放行后,业务层的归属/存在性检查给出 404,
// 证明 guard 没有替代、也没有跳过资源归属校验。
mockMvc.perform(get("/company/candidates/{applicationId}", 999_999_999L)
.header("Authorization", token(COMPANY_ID, "COMPANY")))
.andExpect(status().isNotFound());
}
}

69
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/UserRoleGuardTestSupport.java

@ -0,0 +1,69 @@
package com.mydreamjob.common.r01b;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.doAnswer;
/**
* R01-B/B1 测试支持:让被 mock 的 {@link UserRoleGuard} 具备**真实授权行为**。
*
* <p>服务单元测试用 {@code @InjectMocks} 组装被测服务,新加入的
* {@link UserRoleGuard} 依赖在测试里只能以 {@code @Mock} 形式提供(否则该构造参数为 null)。
* 若直接使用"什么都不做"的裸 mock,所有"角色不符应抛 403"的既有断言都会失效 ——
* 那等于削弱断言。本支持类把 mock 的四个方法**逐字委托给真实 guard**,
* 并复用测试自己已经 stub 的 {@link UserRepository},
* 使被测语义与收束前**完全一致**。</p>
*
* <p>使用 {@code lenient()}:单个用例可能只触发其中一种角色检查,
* 未被使用的委托桩不应被判为多余桩。</p>
*/
public final class UserRoleGuardTestSupport {
private UserRoleGuardTestSupport() {
}
/** 把 {@code mockGuard} 的授权方法委托给基于 {@code userRepository} 的真实 guard。 */
public static void delegateToRealGuard(UserRoleGuard mockGuard, UserRepository userRepository) {
UserRoleGuard real = new UserRoleGuard(userRepository);
lenient().doAnswer(inv -> {
real.requireStudent((Long) inv.getArgument(0));
return null;
}).when(mockGuard).requireStudent(anyLong());
lenient().doAnswer(inv -> {
real.requireCompany((Long) inv.getArgument(0));
return null;
}).when(mockGuard).requireCompany(anyLong());
lenient().doAnswer(inv -> {
real.requireAdmin((Long) inv.getArgument(0));
return null;
}).when(mockGuard).requireAdmin(anyLong());
lenient().doAnswer(inv -> {
real.requireAny((Long) inv.getArgument(0), (User.Role[]) inv.getArgument(1));
return null;
}).when(mockGuard).requireAny(anyLong(), any(User.Role[].class));
lenient().doAnswer(inv -> {
real.requireStudent((User) inv.getArgument(0));
return null;
}).when(mockGuard).requireStudent(any(User.class));
lenient().doAnswer(inv -> {
real.requireCompany((User) inv.getArgument(0));
return null;
}).when(mockGuard).requireCompany(any(User.class));
lenient().doAnswer(inv -> {
real.requireAdmin((User) inv.getArgument(0));
return null;
}).when(mockGuard).requireAdmin(any(User.class));
}
}

8
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java

@ -2,6 +2,8 @@ package com.mydreamjob.company;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.dto.CompanyCreateRequest; import com.mydreamjob.company.dto.CompanyCreateRequest;
@ -28,6 +30,9 @@ class CompanyServiceTest {
@Mock @Mock
private UserRepository userRepository; private UserRepository userRepository;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks @InjectMocks
private CompanyService companyService; private CompanyService companyService;
@ -37,6 +42,9 @@ class CompanyServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
company = Company.builder() company = Company.builder()
.id(1L).ownerUserId(COMPANY_USER_ID) .id(1L).ownerUserId(COMPANY_USER_ID)
.name("测试企业").description("一家科技公司") .name("测试企业").description("一家科技公司")

11
mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java

@ -4,6 +4,8 @@ import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
@ -12,6 +14,7 @@ import com.mydreamjob.competition.dto.CompetitionRankingResponse;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
@ -42,6 +45,11 @@ import static org.mockito.Mockito.*;
@MockitoSettings(strictness = Strictness.LENIENT) @MockitoSettings(strictness = Strictness.LENIENT)
class CompetitionRankingServiceTest { class CompetitionRankingServiceTest {
@BeforeEach
void wireRoleGuard() {
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
}
private static final Long ME = 100L; private static final Long ME = 100L;
private static final Long JOB = 9L; private static final Long JOB = 9L;
@ -51,6 +59,9 @@ class CompetitionRankingServiceTest {
@Mock private CompetitionRankingCalculator rankingCalculator; @Mock private CompetitionRankingCalculator rankingCalculator;
@Mock private CandidateScoringCalculator candidateScoringCalculator; @Mock private CandidateScoringCalculator candidateScoringCalculator;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private CompetitionRankingService service; @InjectMocks private CompetitionRankingService service;
// ── 权限与参数校验 ───────────────────────────────────────── // ── 权限与参数校验 ─────────────────────────────────────────

8
mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java

@ -2,6 +2,8 @@ package com.mydreamjob.job;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -33,6 +35,9 @@ class JobFavoriteServiceTest {
@Mock @Mock
private UserRepository userRepository; private UserRepository userRepository;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks @InjectMocks
private JobFavoriteService favoriteService; private JobFavoriteService favoriteService;
@ -45,6 +50,9 @@ class JobFavoriteServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
publishedJob = Job.builder().id(JOB_ID).companyId(1L) publishedJob = Job.builder().id(JOB_ID).companyId(1L)
.title("Java 后端").status("PUBLISHED").build(); .title("Java 后端").status("PUBLISHED").build();
draftJob = Job.builder().id(20L).companyId(1L) draftJob = Job.builder().id(20L).companyId(1L)

8
mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java

@ -3,6 +3,8 @@ package com.mydreamjob.match;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
@ -34,6 +36,9 @@ class MatchServiceTest {
@Mock private StudentProfileRepository profileRepository; @Mock private StudentProfileRepository profileRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private MatchService matchService; @InjectMocks private MatchService matchService;
private static final Long STUDENT_ID = 100L; private static final Long STUDENT_ID = 100L;
@ -45,6 +50,9 @@ class MatchServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
publishedJob = Job.builder().id(JOB_ID).companyId(1L).title("Java 后端") publishedJob = Job.builder().id(JOB_ID).companyId(1L).title("Java 后端")
.city("杭州").employmentType("FULL_TIME") .city("杭州").employmentType("FULL_TIME")
.salaryMax(20000).status("PUBLISHED").build(); .salaryMax(20000).status("PUBLISHED").build();

8
mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java

@ -2,6 +2,8 @@ package com.mydreamjob.preference;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.preference.dto.StudentPreferenceRequest; import com.mydreamjob.preference.dto.StudentPreferenceRequest;
@ -31,6 +33,9 @@ class PreferenceServiceTest {
@Mock @Mock
private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks @InjectMocks
private PreferenceService preferenceService; private PreferenceService preferenceService;
@ -41,6 +46,9 @@ class PreferenceServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
pref = StudentPreference.builder() pref = StudentPreference.builder()
.id(1L).studentUserId(STUDENT_ID) .id(1L).studentUserId(STUDENT_ID)
.expectedSalaryMin(10000).expectedSalaryMax(20000) .expectedSalaryMin(10000).expectedSalaryMax(20000)

8
mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java

@ -4,6 +4,8 @@ import com.mydreamjob.application.CompanyResumeAccessPolicy;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsResponse;
@ -32,6 +34,9 @@ class PrivacySettingsServiceTest {
@Mock private CompanyResumeAccessPolicy companyResumeAccessPolicy; @Mock private CompanyResumeAccessPolicy companyResumeAccessPolicy;
@Mock private AuditService auditService; @Mock private AuditService auditService;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private PrivacySettingsService service; @InjectMocks private PrivacySettingsService service;
private static final Long STUDENT_ID = 10L; private static final Long STUDENT_ID = 10L;
@ -40,6 +45,9 @@ class PrivacySettingsServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
lenient().doNothing().when(auditService).log(any(), any(), any(), any(), any(), any()); lenient().doNothing().when(auditService).log(any(), any(), any(), any(), any(), any());
} }

8
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java

@ -6,6 +6,8 @@ import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.profile.storage.ResumeFileValidator; import com.mydreamjob.profile.storage.ResumeFileValidator;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@ -37,6 +39,9 @@ class ResumeServiceTest {
@Spy @Spy
private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); private ResumeFileValidator resumeFileValidator = new ResumeFileValidator();
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks @InjectMocks
private ProfileService profileService; private ProfileService profileService;
@ -46,6 +51,9 @@ class ResumeServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
profile = StudentProfile.builder() profile = StudentProfile.builder()
.id(1L).userId(STUDENT_ID).realName("张三") .id(1L).userId(STUDENT_ID).realName("张三")
.resumeUrl("old_resume.pdf") .resumeUrl("old_resume.pdf")

11
mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java

@ -3,10 +3,13 @@ package com.mydreamjob.recommendation;
import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobRepository;
import com.mydreamjob.preference.StudentPreferenceRepository; import com.mydreamjob.preference.StudentPreferenceRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*; import org.mockito.*;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@ -19,6 +22,11 @@ import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class JobRecommendationServiceTest { class JobRecommendationServiceTest {
@BeforeEach
void wireRoleGuard() {
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
}
@Mock JobRecommendationTaxonomyRepository taxonomyRepository; @Mock JobRecommendationTaxonomyRepository taxonomyRepository;
@Mock JobRecommendationProfileRepository profileRepository; @Mock JobRecommendationProfileRepository profileRepository;
@Mock StudentJobRecommendationFeedbackRepository feedbackRepository; @Mock StudentJobRecommendationFeedbackRepository feedbackRepository;
@ -30,6 +38,9 @@ class JobRecommendationServiceTest {
@Mock ApplicationRepository applicationRepository; @Mock ApplicationRepository applicationRepository;
@Mock com.mydreamjob.audit.AuditService auditService; @Mock com.mydreamjob.audit.AuditService auditService;
@Mock JdbcTemplate jdbcTemplate; @Mock JdbcTemplate jdbcTemplate;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks JobRecommendationService service; @InjectMocks JobRecommendationService service;
@Test @Test

8
mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java

@ -3,6 +3,8 @@ package com.mydreamjob.review;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
@ -43,6 +45,9 @@ class JobReviewDimensionServiceTest {
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private JobReviewService reviewService; @InjectMocks private JobReviewService reviewService;
private static final Long STUDENT_ID = 100L; private static final Long STUDENT_ID = 100L;
@ -53,6 +58,9 @@ class JobReviewDimensionServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of());
lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of());
lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any()))

8
mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java

@ -3,6 +3,8 @@ package com.mydreamjob.review;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
@ -35,6 +37,9 @@ class JobReviewServiceTest {
@Mock private JobService jobService; @Mock private JobService jobService;
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks private JobReviewService reviewService; @InjectMocks private JobReviewService reviewService;
private static final Long STUDENT_ID = 100L; private static final Long STUDENT_ID = 100L;
@ -46,6 +51,9 @@ class JobReviewServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).studentUserId(STUDENT_ID) review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).studentUserId(STUDENT_ID)
.rating(4).content("不错的岗位").status("PUBLISHED").build(); .rating(4).content("不错的岗位").status("PUBLISHED").build();
// 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到) // 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到)

11
mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java

@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
@ -13,6 +15,7 @@ import com.mydreamjob.recommendation.JobRecommendationProfileRepository;
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse;
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceUpdateRequest; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceUpdateRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
@ -32,6 +35,11 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class TalentDiscoveryServiceTest { class TalentDiscoveryServiceTest {
@BeforeEach
void wireRoleGuard() {
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
}
@Mock StudentTalentDiscoveryPreferenceRepository preferenceRepository; @Mock StudentTalentDiscoveryPreferenceRepository preferenceRepository;
@Mock UserRepository userRepository; @Mock UserRepository userRepository;
@Mock CompanyRepository companyRepository; @Mock CompanyRepository companyRepository;
@ -41,6 +49,9 @@ class TalentDiscoveryServiceTest {
@Mock TalentDiscoveryScoreCalculator scoreCalculator; @Mock TalentDiscoveryScoreCalculator scoreCalculator;
@Mock AuditService auditService; @Mock AuditService auditService;
@Mock CandidateKeyGenerator candidateKeyGenerator; @Mock CandidateKeyGenerator candidateKeyGenerator;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks TalentDiscoveryService service; @InjectMocks TalentDiscoveryService service;
@Test @Test

8
mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java

@ -4,6 +4,8 @@ import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
@ -37,6 +39,9 @@ class CompanyVerificationServiceTest {
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private EntityManager entityManager; @Mock private EntityManager entityManager;
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@InjectMocks @InjectMocks
private CompanyVerificationService verificationService; private CompanyVerificationService verificationService;
@ -51,6 +56,9 @@ class CompanyVerificationServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
company = Company.builder().id(companyId).ownerUserId(companyUserId) company = Company.builder().id(companyId).ownerUserId(companyUserId)
.name("测试企业").verified(false).build(); .name("测试企业").verified(false).build();
companyUser = User.builder().id(companyUserId).roles(Set.of(User.Role.COMPANY)).build(); companyUser = User.builder().id(companyUserId).roles(Set.of(User.Role.COMPANY)).build();

169
mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv

@ -0,0 +1,169 @@
# R01-B 独立复验基线:从 HEAD:GlobalExceptionHandler.buildBusinessHttpStatuses() 独立提取
# 列: ErrorCode<TAB>数字码<TAB>HEAD 状态(未登记则为 400 默认)
# 生成自 git HEAD 版本,与工作区实现无任何共享代码
SUCCESS 1000 400
BAD_REQUEST 1001 400
INTERNAL_ERROR 1002 400
UNAUTHORIZED 2001 400
FORBIDDEN 2002 FORBIDDEN
TOKEN_EXPIRED 2003 400
TOKEN_INVALID 2004 400
CREDENTIALS_INVALID 2005 400
SMS_CODE_SENT 3001 400
SMS_CODE_EXPIRED 3002 400
SMS_CODE_INVALID 3003 400
SMS_CODE_ATTEMPTS_EXCEEDED 3004 400
SMS_RATE_LIMITED 3005 400
SMS_SEND_FAILED 3006 400
USER_NOT_FOUND 4001 NOT_FOUND
USER_ALREADY_EXISTS 4002 400
USER_DISABLED 4003 400
COMPANY_NOT_FOUND 5001 NOT_FOUND
COMPANY_OWNER_MISMATCH 5002 400
COMPANY_ALREADY_EXISTS 5003 CONFLICT
PROFILE_NOT_FOUND 6001 NOT_FOUND
JOB_NOT_FOUND 7001 NOT_FOUND
JOB_OWNER_MISMATCH 7002 400
INVALID_JOB_STATUS_TRANSITION 7003 CONFLICT
APPLICATION_DUPLICATE 8001 CONFLICT
APPLICATION_NOT_FOUND 8002 NOT_FOUND
INVALID_APPLICATION_STATUS_TRANSITION 8003 CONFLICT
APPLICATION_OWNER_MISMATCH 8004 FORBIDDEN
OFFER_NOT_FOUND 8101 NOT_FOUND
DUPLICATE_OFFER 8102 CONFLICT
INVALID_OFFER_STATUS_TRANSITION 8103 CONFLICT
OFFER_EXPIRED 8104 CONFLICT
OFFER_NOT_EDITABLE 8105 CONFLICT
OFFER_APPLICATION_NOT_ELIGIBLE 8106 400
OFFER_REMINDER_DUPLICATE 8107 CONFLICT
OFFER_NOT_REMINDABLE 8108 400
OFFER_COUNTER_OFFER_EXISTS 8109 CONFLICT
OFFER_COUNTER_OFFER_NOT_FOUND 8110 NOT_FOUND
OFFER_COUNTER_OFFER_NOT_PENDING 8111 CONFLICT
OFFER_NOT_WITHDRAWABLE 8112 400
OFFER_COUNTER_OFFER_EXPIRED 8113 CONFLICT
OFFER_NOT_NEGOTIABLE 8114 400
OFFER_COMPARE_TOO_MANY 8115 400
OFFER_COMPARE_NOT_OWNER 8116 FORBIDDEN
RESUME_VERSION_NOT_FOUND 8201 NOT_FOUND
DEFAULT_RESUME_DELETE_NOT_ALLOWED 8202 CONFLICT
RESUME_EXPORT_TASK_NOT_FOUND 8203 NOT_FOUND
RESUME_EXPORT_IN_PROGRESS 8204 CONFLICT
DUPLICATE_DEFAULT_RESUME 8205 CONFLICT
RESUME_EXPORT_NOT_READY 8206 CONFLICT
MATCH_CLOSED 8301 CONFLICT
MATCH_SCORES_LOCKED 8302 CONFLICT
MATCH_REQUIRED_FOR_CONVERSATION 8303 FORBIDDEN
APPLICATION_NOT_MATCHED 8304 CONFLICT
CONVERSATION_NOT_FOUND 8305 NOT_FOUND
MESSAGE_NOT_FOUND 8306 NOT_FOUND
MESSAGE_RECALL_WINDOW_EXPIRED 8307 CONFLICT
MESSAGE_RECALL_CONCURRENT_UPDATE 8308 CONFLICT
DUPLICATE_REVIEW 9101 CONFLICT
REVIEW_NOT_FOUND 9102 NOT_FOUND
REVIEW_ALREADY_DECIDED 9103 CONFLICT
DUPLICATE_FEEDBACK 9103 CONFLICT
FEEDBACK_NOT_FOUND 9104 NOT_FOUND
NOTIFICATION_NOT_FOUND 9105 400
REVIEW_WEIGHT_CONCURRENT_UPDATE 9106 CONFLICT
INTERVIEW_NOT_FOUND 8401 400
INTERVIEW_ACCESS_DENIED 8402 FORBIDDEN
INVALID_INTERVIEW_STATUS_TRANSITION 8403 CONFLICT
INTERVIEW_TIME_INVALID 8404 400
INTERVIEW_DURATION_EXCEEDED 8405 400
INTERVIEW_ONLINE_URL_REQUIRED 8406 400
INTERVIEW_OFFLINE_ADDRESS_REQUIRED 8407 400
INTERVIEW_START_TIME_PAST 8408 400
INTERVIEW_COMPLETE_NOT_READY 8409 CONFLICT
INTERVIEW_ALREADY_RESPONDED 8410 CONFLICT
VERIFICATION_NOT_FOUND 8501 NOT_FOUND
VERIFICATION_ALREADY_PENDING 8502 CONFLICT
VERIFICATION_ALREADY_APPROVED 8503 CONFLICT
INVALID_VERIFICATION_STATUS_TRANSITION 8504 CONFLICT
VERIFICATION_REJECT_REASON_REQUIRED 8505 400
APPEAL_NOT_FOUND 8601 NOT_FOUND
APPEAL_DUPLICATE 8602 CONFLICT
APPEAL_ALREADY_DECIDED 8603 CONFLICT
APPEAL_REASON_REQUIRED 8604 400
APPEAL_ROLLBACK_LIMIT_EXCEEDED 8605 CONFLICT
APPEAL_INVALID_TRANSITION 8606 CONFLICT
APPEAL_IDEMPOTENCY_CONFLICT 8607 CONFLICT
SERVICE_UNAVAILABLE 9001 400
RATE_LIMITED 9002 400
PRIVACY_SETTINGS_CONCURRENT_UPDATE 9201 CONFLICT
INVALID_DELETION_STATE 9301 CONFLICT
ACCOUNT_DELETION_PENDING 9302 CONFLICT
ACCOUNT_DELETED 9303 CONFLICT
ACCOUNT_DELETION_CONCURRENT_UPDATE 9304 CONFLICT
ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED 9305 CONFLICT
LAST_ADMIN_CANNOT_DELETE 9306 CONFLICT
DATA_EXPORT_TASK_NOT_FOUND 9401 NOT_FOUND
DATA_EXPORT_DUPLICATE 9402 CONFLICT
DATA_EXPORT_NOT_READY 9403 CONFLICT
DATA_EXPORT_EXPIRED 9404 CONFLICT
DATA_EXPORT_FAILED 9405 CONFLICT
DATA_EXPORT_FILE_NOT_FOUND 9406 CONFLICT
INTERVIEW_SCORE_CONCURRENT_UPDATE 9501 CONFLICT
INTERVIEW_NOT_COMPLETED 9502 CONFLICT
INTERVIEW_SCORE_SUBMITTED 9503 CONFLICT
INTERVIEW_SCORE_NOT_FOUND 9504 NOT_FOUND
REPORT_NOT_FOUND 9601 NOT_FOUND
CANNOT_REPORT_SELF 9602 400
REPORT_DUPLICATE 9603 CONFLICT
REPORT_ALREADY_DECIDED 9604 CONFLICT
REPORT_INVALID_TRANSITION 9605 CONFLICT
REPORT_IDEMPOTENCY_CONFLICT 9606 CONFLICT
REPORT_REASON_REQUIRED 9607 400
CANNOT_BLOCK_SELF 9608 400
REPORT_RATE_LIMITED 9609 TOO_MANY_REQUESTS
BLOCKED_SEND_PROHIBITED 9610 400
ASSESSMENT_TYPE_NOT_FOUND 9701 NOT_FOUND
ASSESSMENT_ATTEMPT_NOT_FOUND 9702 NOT_FOUND
ASSESSMENT_ATTEMPT_INVALID_STATE 9703 CONFLICT
ASSESSMENT_ANSWER_INVALID 9705 400
ASSESSMENT_INCOMPLETE 9706 400
ASSESSMENT_CONCURRENT_UPDATE 9707 CONFLICT
ASSESSMENT_DUPLICATE_ACTIVE 9708 CONFLICT
ASSESSMENT_IDEMPOTENCY_CONFLICT 9709 CONFLICT
ABILITY_TYPE_NOT_FOUND 9801 NOT_FOUND
ABILITY_ATTEMPT_NOT_FOUND 9802 NOT_FOUND
ABILITY_ATTEMPT_INVALID_STATE 9803 CONFLICT
ABILITY_RESULT_NOT_FOUND 9804 NOT_FOUND
ABILITY_ANSWER_INVALID 9805 400
ABILITY_INCOMPLETE 9806 400
ABILITY_CONCURRENT_UPDATE 9807 CONFLICT
ABILITY_DUPLICATE_ACTIVE 9808 CONFLICT
ABILITY_IDEMPOTENCY_CONFLICT 9809 CONFLICT
ABILITY_RETAKE_COOLDOWN 9810 CONFLICT
ABILITY_RETAKE_LIMIT 9811 TOO_MANY_REQUESTS
RISK_RULE_NOT_FOUND 9901 NOT_FOUND
RISK_EVENT_NOT_FOUND 9902 NOT_FOUND
RISK_RULE_CONCURRENT_UPDATE 9903 CONFLICT
RISK_EVENT_CONCURRENT_UPDATE 9904 CONFLICT
RISK_INVALID_ACTION 9905 CONFLICT
RISK_INVALID_FILTER 9906 400
RECOMMENDATION_PROFILE_NOT_FOUND 10001 400
RECOMMENDATION_PROFILE_INVALID 10002 400
RECOMMENDATION_PROFILE_CONCURRENT_UPDATE 10003 400
RECOMMENDATION_INVALID_QUERY 10004 400
RECOMMENDATION_TAXONOMY_INVALID 10005 400
RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE 10006 400
RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT 10007 400
RECOMMENDATION_FEEDBACK_NOT_FOUND 10008 400
RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE 10009 400
REVIEW_REPLY_NOT_FOUND 10101 NOT_FOUND
REVIEW_REPLY_ALREADY_EXISTS 10102 CONFLICT
REVIEW_REPLY_INVALID_STATE 10103 CONFLICT
REVIEW_REPLY_CONCURRENT_UPDATE 10104 CONFLICT
REVIEW_REPLY_CONTENT_INVALID 10105 400
REVIEW_REPLY_IDEMPOTENCY_CONFLICT 10106 CONFLICT
REVIEW_REPLY_DECISION_INVALID 10107 400
TALENT_INVITATION_NOT_FOUND 10201 NOT_FOUND
TALENT_INVITATION_CANDIDATE_UNAVAILABLE 10202 CONFLICT
TALENT_INVITATION_ALREADY_ACTIVE 10203 CONFLICT
TALENT_INVITATION_INVALID_STATE 10204 CONFLICT
TALENT_INVITATION_EXPIRED 10205 CONFLICT
TALENT_INVITATION_CONCURRENT_UPDATE 10206 CONFLICT
TALENT_INVITATION_IDEMPOTENCY_CONFLICT 10207 CONFLICT
TALENT_INVITATION_CONTENT_INVALID 10208 400
TALENT_INVITATION_RATE_LIMITED 10209 TOO_MANY_REQUESTS
Can't render this file because it has a wrong number of fields in line 4.
Loading…
Cancel
Save