You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

28 KiB

R01-B 角色授权与异常映射收束报告

日期:2026-09-17 分支:refactor/r01-structure-performance HEAD:84fe9f5febcf56910d5c978dd44e0e16d15d7209(A6 后夹具修复提交) 本轮性质:实施与自动化验证 —— 未暂存、未提交、未推送,待独立代码复验 范围:B1 角色授权重复收束 + B2 异常到 HTTP 状态映射收束(不拆分子阶段,不进入 R01-C/E)


0. 结论

R01-B角色授权与异常映射收束完成,待独立代码复验。

  • 角色授权重复逻辑收束到唯一实现点 security/UserRoleGuard,54 个调用点改走公共 guard; 角色 guard 方法定义 32 → 21,getRoles() 角色比较 46 处 / 31 文件 → 23 处 / 16 文件;
  • HTTP 状态映射收束到 common/exception/ErrorHttpStatusRegistry,GlobalExceptionHandler 不再持有映射表;112 个 ErrorCode 的映射逐项保持不变,未映射的 54 个仍回退 400;
  • 收束过程中发现一处既有潜在缺陷(ErrorCode 166 个常量只有 165 个唯一数字码), 已记录、未擅自修改(见 §9.4)。

1. 前置 Git 状态

项 结果
git branch --show-current refactor/r01-structure-performance
git rev-parse HEAD 84fe9f5febcf56910d5c978dd44e0e16d15d7209
git rev-list --left-right --count HEAD...@{upstream} 0 0
git diff --cached --name-only 空
git diff --check 仅 LF/CRLF 警告,exit 0
与目标文件重叠的未提交修改 无。GlobalExceptionHandler.java 在 git status 中显示 M,但 git hash-object = git rev-parse HEAD:<file> = 1fdc341e…,git diff --quiet exit 0 —— 属 Windows stat 缓存假阳性,非真实修改

2. 角色授权库存(重构前)

搜索范围:mydreamjob-server/src/main/java。 重构前共 32 个角色 guard 方法定义 + 7 个 private boolean hasRole(User, Role) 助手。

共同语义(近乎逐字相同的复制):

User user = userRepository.findById(userId)
        .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean ok = user.getRoles().stream().anyMatch(r -> r == User.Role.X);   // user_roles
if (!ok) throw new BusinessException(ErrorCode.FORBIDDEN);
项 统一事实
输入来源 已解析出的 userId(由 CurrentUserAccessor 或调用方传入),没有任何一处从请求参数取角色
角色来源 user_roles 关联表(user.getRoles())—— 31 个文件、46 处 getRoles();users.role 单值列不参与授权判定(它只用于 JWT 声明签发)
失败错误码 用户不存在 → USER_NOT_FOUND(4001);角色不符 → FORBIDDEN(2002)
HTTP 状态 4001 → 404;2002 → 403(经 §3 的映射表)
是否含资源归属 不含。归属(如 assertOwnerOrThrow、findByOwnerUserId)由各业务 Service 独立执行
是否允许 ADMIN 不允许(无隐式超级权限);requireAny 由调用方显式列出 ADMIN 才放行
可否安全委托 纯 void 角色门禁 → 可;返回 Company 实体的 requireCompany → 不可整体委托(见 §7)

3. HTTP 映射库存(重构前)

GlobalExceptionHandler.buildBusinessHttpStatuses()(第 71–136 行)+ register()(第 138–147 行)

  • resolveHttpStatus()(第 67–69 行),共 112 个 ErrorCode 引用:
HttpStatus 数量 备注
CONFLICT 77
NOT_FOUND 27
FORBIDDEN 5 FORBIDDEN、INTERVIEW_ACCESS_DENIED、APPLICATION_OWNER_MISMATCH、MATCH_REQUIRED_FOR_CONVERSATION、OFFER_COMPARE_NOT_OWNER
TOO_MANY_REQUESTS 3
合计 112
项 结果
重复键 0(112 个 ErrorCode 常量互不相同)
重复项映射一致性 不适用
未显式映射的 ErrorCode 54 / 166 → 一律回退 HTTP 400
关键既有语义 UNAUTHORIZED(2001) 未显式映射 → 400(不是 401);SERVICE_UNAVAILABLE、RATE_LIMITED、INTERNAL_ERROR 同为 400
已有测试锁定状态码 有:GlobalExceptionHandlerStatusContractTest 以 @EnumSource(ErrorCode.class) 对全部 166 个错误码锁定状态 + 业务码 + message + requestId

