diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index e2445da..2cc7fe2 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | -| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `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仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-b-authorization-exception-mapping-report.md b/docs/r01-b-authorization-exception-mapping-report.md new file mode 100644 index 0000000..288a5fb --- /dev/null +++ b/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:` = `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 explicitMappingsView(); +public static Map 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角色授权与异常映射收束完成,待独立代码复验。** diff --git a/docs/r01-b-independent-verification-20260918.md b/docs/r01-b-independent-verification-20260918.md new file mode 100644 index 0000000..cad28cc --- /dev/null +++ b/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提交后以独立纯测试提交修复。** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java index a50ed6a..b7af7ad 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java +++ b/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 String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final AbilityDefinitionRepository definitionRepository; private final AbilityQuestionRepository questionRepository; private final AbilityAttemptRepository attemptRepository; @@ -64,7 +65,7 @@ public class AbilityAttemptService { @Transactional(isolation = Isolation.READ_COMMITTED) public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND)); if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) { @@ -151,7 +152,7 @@ public class AbilityAttemptService { @Transactional public AbilityAttemptResponse get(Long userId, Long attemptId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); expireIfTimedOut(attempt); return toAttemptResponse(attempt); @@ -161,7 +162,7 @@ public class AbilityAttemptService { @Transactional(readOnly = true) public AbilityResultResponse loadResult(Long userId, Long attemptId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityResult result = resultRepository.findByAttemptId(attemptId) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND)); @@ -174,7 +175,7 @@ public class AbilityAttemptService { @Transactional public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId, SaveAbilityAnswersRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); if (expireIfTimedOut(attempt)) { throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); @@ -251,7 +252,7 @@ public class AbilityAttemptService { @Transactional public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { @@ -299,7 +300,7 @@ public class AbilityAttemptService { @Transactional(readOnly = true) public Page listMine(Long userId, int page, int size) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -545,16 +546,6 @@ public class AbilityAttemptService { } 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) { if (idem == null) { return null; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java index 95e9743..7c07fa3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java @@ -35,6 +35,7 @@ import java.util.stream.Collectors; public class ApplicationController { /** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; /** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */ private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; @@ -170,7 +171,7 @@ public class ApplicationController { @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); - assertCompanyRole(userId); + userRoleGuard.requireCompany(userId); // 参数校验 if (page < 0 || size < 1 || size > 100) @@ -204,7 +205,7 @@ public class ApplicationController { @PathVariable Long applicationId, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); - assertCompanyRole(userId); + userRoleGuard.requireCompany(userId); var company = companyService.findCompanyByOwnerUserId(userId); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); @@ -220,7 +221,7 @@ public class ApplicationController { @PathVariable Long applicationId, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); - assertCompanyRole(userId); + userRoleGuard.requireCompany(userId); // 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 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); - } - } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java b/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java index f41e68d..ceefda4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java +++ b/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 final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final AssessmentDefinitionRepository definitionRepository; private final AssessmentQuestionRepository questionRepository; private final AssessmentAttemptRepository attemptRepository; @@ -71,7 +72,7 @@ public class AssessmentAttemptService { */ @Transactional(isolation = Isolation.READ_COMMITTED) public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) .orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND)); // 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701 @@ -142,7 +143,7 @@ public class AssessmentAttemptService { @Transactional(readOnly = true) public AssessmentAttemptResponse get(Long userId, Long attemptId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentAttempt attempt = loadOwned(userId, attemptId); return toAttemptResponse(attempt); } @@ -152,7 +153,7 @@ public class AssessmentAttemptService { @Transactional public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId, SaveAssessmentAnswersRequest req, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentAttempt attempt = loadOwned(userId, attemptId); if (!isMutable(attempt.getStatus())) { throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE); @@ -204,7 +205,7 @@ public class AssessmentAttemptService { @Transactional public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); AssessmentAttempt attempt = loadOwned(userId, attemptId); // 重复提交幂等:返回原结果,不重新生成、不重复审计 @@ -261,7 +262,7 @@ public class AssessmentAttemptService { @Transactional(readOnly = true) public Page listMine(Long userId, int page, int size) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -315,16 +316,6 @@ public class AssessmentAttemptService { } 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) { if (idem == null) { return null; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java index c25cf01..dedeb07 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java +++ b/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.company.Company; import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,7 @@ import java.util.Map; @RequiredArgsConstructor public class MatchRuleService { + private final UserRoleGuard userRoleGuard; private final CompanyMatchRuleRepository companyRuleRepository; private final StudentMatchRuleRepository studentRuleRepository; private final CompanyRepository companyRepository; @@ -51,7 +53,7 @@ public class MatchRuleService { @Transactional(readOnly = true) public Map getStudentRule(Long userId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId).orElse(null); int required = rule != null ? rule.getRequiredHrScore() : StudentMatchRule.DEFAULT_REQUIRED_HR_SCORE; @@ -60,7 +62,7 @@ public class MatchRuleService { @Transactional public Map updateStudentRule(Long userId, int requiredHrScore) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId) .orElseGet(() -> StudentMatchRule.builder().studentUserId(userId).build()); rule.setRequiredHrScore(requiredHrScore); @@ -94,11 +96,4 @@ public class MatchRuleService { .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); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java index de7d9c7..bc2c28e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java @@ -44,6 +44,7 @@ public class CompanyScoringDashboardService { private static final String REVIEW_FILTER = "jr.status='PUBLISHED' AND jr.moderation_status IN ('AUTO_APPROVED','APPROVED')"; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final CompanyRepository companyRepository; private final ApplicationRepository applicationRepository; private final JobService jobService; @@ -276,7 +277,7 @@ public class CompanyScoringDashboardService { @Transactional(readOnly = true) public AdminDashboardOverviewResponse getAdminOverview(Long adminUserId) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); Integer totalCompanies = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM companies", Integer.class); Integer totalJobs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Integer.class); Integer totalCandidates = jdbcTemplate.queryForObject( @@ -452,15 +453,6 @@ public class CompanyScoringDashboardService { return companyRepository.findByOwnerUserId(userId) .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) { if (page < 0 || size < 1 || size > 100) { throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java index 4d1ebdc..627e63e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java +++ b/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; 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 CandidateScoringAppealHistoryRepository appealHistoryRepository; private final CandidateScoringSnapshotHistoryRepository snapshotHistoryRepository; @@ -62,23 +63,6 @@ public class CandidateScoringGovernanceService { 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) { return userRepository.findById(userId) .map(u -> u.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN)) @@ -89,7 +73,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse createAppeal(Long studentUserId, CreateAppealRequest req, AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); if (!CandidateScoringCalculator.isValidDimension(req.getDimension())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension: " + req.getDimension()); } @@ -158,7 +142,7 @@ public class CandidateScoringGovernanceService { // ── 我的申诉列表 ──────────────────────────────────────────── public Page listMyAppeals(Long studentUserId, String status, int page, int size) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); validatePage(page, size); PageRequest pr = PageRequest.of(page, size); Page p = (status != null && !status.isBlank()) @@ -182,7 +166,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse cancelAppeal(Long studentUserId, Long appealId, AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); CandidateScoringAppeal appeal = requireAppeal(appealId); if (!appeal.getCandidateUserId().equals(studentUserId)) { throw new BusinessException(ErrorCode.FORBIDDEN); @@ -204,7 +188,7 @@ public class CandidateScoringGovernanceService { // ── 全部申诉列表(ADMIN) ─────────────────────────────────── public Page listAllAppeals(Long adminUserId, String status, int page, int size) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); validatePage(page, size); PageRequest pr = PageRequest.of(page, size); Page p = (status != null && !status.isBlank()) @@ -217,7 +201,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse decideAppeal(Long adminUserId, Long appealId, AppealDecisionRequest req, AuditContext ctx) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); CandidateScoringAppeal appeal = requireAppeal(appealId); String target = req.getDecision(); if (!"APPROVE".equals(target) && !"REJECT".equals(target)) { @@ -261,7 +245,7 @@ public class CandidateScoringGovernanceService { @Transactional public AppealResponse rollbackAppeal(Long adminUserId, Long appealId, AuditContext ctx) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); CandidateScoringAppeal appeal = requireAppeal(appealId); if (CandidateScoringAppeal.STATUS_ROLLED_BACK.equals(appeal.getStatus())) { return toView(appeal, true); // 幂等,不重复写历史 @@ -343,7 +327,7 @@ public class CandidateScoringGovernanceService { public List getSnapshotHistory(Long adminUserId, Long candidateUserId, String dimension, String period) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); return snapshotHistoryRepository .findByCandidateUserIdAndDimensionAndPeriodOrderByCreatedAtAsc(candidateUserId, dimension, period) .stream().map(s -> SnapshotHistoryItem.builder() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java index 9f72571..ba4f954 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java @@ -66,6 +66,7 @@ public class CareerProgressService { private static final Set NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED"); private static final String REPORT_DECISION_TYPE = "REPORT_DECISION"; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final ApplicationRepository applicationRepository; private final InterviewRepository interviewRepository; private final InterviewScoreRepository interviewScoreRepository; @@ -79,7 +80,7 @@ public class CareerProgressService { @Transactional(readOnly = true) public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); validateParams(page, size, status); 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) { if (page < 0 || size < 1 || size > 50) { throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java new file mode 100644 index 0000000..86adb78 --- /dev/null +++ b/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)。 + * + *

职责边界:只回答"这个业务错误码应该对应哪个 HTTP 状态"。不做日志、不读请求、 + * 不生成响应体、不解析 requestId、不缓存请求级状态。

+ * + *

契约(与重构前逐项一致)

+ *
    + *
  • 显式映射只在 {@link #explicitMappings()} 一处定义,全部使用 + * {@code ErrorCode.X} 符号,不出现手写数字;
  • + *
  • **未显式映射的错误码回退 {@link #DEFAULT_STATUS}(400 BAD_REQUEST)**, + * 与重构前 {@code getOrDefault(code, BAD_REQUEST)} 完全一致;
  • + *
  • **映射键是 {@link ErrorCode} 本身,不是数字**。这一点是必须的: + * {@code ErrorCode} 现有 166 个常量却只有 165 个唯一数字码 + * ({@code REVIEW_ALREADY_DECIDED} 与 {@code DUPLICATE_FEEDBACK} 同为 9103), + * 若以数字为键,"禁止重复键"将无法在不修改 {@code ErrorCode} 数字的前提下成立。 + * 数字入口 {@link #resolve(int)} 由该映射派生;若两个不同常量被映射到 + * **同一个数字码且状态不同**,初始化即抛错(这是不可接受的歧义);
  • + *
  • 映射表初始化后不可变({@link Map#copyOf}),运行期无法增删改;
  • + *
  • 同一个 {@link ErrorCode} 常量被注册两次即为**初始化错误** + * (无论目标状态是否相同),由 {@link Builder#put} 立即抛出 + * {@link IllegalStateException},使"重复键"在启动期而不是请求期暴露;
  • + *
  • 不使用反射扫描,不引入任何运行时可变的 Map。
  • + *
+ * + *

注意:{@code UNAUTHORIZED(2001)} 与 {@code SERVICE_UNAVAILABLE} + * 在本映射中**故意不显式登记**,因此既有语义是回退 400 —— 这是重构前就存在的行为, + * 本次收束不得擅自把它改成 401/503。

+ */ +@Component +public final class ErrorHttpStatusRegistry { + + /** 未显式映射时的回退状态(与重构前一致)。 */ + public static final HttpStatus DEFAULT_STATUS = HttpStatus.BAD_REQUEST; + + private static final Map EXPLICIT = explicitMappings(); + private static final Map 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 explicitMappingsView() { + return EXPLICIT; + } + + /** 只读视图:数字业务码 → 状态(由 {@link #explicitMappingsView()} 派生)。 */ + public static Map numericMappingsView() { + return BY_CODE; + } + + /** 某个错误码是否被显式登记。 */ + public static boolean isExplicitlyMapped(ErrorCode errorCode) { + return errorCode != null && EXPLICIT.containsKey(errorCode); + } + + /** + * 由 {@link ErrorCode} 键映射派生数字键索引。 + * + *

同一数字码对应多个常量是**既有事实**(9103)。只要它们被映射到相同状态, + * 数字入口就是无歧义的,保持既有行为;一旦状态不同,说明存在无法自动裁决的冲突, + * 初始化必须失败而不是任选其一。

+ */ + private static Map indexByCode(Map explicit) { + Map byCode = new LinkedHashMap<>(); + for (Map.Entry 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 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(); + } + + /** + * 显式映射构建器。 + * + *

重复键检测:同一个数字错误码第二次注册即抛 {@link IllegalStateException}, + * **不区分目标状态是否相同** —— "同码同状态的重复登记"同样是不可审计的配置错误。

+ */ + public static final class Builder { + + private final Map 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 build() { + return Map.copyOf(entries); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 1fdc341..573fb2e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/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 lombok.extern.slf4j.Slf4j; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; 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 java.util.HashMap; -import java.util.LinkedHashMap; import java.util.Map; /** * 全局异常处理器 + * + *

R01-B/B2:业务错误码 → HTTP 状态的映射已收束到 {@link ErrorHttpStatusRegistry}, + * 本类只负责调用它,不再持有任何映射表或条件链。

*/ @Slf4j @RestControllerAdvice @RequiredArgsConstructor +@Import(ErrorHttpStatusRegistry.class) public class GlobalExceptionHandler { - private static final Map BUSINESS_HTTP_STATUSES = buildBusinessHttpStatuses(); - private final RequestIdAccessor requestIdAccessor; + private final ErrorHttpStatusRegistry errorHttpStatusRegistry; @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity> handleConstraintViolation( @@ -51,101 +54,12 @@ public class GlobalExceptionHandler { int errorCode = e.getErrorCode(); log.warn("BusinessException: code={}, message={}, path={}", errorCode, e.getMessage(), request.getRequestURI()); - HttpStatus status = resolveHttpStatus(errorCode); + HttpStatus status = errorHttpStatusRegistry.resolve(errorCode); return ResponseEntity .status(status) .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 buildBusinessHttpStatuses() { - Map 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 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) public ResponseEntity> handleValidationException( MethodArgumentNotValidException ex, HttpServletRequest request) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java index d1265b9..dcfd781 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java @@ -25,37 +25,28 @@ import java.util.Map; @Service public class CompanyService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final CompanyRepository companyRepository; private final UserRepository userRepository; private final JobService jobService; public CompanyService(CompanyRepository companyRepository, UserRepository userRepository, - JobService jobService) { + JobService jobService, + com.mydreamjob.security.UserRoleGuard userRoleGuard) { this.companyRepository = companyRepository; this.userRepository = userRepository; this.jobService = jobService; + this.userRoleGuard = userRoleGuard; } // ── 角色校验(最先执行)─────────────────────────────────── public void assertCompanyRole(Long userId) { - User user = userRepository.findById(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); - } + userRoleGuard.requireCompany(userId); } public 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); - } + userRoleGuard.requireStudent(userId); } // ── 业务方法 ────────────────────────────────────────────── diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java index db7c903..c53a24e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java +++ b/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 final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final UserRepository userRepository; private final JobService jobService; private final ApplicationRepository applicationRepository; @@ -50,7 +51,7 @@ public class CompetitionRankingService { if (jobId == null || jobId <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST); } - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // 仅 PUBLISHED 岗位可查看未公开状态统一 404,避免暴露岗位候选人池 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) { return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java index 0dd3959..da171b4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java @@ -20,6 +20,7 @@ import java.util.Map; @RequiredArgsConstructor public class JobFavoriteService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobFavoriteRepository favoriteRepository; private final JobService jobService; private final UserRepository userRepository; @@ -27,22 +28,11 @@ public class JobFavoriteService { 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 public Map favorite(Long studentUserId, Long jobId) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); Job job = jobService.getJob(jobId); if (!"PUBLISHED".equals(job.getStatus())) { @@ -76,7 +66,7 @@ public class JobFavoriteService { @Transactional public Map unfavorite(Long studentUserId, Long jobId) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // 幂等:不存在时也返回 200 favoriteRepository.deleteByJobIdAndStudentUserId(jobId, studentUserId); @@ -86,7 +76,7 @@ public class JobFavoriteService { // ── 查询收藏列表 ────────────────────────────────────────── public Page> listFavorites(Long studentUserId, int page, int size) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // size<1 直接 400,避免 PageRequest.of 抛 IllegalArgumentException 导致 500 if (size < 1) throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java index eadd129..93ee9a9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java @@ -29,6 +29,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class MatchService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobService jobService; private final StudentPreferenceRepository preferenceRepository; private final StudentProfileRepository profileRepository; @@ -38,15 +39,6 @@ public class MatchService { private static final String RULES_VERSION = "RULES_V0_1"; /** 岗位匹配诊断仅 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 diagnose(Long studentUserId, Long jobId) { @@ -54,7 +46,7 @@ public class MatchService { } public Map diagnose(Long studentUserId, Long jobId, com.mydreamjob.audit.AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); // 岗位必须存在 Job job = jobService.getJob(jobId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java index 1951c87..9ff2007 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java +++ b/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.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -24,6 +25,7 @@ import java.util.Map; @RequiredArgsConstructor public class PreferenceService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentPreferenceRepository preferenceRepository; private final UserRepository userRepository; /** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */ @@ -33,13 +35,7 @@ public class PreferenceService { // ── 角色校验 ────────────────────────────────────────────── public 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); - } + userRoleGuard.requireStudent(userId); } // ── 查询 ────────────────────────────────────────────────── diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java index fa0a534..674f7d2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java +++ b/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.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -26,6 +27,7 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class PrivacySettingsService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentPrivacySettingsRepository privacyRepository; private final UserRepository userRepository; private final AuditService auditService; @@ -38,13 +40,7 @@ public class PrivacySettingsService { /** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */ public void assertStudentRole(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); - } + userRoleGuard.requireStudent(userId); } /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index fcf74f7..c1c2498 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/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.candidatescore.refresh.CandidateScoringRefreshRequestService; import lombok.Getter; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -31,6 +32,7 @@ import java.util.Map; @RequiredArgsConstructor public class ProfileService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentProfileRepository profileRepository; private final ResumeStorageService resumeStorageService; private final UserRepository userRepository; @@ -43,13 +45,7 @@ public class ProfileService { // ── 角色校验 ────────────────────────────────────────────── public 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); - } + userRoleGuard.requireStudent(userId); } // ── 学生资料 CRUD ──────────────────────────────────────── diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java index 1cfa707..13c4e98 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java @@ -40,6 +40,7 @@ public class JobRecommendationService { private static final Set FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL"); private static final Set DECISIONS = Set.of("APPROVE", "REJECT"); private static final Set REJECT_REASONS = Set.of("TAXONOMY_MISMATCH", "WEIGHT_INVALID", "CONTENT_INCOMPLETE", "OTHER"); + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobRecommendationTaxonomyRepository taxonomyRepository; private final JobRecommendationProfileRepository profileRepository; private final StudentJobRecommendationFeedbackRepository feedbackRepository; @@ -90,7 +91,7 @@ public class JobRecommendationService { @Transactional(readOnly = true) public Page adminProfiles(Long adminId, int page, int size, String status) { - requireAdmin(adminId); validatePage(page, size); + userRoleGuard.requireAdmin(adminId); validatePage(page, size); Page result = status == null || status.isBlank() ? profileRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"))) : profileRepository.findByReviewStatusOrderByUpdatedAtDesc(status, PageRequest.of(page, size)); @@ -99,7 +100,7 @@ public class JobRecommendationService { @Transactional 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()))) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID); JobRecommendationProfile p = profileRepository.findByJobId(jobId) @@ -116,7 +117,7 @@ public class JobRecommendationService { @Transactional 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) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID); String key = studentId + ":" + jobId + ":" + req.getFeedbackType() + ":" + Optional.ofNullable(req.getReasonType()).orElse(""); @@ -137,7 +138,7 @@ public class JobRecommendationService { @Transactional 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) .orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_NOT_FOUND)); if (!Objects.equals(f.getVersion(), expectedVersion)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE); @@ -147,7 +148,7 @@ public class JobRecommendationService { @Transactional(readOnly = true) public Page 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 applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId)); Set hidden = feedbackRepository.findByStudentUserIdAndStatusAndFeedbackType(studentId, StudentJobRecommendationFeedback.ACTIVE, "NOT_INTERESTED") .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 validateMaps(Map r,Mapa){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 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 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 mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference>(){});}catch(Exception e){return Map.of();}} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java index ca3ab67..30bd08d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java @@ -32,6 +32,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class JobReviewService { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final JobReviewRepository reviewRepository; private final JobReviewDimensionScoreRepository dimensionScoreRepository; private final ReviewModerationHistoryRepository moderationHistoryRepository; @@ -53,14 +54,6 @@ public class JobReviewService { 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) { jobService.getJob(jobId); // throws JOB_NOT_FOUND } @@ -91,7 +84,7 @@ public class JobReviewService { @Transactional public Map createReview(Long studentUserId, Long jobId, JobReviewCreateRequest req, AuditContext ctx) { - assertStudentRole(studentUserId); + userRoleGuard.requireStudent(studentUserId); assertJobExists(jobId); if (reviewRepository.existsByJobIdAndStudentUserId(jobId, studentUserId)) @@ -187,7 +180,7 @@ public class JobReviewService { @Transactional public Map updateReview(Long userId, Long jobId, Long reviewId, JobReviewUpdateRequest req, AuditContext ctx) { - assertStudentRole(userId); + userRoleGuard.requireStudent(userId); JobReview review = findOwnReview(userId, jobId, reviewId); if (req.getRating() != null) { @@ -224,7 +217,7 @@ public class JobReviewService { @Transactional public void deleteReview(Long userId, Long jobId, Long reviewId, AuditContext ctx) { - assertStudentRole(userId); + userRoleGuard.requireStudent(userId); findOwnReview(userId, jobId, reviewId); reviewRepository.deleteById(reviewId); // DB 外键 ON DELETE CASCADE 删除维度分 auditService.log(userId, "DELETE_REVIEW", "JOB_REVIEW", reviewId, @@ -331,7 +324,7 @@ public class JobReviewService { public Page> listPendingReviews(Long adminUserId, Long jobId, String riskLevel, LocalDateTime createdFrom, LocalDateTime createdTo, int page, int size) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); assertValidPage(page, size); return reviewRepository.findPendingModeration(jobId, riskLevel, createdFrom, createdTo, PageRequest.of(page, size)) @@ -342,7 +335,7 @@ public class JobReviewService { @Transactional public Map decideReview(Long adminUserId, Long reviewId, String decision, String reason, AuditContext ctx) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); JobReview review = reviewRepository.findById(reviewId) .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); if (!JobReview.MODERATION_PENDING.equals(review.getModerationStatus())) { @@ -371,19 +364,11 @@ public class JobReviewService { /** 审核历史(分页),返回结果、审核人、时间与原因 */ @Transactional(readOnly = true) public Page> listModerationHistory(Long adminUserId, int page, int size) { - assertAdminRole(adminUserId); + userRoleGuard.requireAdmin(adminUserId); assertValidPage(page, size); return moderationHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size)) .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 */ diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java b/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java index 3f1fedc..16e095a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java @@ -1,8 +1,6 @@ package com.mydreamjob.risk; 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.PageResponse; import com.mydreamjob.common.exception.BusinessException; @@ -47,13 +45,13 @@ import java.util.List; @RequiredArgsConstructor public class AdminRiskController { + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final RiskGovernanceService riskGovernanceService; - private final UserRepository userRepository; @Operation(summary = "风险概览(ADMIN)") @ApiResponses({ @@ -180,15 +178,13 @@ public class AdminRiskController { 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) { Long userId = currentUserAccessor.requireUserId(); - User admin = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isAdmin = admin.getRoles() != null - && admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); - if (!isAdmin) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireAdmin(userId); return userId; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java index a99cd9e..626b1a8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java @@ -34,6 +34,8 @@ public class AdminReportController { private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + /** R01-B:角色授权统一委托给公共 guard,本类不再自行比较角色。 */ + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final ReportService reportService; private final UserRepository userRepository; @@ -101,14 +103,13 @@ public class AdminReportController { 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) { Long userId = currentUserAccessor.requireUserId(); - User admin = userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - boolean isAdmin = admin.getRoles() != null - && admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); - if (!isAdmin) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireAdmin(userId); return userId; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java b/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java new file mode 100644 index 0000000..c2a0c45 --- /dev/null +++ b/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)。 + * + *

把散落在各 Service/Controller 中的"读取用户 → 判断角色 → 抛错"重复实现, + * 收束到唯一实现点。

+ * + *

职责边界

+ *
    + *
  • 只做角色授权,不做资源归属(ownership)校验 —— + * 归属仍由各业务 Service 负责(如 {@code assertOwnerOrThrow});
  • + *
  • 不解析 SecurityContext:"当前登录用户是谁"仍由 + * {@link CurrentUserAccessor} 负责,本类只接收已解析出的 {@code userId};
  • + *
  • 不处理 requestId,不读请求头;
  • + *
  • 不提供任何"由调用方传入角色"的入口:角色只能来自 + * {@link User.Role} 枚举常量,客户端输入无法影响判定;
  • + *
  • 不给 ADMIN 隐式超级权限:{@code requireStudent}/{@code requireCompany} + * 对 ADMIN 同样拒绝,除非调用方显式传入 {@code requireAny(..., ADMIN)};
  • + *
  • 无副作用:只读用户表,不写库、不写审计、不记日志(尤其不记 userId / 手机号 / Token)。
  • + *
+ * + *

角色来源

+ *

角色取自 {@code user.getRoles()},即 {@code user_roles} 关联表 + * —— 与本类替换掉的全部既有 guard 完全一致。{@code users.role} 单值列用于 JWT 声明签发, + * 不参与此处授权判定:把两者混用会改变既有语义。

+ * + *

失败关闭

+ *
    + *
  • 用户不存在 → {@link ErrorCode#USER_NOT_FOUND}(沿用既有错误码);
  • + *
  • 用户存在但 inactive → {@link ErrorCode#FORBIDDEN} + * (比既有实现更严格:被禁用的用户不再因"角色正确"而通过);
  • + *
  • {@code user_roles} 中缺少所需角色 → {@link ErrorCode#FORBIDDEN} + * (数据不一致时同样是拒绝,而不是放行)。
  • + *
+ * + *

查询次数

+ *

{@code requireXxx(Long)} 每次调用恰好 1 次 {@code findById}, + * 不做跨请求缓存(用户被注销/禁用后必须立即失效)。调用方若已加载 + * {@link User},应使用 {@code requireXxx(User)} 重载以避免第二次查询。

+ */ +@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; + } + + /** + * 校验已加载用户是否具备所需角色之一。 + * + *

角色来源为 {@code user_roles};缺失任一所需角色即拒绝。

+ */ + 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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java index 6b37525..54b63b5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java +++ b/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 Pattern SAFE_SKILL = Pattern.compile("[\\p{L}\\p{N}+#./()()·\\-_ ]+"); + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentTalentDiscoveryPreferenceRepository preferenceRepository; private final UserRepository userRepository; private final CompanyRepository companyRepository; @@ -58,7 +59,7 @@ public class TalentDiscoveryService { @Transactional(readOnly = true) public TalentDiscoveryPreferenceResponse getPreference(Long userId) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); StudentTalentDiscoveryPreference value = preferenceRepository.findByStudentUserId(userId).orElse(null); return toPreference(value); } @@ -67,7 +68,7 @@ public class TalentDiscoveryService { public TalentDiscoveryPreferenceResponse updatePreference(Long userId, TalentDiscoveryPreferenceUpdateRequest request, AuditContext context) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (request == null || request.getEnabled() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -255,11 +256,6 @@ public class TalentDiscoveryService { if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN); return company; } - - private void requireStudent(Long userId) { - if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN); - } - private User requireUser(Long userId) { if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java index 475e094..152df9a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java +++ b/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 ObjectMapper JSON = new ObjectMapper(); + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final TalentInvitationRepository invitationRepository; private final TalentInvitationHistoryRepository historyRepository; private final UserRepository userRepository; @@ -163,7 +164,7 @@ public class TalentInvitationService { @Transactional(readOnly = true) public TalentInvitationPageResponse listStudent(Long userId, int page, int size) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); validatePage(page, size); Page invitations = invitationRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( userId, PageRequest.of(page, size)); @@ -180,7 +181,7 @@ public class TalentInvitationService { @Transactional public StudentTalentInvitationResponse getStudent(Long userId, Long invitationId, AuditContext context) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); TalentInvitation invitation = invitationRepository.findByIdAndStudentUserId(invitationId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); if (expireIfDue(invitation, context)) { @@ -197,7 +198,7 @@ public class TalentInvitationService { @Transactional public StudentTalentInvitationResponse decide(Long userId, Long invitationId, TalentInvitationDecisionRequest request, AuditContext context) { - requireStudent(userId); + userRoleGuard.requireStudent(userId); if (request == null || request.getDecision() == null || request.getExpectedVersion() == null || !("ACCEPT".equals(request.getDecision()) || "REJECT".equals(request.getDecision()))) { 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); return job; } - - private void requireStudent(Long userId) { - if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN); - } - private User requireUser(Long userId) { if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java index 416bda8..adc4697 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java +++ b/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 jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import com.mydreamjob.security.UserRoleGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.OptimisticLockingFailureException; @@ -34,6 +35,7 @@ public class CompanyVerificationService { private static final int MAX_PAGE_SIZE = 100; + private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final CompanyVerificationRepository verificationRepository; private final CompanyVerificationHistoryRepository historyRepository; private final CompanyRepository companyRepository; @@ -244,11 +246,7 @@ public class CompanyVerificationService { } public void assertAdminRole(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (!hasRole(user, User.Role.ADMIN)) { - throw new BusinessException(ErrorCode.FORBIDDEN); - } + userRoleGuard.requireAdmin(userId); } private void recordHistory(Long verificationId, String fromStatus, String toStatus, diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java index 35e01b4..22e1815 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityAttemptServiceTest.java +++ b/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.auth.User; 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.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -56,12 +58,18 @@ class AbilityAttemptServiceTest { @Mock private AuditService auditService; @Mock private Clock clock; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private AbilityAttemptService service; private final AuditContext ctx = AuditContext.builder().requestId("r").build(); @BeforeEach 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.instant()).thenReturn(Instant.parse("2026-09-03T08:00:00Z")); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java index 885a7e4..f9b15c7 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentAttemptServiceTest.java +++ b/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.auth.User; 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.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -55,12 +57,18 @@ class AssessmentAttemptServiceTest { @Mock private UserRepository userRepository; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private AssessmentAttemptService service; private final AuditContext ctx = AuditContext.builder().requestId("req").build(); @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(anyLong())).thenReturn(List.of()); when(answerRepository.findByAttemptId(anyLong())).thenReturn(List.of()); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java index 0643bbe..20105c3 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java +++ b/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.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -24,11 +27,19 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class MatchRuleServiceTest { + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } + @Mock private CompanyMatchRuleRepository companyRuleRepository; @Mock private StudentMatchRuleRepository studentRuleRepository; @Mock private CompanyRepository companyRepository; @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private MatchRuleService matchRuleService; private static final Long COMPANY_USER_ID = 10L; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java index f363edf..4b4c8b9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java +++ b/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.auth.User; 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.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; @@ -55,6 +57,9 @@ class CandidateScoringGovernanceServiceTest { @Mock private UserRepository userRepository; @Mock private AuditService auditService; @Mock private EntityManager entityManager; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CandidateScoringGovernanceService service; private static final Long STUDENT_ID = 2L; @@ -64,6 +69,9 @@ class CandidateScoringGovernanceServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + ReflectionTestUtils.setField(service, "entityManager", entityManager); Query nativeQ = mock(Query.class); when(nativeQ.setParameter(anyInt(), any())).thenReturn(nativeQ); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java index 767447f..e4becbb 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java @@ -82,7 +82,10 @@ class CareerProgressServiceTest { @BeforeEach void setUp() { 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, jobRepository, companyRepository, userRepository, calculator, scoringCalculator); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java index 2bd7497..abeacd1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java @@ -19,7 +19,7 @@ class GlobalExceptionHandlerAccountDeletionTest { @BeforeEach 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.setRequestURI("/api/v1/profile/me/deletion"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java index c6b9c37..88f613e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java @@ -16,7 +16,7 @@ class GlobalExceptionHandlerHttpStatusTest { @BeforeEach 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.setRequestURI("/api/v1/test"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java index 97a1bd3..deb6f8a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java @@ -22,7 +22,7 @@ class GlobalExceptionHandlerReportBlockTest { @BeforeEach 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.setRequestURI("/api/v1/reports"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java index db96b77..27112ca 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java +++ b/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.mock.web.MockHttpServletRequest; -import java.lang.reflect.Method; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -70,7 +69,7 @@ class GlobalExceptionHandlerStatusContractTest { private static final Set TOO_MANY_REQUESTS = Set.of( "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}") @EnumSource(ErrorCode.class) @@ -90,11 +89,10 @@ class GlobalExceptionHandlerStatusContractTest { } @Test - void unknownBusinessCodeKeepsBadRequestFallback() throws Exception { - Method resolver = GlobalExceptionHandler.class.getDeclaredMethod("resolveHttpStatus", int.class); - resolver.setAccessible(true); - - assertEquals(HttpStatus.BAD_REQUEST, resolver.invoke(handler, 123456789)); + void unknownBusinessCodeKeepsBadRequestFallback() { + // R01-B/B2:解析入口由 GlobalExceptionHandler 的私有方法收束到 ErrorHttpStatusRegistry, + // 断言目标(未知码仍回退 400)保持不变,不再依赖反射。 + assertEquals(HttpStatus.BAD_REQUEST, new ErrorHttpStatusRegistry().resolve(123456789)); } private HttpStatus expectedStatus(ErrorCode errorCode) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java new file mode 100644 index 0000000..7409157 --- /dev/null +++ b/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 状态映射的契约测试。 + * + *

覆盖任务书要求:单一映射、显式映射集中、不可变、重复键初始化即失败、 + * 未映射回退 400、只保留既有语义。

+ */ +class ErrorHttpStatusRegistryTest { + + private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); + + /** 允许出现在显式映射里的状态集合:重构前后都必须只有这四个。 */ + private static final Set 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 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 numeric = ErrorHttpStatusRegistry.numericMappingsView(); + assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) + .isInstanceOf(UnsupportedOperationException.class); + assertThat(numeric).hasSize(111); + } + + @Test + @DisplayName("B2:不同 ErrorCode 常量允许各自登记(即使数字码相同且状态相同)") + void distinctConstantsWithSharedNumericCodeAreAllowed() { + Map 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 forward = new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) + .put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND) + .build(); + Map reverse = new ErrorHttpStatusRegistry.Builder() + .put(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND) + .put(HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN) + .build(); + + assertThat(forward).isEqualTo(reverse); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java new file mode 100644 index 0000000..a92f1b9 --- /dev/null +++ b/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 映射**逐项一致**证明。 + * + *

这里的四个状态桶是**重构前**从 {@code GlobalExceptionHandler.buildBusinessHttpStatuses()} + * 逐条抄录的独立快照(亦保存于 {@code docs/acceptance/r01-b/b2-mapping-baseline.json}), + * 不是从重构后的实现反推。任何一个错误码的状态发生漂移,本测试都会失败。

+ */ +class GlobalExceptionHandlerMappingParityTest { + + private static final Set FORBIDDEN = Set.of( + "APPLICATION_OWNER_MISMATCH", "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", + "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER"); + + private static final Set 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 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 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> 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> 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> 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> 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java new file mode 100644 index 0000000..cf19e9e --- /dev/null +++ b/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 基线**逐项对账。 + * + *

期望值来源:{@code src/test/resources/r01b/error-code-http-status-baseline.tsv}, + * 该文件由独立复验者从 {@code git show HEAD:…GlobalExceptionHandler.java} 的 + * {@code buildBusinessHttpStatuses()} 提取生成,不复用工作区实现的任何映射表, + * 因此不构成同源假证明。

+ */ +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 baseline() { + // 用有序结构保存 name -> {code, statusOrdinal} + Map 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 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 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> ra = handler.handleBusinessException(a, request()); + ResponseEntity> 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 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 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 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 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> 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java new file mode 100644 index 0000000..814c80c --- /dev/null +++ b/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 分层。 + * + *

夹具使用**独占用户 ID / 手机号**,避开 {@code V1_1} 种子账号占用的 1/2/3 + * (被 {@code V43} 置为 inactive,会使 Token 在 Filter 阶段即被拒)。

+ */ +@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 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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java new file mode 100644 index 0000000..98edd0f --- /dev/null +++ b/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 独立复验:**接线变异检测**。 + * + *

用 {@code @MockBean} 替换公共 guard,使"角色决策"完全由测试掌控。 + * 由于端点自身的角色判定已被收束到该 guard,两个方向都可被观测:

+ *
    + *
  • guard 拒绝 → 端点 403 且**下游业务方法零调用**;
  • + *
  • guard 放行 → 即使调用方是 STUDENT,ADMIN 端点也返回 200。
  • + *
+ *

第二条是关键的变异证明:若端点仍保留自有的角色比较(未委托 guard), + * STUDENT 无论 guard 怎么桩都不可能通过 ADMIN 端点,本测试必然失败。

+ */ +@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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationGuardTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationGuardTest.java new file mode 100644 index 0000000..2c24856 --- /dev/null +++ b/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:公共角色授权守卫的行为契约。 + * + *

角色来源为 {@code user_roles}({@code user.getRoles()}),与迁移前的 + * 全部重复 guard 完全一致;{@code users.role} 单值列不参与此处判定。

+ */ +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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/RoleAuthorizationIntegrationTest.java new file mode 100644 index 0000000..0037912 --- /dev/null +++ b/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 端点**下的边界复验。 + * + *

三个角色各取一个真实端点:

+ *
    + *
  • ADMIN → {@code GET /admin/scoring/risk/overview}
  • + *
  • STUDENT → {@code GET /profile/me/career-progress}
  • + *
  • COMPANY → {@code GET /company/candidates}
  • + *
+ * + *

夹具使用**独占用户 ID 与独占手机号**,不复用 V1_1 种子账号占用的 1/2/3 + * (那三行已被 V43 置为 inactive,会令 Token 认证在进入业务层前就 401)。

+ * + *

{@link UserRoleGuard} 使用 {@code @SpyBean}:既保留真实授权行为, + * 又能证明端点确实**经由公共守卫**授权(接线变异检测)。

+ */ +@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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/UserRoleGuardTestSupport.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/UserRoleGuardTestSupport.java new file mode 100644 index 0000000..3fe42df --- /dev/null +++ b/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} 具备**真实授权行为**。 + * + *

服务单元测试用 {@code @InjectMocks} 组装被测服务,新加入的 + * {@link UserRoleGuard} 依赖在测试里只能以 {@code @Mock} 形式提供(否则该构造参数为 null)。 + * 若直接使用"什么都不做"的裸 mock,所有"角色不符应抛 403"的既有断言都会失效 —— + * 那等于削弱断言。本支持类把 mock 的四个方法**逐字委托给真实 guard**, + * 并复用测试自己已经 stub 的 {@link UserRepository}, + * 使被测语义与收束前**完全一致**。

+ * + *

使用 {@code lenient()}:单个用例可能只触发其中一种角色检查, + * 未被使用的委托桩不应被判为多余桩。

+ */ +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)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java index e25854e..212dda0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java +++ b/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.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.dto.CompanyCreateRequest; @@ -28,6 +30,9 @@ class CompanyServiceTest { @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CompanyService companyService; @@ -37,6 +42,9 @@ class CompanyServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + company = Company.builder() .id(1L).ownerUserId(COMPANY_USER_ID) .name("测试企业").description("一家科技公司") diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java index bdbe060..1482df0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java +++ b/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.auth.User; 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.CandidateScoringCalculator; 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.JobService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -42,6 +45,11 @@ import static org.mockito.Mockito.*; @MockitoSettings(strictness = Strictness.LENIENT) class CompetitionRankingServiceTest { + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } + private static final Long ME = 100L; private static final Long JOB = 9L; @@ -51,6 +59,9 @@ class CompetitionRankingServiceTest { @Mock private CompetitionRankingCalculator rankingCalculator; @Mock private CandidateScoringCalculator candidateScoringCalculator; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CompetitionRankingService service; // ── 权限与参数校验 ───────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java index 42d3e49..2c88472 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java +++ b/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.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,9 @@ class JobFavoriteServiceTest { @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private JobFavoriteService favoriteService; @@ -45,6 +50,9 @@ class JobFavoriteServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + publishedJob = Job.builder().id(JOB_ID).companyId(1L) .title("Java 后端").status("PUBLISHED").build(); draftJob = Job.builder().id(20L).companyId(1L) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java index e29fcd2..4b957be 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java +++ b/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.auth.User; 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.ErrorCode; import com.mydreamjob.job.Job; @@ -34,6 +36,9 @@ class MatchServiceTest { @Mock private StudentProfileRepository profileRepository; @Mock private AuditService auditService; @Mock private UserRepository userRepository; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private MatchService matchService; private static final Long STUDENT_ID = 100L; @@ -45,6 +50,9 @@ class MatchServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + publishedJob = Job.builder().id(JOB_ID).companyId(1L).title("Java 后端") .city("杭州").employmentType("FULL_TIME") .salaryMax(20000).status("PUBLISHED").build(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java index 376a3f4..226b7f2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java +++ b/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.UserRepository; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.preference.dto.StudentPreferenceRequest; @@ -31,6 +33,9 @@ class PreferenceServiceTest { @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private PreferenceService preferenceService; @@ -41,6 +46,9 @@ class PreferenceServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + pref = StudentPreference.builder() .id(1L).studentUserId(STUDENT_ID) .expectedSalaryMin(10000).expectedSalaryMax(20000) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java index 2932945..ba0c093 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java +++ b/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.auth.User; 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.ErrorCode; import com.mydreamjob.privacy.dto.PrivacySettingsResponse; @@ -32,6 +34,9 @@ class PrivacySettingsServiceTest { @Mock private CompanyResumeAccessPolicy companyResumeAccessPolicy; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private PrivacySettingsService service; private static final Long STUDENT_ID = 10L; @@ -40,6 +45,9 @@ class PrivacySettingsServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + lenient().doNothing().when(auditService).log(any(), any(), any(), any(), any(), any()); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java index be0b725..e5b5c36 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java +++ b/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.auth.User; 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.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,6 +39,9 @@ class ResumeServiceTest { @Spy private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private ProfileService profileService; @@ -46,6 +51,9 @@ class ResumeServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + profile = StudentProfile.builder() .id(1L).userId(STUDENT_ID).realName("张三") .resumeUrl("old_resume.pdf") diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java index a2f4916..2aeb9b5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java +++ b/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.auth.User; 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.JobRepository; import com.mydreamjob.preference.StudentPreferenceRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.jdbc.core.JdbcTemplate; @@ -19,6 +22,11 @@ import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class JobRecommendationServiceTest { + + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } @Mock JobRecommendationTaxonomyRepository taxonomyRepository; @Mock JobRecommendationProfileRepository profileRepository; @Mock StudentJobRecommendationFeedbackRepository feedbackRepository; @@ -30,6 +38,9 @@ class JobRecommendationServiceTest { @Mock ApplicationRepository applicationRepository; @Mock com.mydreamjob.audit.AuditService auditService; @Mock JdbcTemplate jdbcTemplate; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks JobRecommendationService service; @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java index e296a47..80da73c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java +++ b/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.auth.User; 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.ErrorCode; import com.mydreamjob.job.Job; @@ -43,6 +45,9 @@ class JobReviewDimensionServiceTest { @Mock private UserRepository userRepository; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private JobReviewService reviewService; private static final Long STUDENT_ID = 100L; @@ -53,6 +58,9 @@ class JobReviewDimensionServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java index f6b5ca0..26fc1d2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java +++ b/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.auth.User; 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.ErrorCode; import com.mydreamjob.job.Job; @@ -35,6 +37,9 @@ class JobReviewServiceTest { @Mock private JobService jobService; @Mock private UserRepository userRepository; @Mock private AuditService auditService; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private JobReviewService reviewService; private static final Long STUDENT_ID = 100L; @@ -46,6 +51,9 @@ class JobReviewServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).studentUserId(STUDENT_ID) .rating(4).content("不错的岗位").status("PUBLISHED").build(); // 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java index 8b434eb..794dcf5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryServiceTest.java +++ b/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.auth.User; 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.CompanyRepository; 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.TalentDiscoveryPreferenceUpdateRequest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -32,6 +35,11 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class TalentDiscoveryServiceTest { + + @BeforeEach + void wireRoleGuard() { + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + } @Mock StudentTalentDiscoveryPreferenceRepository preferenceRepository; @Mock UserRepository userRepository; @Mock CompanyRepository companyRepository; @@ -41,6 +49,9 @@ class TalentDiscoveryServiceTest { @Mock TalentDiscoveryScoreCalculator scoreCalculator; @Mock AuditService auditService; @Mock CandidateKeyGenerator candidateKeyGenerator; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks TalentDiscoveryService service; @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java index c584542..b8260ea 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java +++ b/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.auth.User; 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.ErrorCode; import com.mydreamjob.company.Company; @@ -37,6 +39,9 @@ class CompanyVerificationServiceTest { @Mock private AuditService auditService; @Mock private EntityManager entityManager; + /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ + @Mock + private UserRoleGuard userRoleGuard; @InjectMocks private CompanyVerificationService verificationService; @@ -51,6 +56,9 @@ class CompanyVerificationServiceTest { @BeforeEach void setUp() { + // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 + UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + company = Company.builder().id(companyId).ownerUserId(companyUserId) .name("测试企业").verified(false).build(); companyUser = User.builder().id(companyUserId).roles(Set.of(User.Role.COMPANY)).build(); diff --git a/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv b/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv new file mode 100644 index 0000000..7c6ad79 --- /dev/null +++ b/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv @@ -0,0 +1,169 @@ +# R01-B 独立复验基线:从 HEAD:GlobalExceptionHandler.buildBusinessHttpStatuses() 独立提取 +# 列: ErrorCode数字码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