快照已保存:docs/acceptance/r01-b/b2-mapping-baseline.json(112 条 名称 → 状态), 并逐条转写进 GlobalExceptionHandlerMappingParityTest 作为独立基线。


4. 修复前失败测试(红灯)

观察 说明
角色授权 重构前没有公共 guard:32 个方法各自实现,无任何调用点经过统一入口。新增的 RoleAuthorizationIntegrationTest 以 @SpyBean UserRoleGuard 断言"端点确实调用公共 guard"——在重构前该 bean 不存在,测试无法编译/通过
HTTP 映射 重构前映射表在 GlobalExceptionHandler 内部,无法在初始化期检出重复键(register() 仅在"同码不同状态"时抛错,同码同状态被静默容忍);新增的 ErrorHttpStatusRegistryTest 直接断言"同码重复注册必须抛 IllegalStateException"——重构前无从表达
未改变任何既有业务期望 GlobalExceptionHandlerStatusContractTest 的 4 个状态桶与全部断言逐字保留,仅把其反射调用私有方法的用例改为调用 registry.resolve(int)

5. 公共角色 guard 设计

新增 security/UserRoleGuard.java(@Component,仅依赖 UserRepository)。

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:

public HttpStatus resolve(ErrorCode errorCode);
public HttpStatus resolve(int errorCode);   // BusinessException 只暴露数字 code
public HttpStatus defaultStatus();          // 400
public int explicitMappingCount();          // 112
public static Map<ErrorCode, HttpStatus> explicitMappingsView();
public static Map<Integer, HttpStatus> numericMappingsView();  // 111
要求 落实
不可变映射 构建后 Map.copyOf(...),运行期 put/remove 抛 UnsupportedOperationException(测试锁定)
键类型 ErrorCode 常量(见 §9.4);数字入口由该映射派生
初始化禁止重复键 Builder.put 用 putIfAbsent,同码重复注册即抛 IllegalStateException(无论目标状态是否相同)
显式映射集中一处 只在 explicitMappings() 内,全部 ErrorCode.X 符号,无手写数字
未映射默认 400 DEFAULT_STATUS = BAD_REQUEST,与重构前 getOrDefault(code, BAD_REQUEST) 一致
不引入反射扫描 无反射
不使用可运行时修改的 Map 静态 final + Map.copyOf
Handler 只调用 registry GlobalExceptionHandler 删除 BUSINESS_HTTP_STATUSES、buildBusinessHttpStatuses()、register()、resolveHttpStatus();handleBusinessException 改为 errorHttpStatusRegistry.resolve(errorCode)
同码映射到不同状态 未发现(唯一同数字码的两个常量映射到同一状态,见 §9.4)

ErrorHttpStatusRegistry 通过 @Import 挂在 GlobalExceptionHandler 上,使 @WebMvcTest 切片 (CompanyControllerMvcTest)无需改动即可解析到该组件。

9.2 响应契约未变

  • 响应 JSON 结构、字段、业务码、message 全部不变(@EnumSource 全量断言)
  • requestId 仍来自 RequestIdAccessor,测试断言 "r01b-contract" / "r01b-parity"
  • 日志级别未变(handleBusinessException 仍为 log.warn)
  • 未知 code 仍回退 400
  • ErrorCode 数字、枚举成员零修改(git diff 为空)
  • 未新增接口、DTO、依赖

9.3 逐项一致性证明

GlobalExceptionHandlerMappingParityTest:

  1. 显式映射集合 = 重构前快照的 112 个 ErrorCode(对全部 166 个错误码逐一核对: 快照中未显式映射的,重构后也不得显式映射);
  2. 处理器实际返回状态 ≡ registry 解析结果 ≡ 快照基线(@EnumSource 166 参数化);
  3. 未映射码(含 UNAUTHORIZED(2001)、SERVICE_UNAVAILABLE)保持 400;
  4. FORBIDDEN 保持业务码 2002 + HTTP 403;
  5. NOT_FOUND 类 404、CONFLICT 类 409、限流类 429;
  6. 响应体不含异常类名、堆栈、内部路径。

9.4 收束过程中发现的既有潜在缺陷(已记录,未修改)

ErrorCode 166 个常量只有 165 个唯一数字码:REVIEW_ALREADY_DECIDED(9103) 与 DUPLICATE_FEEDBACK(9103) 共用同一数字码。

  • 重构前该重复被 register() 静默容忍(putIfAbsent 只在"同码不同状态"时抛错);
  • 本轮的严格重复检测因此在启动期暴露了它;
  • 二者当前都映射到 CONFLICT,故数字入口无歧义、行为与重构前完全一致;
  • 按任务书"不修改 ErrorCode 数字、不新增 ErrorCode"的要求,本轮不修改, 仅把映射键改为 ErrorCode 常量(这也正是"键必须是 ErrorCode 或稳定错误码类型"的实践理由), 并以 ErrorHttpStatusRegistryTest.distinctConstantsWithSharedNumericCodeAreAllowed 锁定该事实。

处置建议(留待后续独立提交):为 DUPLICATE_FEEDBACK 分配唯一数字码。若两个常量被映射到 不同状态,注册表的 indexByCode 会在初始化期直接抛错,不会静默任选其一。


9.5 量化结果(前后对比)

指标 重构前 重构后 说明
角色 guard 方法定义总数 32 21 其中 6 个是 UserRoleGuard 自身;6 个是保留签名的公开方法(方法体已是一行委托);8 个是实体返回型 requireCompany(非等价,保留);2 个是 Controller 的 requireAdmin(request)(已委托)
重复 student guard 实现 16 1(公共 guard) 其余为公开委托方法/实体返回型
重复 company guard 实现 2(纯角色门禁)+ 7(实体返回型) 1(公共 guard);7 个实体返回型保留
重复 admin guard 实现 7 1(公共 guard)
已迁移调用点 0 54 37 student + 13 admin + 4 company
明确保留的非等价调用点 — 14 7 个实体返回型 requireCompany + 7 个归属/可见性用 hasRole
getRoles() 角色比较 46 处 / 31 文件 23 处 / 16 文件 减少 50%
GlobalExceptionHandler 行数 232 146 净减 86 行;映射表、register()、resolveHttpStatus() 全部移出
resolveHttpStatus 条件链 映射构建 66 行 + 注册 10 行 + 解析 3 行 0(逻辑迁入 ErrorHttpStatusRegistry,261 行,含 javadoc 与显式 112 条映射)
ErrorCode.X.getCode() 用于状态比较的次数 0(原为 Map 查找) 0 状态解析无数字字面量比较
重复 HTTP 映射数 0(且不可检测) 0(且初始化即拒绝)
未映射 ErrorCode 数 54 / 166 → 400 54 / 166 → 400 逐项不变
角色授权 DB 查询次数 每处 1 次 findById requireXxx(Long) 1 次;requireXxx(User) 0 次;无跨请求缓存

10. 查询次数

场景 数据库查询次数
requireStudent/Company/Admin/Any(Long userId) 恰好 1 次 users.findById(RoleAuthorizationGuardTest.queryCountIsBounded 以 Mockito times(1) 锁定)
requireStudent/Company/Admin/Any(User user) 0 次(供已加载实体的调用点使用)
一个请求内相同用户重复角色检查 调用方已加载实体时应使用实体重载;现有调用点每个请求路径至多 1 次 guard 调用
跨请求缓存 无(不缓存 active 状态,注销/禁用后立即失效)
Redis 依赖 无
事务边界 未改变(guard 只读、无 @Transactional)
单次授权的 N+1 无:guard 为单条主键查询,user_roles 由 JPA 关联加载

11. 测试结果

日志目录:docs/acceptance/r01-b/

# 命令 结果 日志
1 mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test 371 / 371,F=0 E=0 S=0 ✔ b1b2-tests.log
2 mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*Admin*Controller*Test,*Company*Controller*Test,*Student*Controller*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test 448 中 6 项失败 —— 全部为既有夹具缺陷(§11.3),非本轮引入 b1-related.log
3 mvn test 2656 / 2656,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS ✔ b-full-mvntest.log
4 mvn clean verify 见 §11.4 b-clean-verify.log
5 python ..\docs\verify_openapi.py 176 paths / 214 operations,All checks passed ✔ —

11.1 新增测试逐类结果

测试类 Tests 覆盖
RoleAuthorizationGuardTest 16 STUDENT/COMPANY/ADMIN 通过各自 guard;跨角色拒绝;ADMIN 无隐式超级权限;inactive 与 active=null 失败关闭;角色缺失;多角色;users.role 与 user_roles 不一致失败关闭;错误码/HTTP 契约;不解析 requestId/SecurityContext;查询次数
RoleAuthorizationIntegrationTest 10 三个真实端点(ADMIN /admin/scoring/risk/overview、STUDENT /profile/me/career-progress、COMPANY /company/candidates)经真实 SecurityFilterChain 验证 401/403/404 分层;接线变异(@SpyBean 断言 guard 确被调用);资源归属仍由业务层判定
ErrorHttpStatusRegistryTest 174 112 条显式映射、111 个唯一数字码、四状态白名单、逐 ErrorCode 自洽、未映射回退 400、重复键初始化拒绝、不可变、键必须为 ErrorCode、9103 共用数字码
GlobalExceptionHandlerMappingParityTest 171 显式映射集合 = 重构前快照;处理器返回 ≡ registry ≡ 快照(166 参数化);FORBIDDEN 2002/403;UNAUTHORIZED(2001) 保持 400;服务不可用保持 400;响应体无内部细节

11.2 测试总数

项 数值
R01-B 前基线 2285
本轮新增 371
预期合计 2656
实测 (mvn test) 2656 ✔ 与预期逐位一致

11.3 关联回归中暴露的既有夹具缺陷(与本轮无关,如实记录)

mvn -Dtest="*Admin*Controller*Test,..." 单独运行时,既有的 AdminReviewControllerTest(4 项)与 CompanyReviewManagementControllerTest(2 项) 出现 expected 200/403/409 but was 401。根因与 A6 独立复验 §9 定位的同一缺陷:

AuthSessionService - session validation rejected: user not usable
JwtAuthenticationFilter - JWT authentication rejected: session not active
  • AdminReviewControllerTest 使用 ADMIN_ID = 1L;CompanyReviewManagementControllerTest 使用 token(1L, "COMPANY") / token(2L, "STUDENT");
  • V1_1 种子账号占用 users.id = 1/2/3 且被 V43 置为 inactive,而 TestAuthTokens 对"已存在但 inactive"的行不插入也不激活;
  • 全量顺序下 com.mydreamjob.controller.AuthIntegrationTest 的 deleteAll() 偶然清掉种子行,问题被掩盖 —— 故 §11.2 的全量结果是干净的;
  • 日志中该警告恰好 6 次,分别落在上述两个类,无一处落在本轮改动的代码路径;
  • 本轮未修改这两个测试类,也未把该失败写成通过。

该缺陷与 FairnessMonitoringControllerTest 属同一类,已在 A6 后以独立提交 84fe9f5 修复 其中一个类;其余同类类别的隔离修复应另开独立纯测试提交,不并入 R01-B。

11.4 mvn clean verify

[INFO] Tests run: 2656, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
项 数值
全量测试总数 2656(不低于收束前 2285 ✔)
Failures / Errors / Skipped 0 / 0 / 0 ✔
日志 docs/acceptance/r01-b/b-clean-verify.log

12. OpenAPI

python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!

git diff -- docs/openapi-rc.json → 空,openapi-rc.json 零修改 ✔


13. 精确修改文件

新增生产代码(2)

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

修改生产代码(22)

ability/AbilityAttemptService.java
application/ApplicationController.java
assessment/AssessmentAttemptService.java
bidirectionalmatch/MatchRuleService.java
candidatescore/dashboard/CompanyScoringDashboardService.java
candidatescore/governance/CandidateScoringGovernanceService.java
careerprogress/CareerProgressService.java
common/exception/GlobalExceptionHandler.java          ← B2
company/CompanyService.java
competition/CompetitionRankingService.java
job/JobFavoriteService.java
match/MatchService.java
preference/PreferenceService.java
privacy/PrivacySettingsService.java
profile/ProfileService.java
recommendation/JobRecommendationService.java
review/JobReviewService.java
risk/AdminRiskController.java
safety/AdminReportController.java
talentdiscovery/TalentDiscoveryService.java
talentinvitation/TalentInvitationService.java
verification/CompanyVerificationService.java

新增测试(5)

common/r01b/RoleAuthorizationGuardTest.java
common/r01b/RoleAuthorizationIntegrationTest.java
common/r01b/ErrorHttpStatusRegistryTest.java
common/r01b/GlobalExceptionHandlerMappingParityTest.java
common/r01b/UserRoleGuardTestSupport.java            ← 测试支持(非测试类)

修改测试(21) —— 仅接线,未放宽任何断言:

careerprogress/CareerProgressServiceTest.java                      ← 构造器参数补 guard(真实实例)
common/exception/GlobalExceptionHandler{AccountDeletion,HttpStatus,ReportBlock,StatusContract}Test.java  ← 构造器补 registry
candidatescore/governance/CandidateScoringGovernanceServiceTest.java
profile/ResumeServiceTest.java
review/JobReviewDimensionServiceTest.java
assessment/AssessmentAttemptServiceTest.java
match/MatchServiceTest.java
privacy/PrivacySettingsServiceTest.java
review/JobReviewServiceTest.java
job/JobFavoriteServiceTest.java
company/CompanyServiceTest.java
preference/PreferenceServiceTest.java
competition/CompetitionRankingServiceTest.java
ability/AbilityAttemptServiceTest.java
bidirectionalmatch/MatchRuleServiceTest.java
verification/CompanyVerificationServiceTest.java
talentdiscovery/TalentDiscoveryServiceTest.java
recommendation/JobRecommendationServiceTest.java

这 16 个 @InjectMocks 单元测试类新增了 @Mock UserRoleGuard 并在 @BeforeEach 通过 UserRoleGuardTestSupport.delegateToRealGuard(...) 把 mock 委托给真实 guard, 复用测试自身已 stub 的 UserRepository。这样"角色不符 → 403"的既有断言语义不变, 没有被削弱成"只验证调用了一次"。

文档(2)

docs/r01-b-authorization-exception-mapping-report.md   (新增)
docs/backend-development-roadmap.md                    (更新:A 系列完成 / 84fe9f5 / R01-B 状态 / 下一阶段 R01-C)

14. Flyway / ErrorCode / 接口 / DTO 是否变化

项 结果
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration 空 —— V1–V45 未改动
ErrorCode.java 未修改(数字、成员、message 全部不变)
接口(路径/方法/参数/响应结构) 未修改(未新增、未删除、未改签名)
DTO 未修改
docs/openapi-rc.json 未修改
依赖(pom.xml) 未修改
新依赖 未引入(guard 只用 Spring Data JPA 与既有 ErrorCode)

15. Git 状态

项 结果
分支 / HEAD refactor/r01-structure-performance / 84fe9f5…(未变)
git diff --cached --name-only 空
git diff --check 仅 LF/CRLF 警告,exit 0
已暂存 / 已提交 / 已推送 均无
历史未跟踪文件 未删除

16. 未暂存、未提交、未推送

确认:未暂存、未提交、未推送。 本轮未使用 git add、未创建提交、未推送; 未执行 reset/restore/checkout/clean;未操作 Docker;未修改 V1–V45。


17. 未进入 R01-C/E

确认:未进入 R01-C、未进入 R01-E。 本轮未处理 overviewStatus、推荐权重、 高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、 新接口与数据库迁移中的任何一项。


R01-B角色授权与异常映射收束完成,待独立代码复验。