diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 65c4988..73e9dab 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 GET 评分快照写入迁移方案设计完成(见 `docs/r01-d4-get-scoring-snapshot-migration-design.md`),待负责人确认后分阶段实施**(仅标记为迁移方案设计完成,不作为性能整改完成) | overview 与偏好口径已集中;百分位 O(n log n);数据导出与会话未读数均改为固定批量查询;R01-D4 确认三 GET 评分接口存在读取放大写入(upsert 快照),已产出写侧事件矩阵与 D4-1/D4-2 分阶段迁移方案 | +| 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)已完成独立复验、提交并推送**(提交 `72b82a322205da7cf70ffdb1a71933a690a38e12`);**R01-A3(第三批 8 个 Controller)已完成实施与独立复验,待提交**(报告 `docs/r01-a3-request-context-migration-report.md`、`docs/r01-a3-independent-verification-20260917.md`)。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 + A3 共迁移 16 个 Controller**(各 8 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A3 共删除 **16 个**私有解析方法(8 Controller × 2)。**剩余业务 Controller:身份解析 17 个、requestId 解析 21 个**,待后续批次 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-a3-independent-verification-20260917.md b/docs/r01-a3-independent-verification-20260917.md new file mode 100644 index 0000000..c5b7b36 --- /dev/null +++ b/docs/r01-a3-independent-verification-20260917.md @@ -0,0 +1,221 @@ +# R01-A3 独立代码复验报告(第三批:8 个 Controller 请求身份与 requestId 收束) + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变) +复验对象:`docs/r01-a3-request-context-migration-report.md` +性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送 + +--- + +## 0. 结论 + +> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。** + +**FAIL = 0(A3 范围内);SKIP = 0。** 另发现**基线已存在的非 A3 阻断缺陷** 1 项(§7),已用基线 worktree 证伪归因。 + +--- + +## 1. 边界确认 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| 工作区构成 | **A2(8 个 Controller)+ A3(8 个 Controller)** + 路线图 + `GlobalExceptionHandler`(stat 缓存,内容零差异)✔ | +| 归因纪律 | 本报告的 A3 证据**仅取自 A3 的 8 个文件路径**;A2 的 8 个文件未计入 A3 证据 ✔ | +| 未进入 A4 | ✔ | +| 未修改既有污染测试 | ✔ | + +--- + +## 2. A3 文件静态审查 + +`git diff -- <8 个 A3 Controller 精确路径>` 的改动逐行分类: + +| 改动类型 | 数量 | +|---|---| +| `Long userId = resolveUserId(request);` → `currentUserAccessor.requireUserId()` | 21 | +| 新增 `private final CurrentUserAccessor currentUserAccessor;` | 8 | +| 新增 `private final RequestIdAccessor requestIdAccessor;` | 8 | +| 删除 `import ...SecurityContextHolder;` | 8 | +| 删除 `import ...Authentication;` | 7 | +| 删除 `return request.getHeader("X-Request-Id");` | 7 | +| 删除 `return Long.valueOf(auth.getName());` | 7 | +| `getRequestId(request)` / `requestId(request)` → `requestIdAccessor.get(request)` | 其余全部调用点 | +| `riskAuditContext` 内 header 直读 → `requestIdAccessor.get(request)` | 1 | + +### 2.1 私有方法删除清单 —— **实际为 16 个(实施报告写 17,属笔误)** + +逐条核对(`git diff | grep '^-.*private .*(resolveUserId\|getUserId\|userId(\|getRequestId\|resolveRequestId\|requestId('`): + +``` +7× resolveUserId(HttpServletRequest) AccountDeletion / CompanyVerification / AdminRisk / + AdminReport / Report / Block / Notification +3× getRequestId(HttpServletRequest) AccountDeletion / CompanyVerification / Notification +4× requestId(HttpServletRequest) AdminRisk / AdminReport / Report / Block +1× userId() JobRecommendation(紧凑单行) +1× requestId(HttpServletRequest r) JobRecommendation(紧凑单行) +──────────────────────────────────────── += 16 +``` + +**这是本次复验发现的报告数字错误**:实施报告 §2.1 称「共删除私有解析方法 17 个」,实际为 **16 个**(8 个 Controller × 2)。行为本身正确(8 个文件门禁命中均为 0),仅报告计数有误。 + +`JobRecommendationController` 原压缩单行身份方法**已完整删除**: +```java +- private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); ... } +- private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");} +``` +其 9 处调用点全部改为 `currentUserAccessor.requireUserId()` / `requestIdAccessor.get(request)` ✔ + +### 2.2 注入方式 + +8 个 Controller 均为 `private final` 构造器注入;**无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` ✔ + +### 2.3 未漂移项 + +| 核查项 | 结论 | +|---|---| +| 角色检查位置 | **未移动** —— `verificationService.assertAdminRole(userId)`×4、`AdminRisk`/`AdminReport`/`Report` 内的 `userRepository.findById → getRoles → FORBIDDEN`、`JobRecommendation` 的 Service 层裁决,全部原样保留 ✔ | +| 资源归属检查 | 未移动 ✔ | +| Service 调用顺序 | 未变 ✔ | +| DTO / 错误码 / 响应结构 | 未变(`ErrorCode`、`SecurityConfig`、Flyway 均零修改)✔ | +| 是否混入 `UserRoleGuard` 或其他重构 | **否** ✔ | + +--- + +## 3. 独立测试(不复用实施者断言) + +新增两个独立测试类:`A3IndependentVerificationTest`(9 例)、`A3AccessorWiringProbeTest`(4 例)。 + +| 覆盖 | 结果 | +|---|---| +| 8 端点 × 8 非法主体(`anonymousUser`/空/空白/非数字/`abc123`/**20 位溢出**/`0`/`-1`) | 全部 **401**,无 500 ✔ | +| 匿名 token 与无 Token | 401 ✔(无 Token 由 Security 链提前返回,按要求**只断言 401**、不要求补 requestId) | +| 错误体安全 | `code=401`;`message ∈ {authentication required, not authenticated}`;无类名/堆栈/`principal`;非数字标记不回显(纯数字主体不参与该断言,避免与状态码/时间戳碰撞)✔ | +| 溢出主体 | 无 `NumberFormatException`;**解析 JSON 后仅检查业务字段**,未做整响应数字搜索 ✔ | +| 合法主体 | 不被身份层拒绝 ✔ | +| **角色语义未漂移** | 显式 stub 角色层抛 `FORBIDDEN` → **403**(非 401);`AdminRiskController` 桩造非 ADMIN 用户 → **403** ✔ | +| request attribute 优先于 header | ✔ | +| 响应 body 使用规范化 requestId(与响应头同源) | 5 种输入 × 8 端点 ✔ | +| CR/LF | 不进入规范化结果(直接经 Accessor 验证)✔ | +| **Accessor 接线变异检测** | mock 抛未登录 → 8 端点必须 401;mock requestId 必须出现在响应体;`verify(notificationService).list(42L, …)` —— **删除 Accessor 调用会使该测试失败** ✔ | + +--- + +## 4. 统计口径重新核对(任务书第四节) + +使用更完整的模式(含 `requestId(...)` 方法名与 `X-Request-Id` 直读): + +```bash +rg -l 'request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(' \ + mydreamjob-server/src/main/java -g "*.java" +``` + +**结果:25 个文件**(此前报告的 36 为**宽口径误计** —— 旧模式 `getRequestId` 还匹配到 `AuditContext#getRequestId` 等非请求入口调用)。 + +| 分类 | 数量 | 明细 | +|---|---|---| +| **合理基础设施** | **4** | `audit/AuditContext.java`、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java`、`deletion/DeletionGuardInterceptor.java` | +| **尚未迁移的业务 Controller** | **21** | `bidirectionalmatch/*`×3、`candidatescore/governance/`、`careerprogress/`、`controller/*`×3、`feedback/`、`interviewscore/`、`job/*`×2、`match/`、`review/*`×5、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/` | +| **Service 或非请求入口** | **0** | — | +| **误报** | **0** | — | + +**真实业务剩余数 = 21 个业务 Controller**(非「36 个文件」)。 + +身份解析: + +```bash +rg -l 'SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)' \ + mydreamjob-server/src/main/java -g "*.java" +``` + +**结果:20 个文件** = 基础设施 3(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`)+ **业务 Controller 17** + 误报 0。 + +**真实业务剩余数 = 17 个业务 Controller。** + +--- + +## 5. 验证结果 + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="R01A3…,A3IndependentVerificationTest,A3AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **51 / 51**,F=0 E=0 **S=0** | +| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **260 / 260**,F=0 E=**1**,BUILD FAILURE —— **全部为基线已存在的非 A3 缺陷**(§7) | +| `mvn clean verify` | **2220 / 2220**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | + +- 较实施轮次(2207)新增 **13** 个测试,全部为本次独立复验新增的两个测试类 +- **`docs/openapi-rc.json` 零修改**;**`ErrorCode` / Flyway / `SecurityConfig` 零修改** ✔ + +--- + +## 6. 独立真实 HTTP 验证 + +只读实例 PID 16604,验证后已停止。 + +| 场景 | 结果 | +|---|---| +| 无 token / 畸形 Bearer / 纯数字 Bearer × 8 端点 | 全部 **401** ✔ | +| STUDENT → ADMIN 风险概览 / 举报 / 认证审核 | **403** ✔ | +| ADMIN → 风险概览(合法) | **200** ✔ | +| STUDENT → 通知列表(合法) | **200** ✔ | +| **上述全部请求前后 22 张表逐字段比较** | **全部零变化** ✔ | + +--- + +## 7. 基线已存在的非 A3 阻断缺陷(如实记录,未修复) + +关联回归命令触发 1 个 Error: + +``` +AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75 +DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000' +``` + +**隔离复现(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A2/A3 改动**)中用**完全相同**的命令运行: + +| | 测试数 | 失败签名 | +|---|---|---| +| 基线(HEAD) | **223** | `AccountDeletionControllerTest...createUser:75`,`USERS(PHONE)='930000000'` | +| 本次(含 A2+A3) | **260** | **完全相同** | + +测试数差 37 = A2(20) + A3(8) + 本次独立新增(9),数字自洽。 + +**判定**:**基线已存在的非 A3 阻断缺陷**,与 A2/A3 无关。 + +- 根因:`AccountDeletionControllerTest` 的 `PHONE` 为 `static AtomicLong(930_000_000L)`,配合共享命名 H2 库(`integrationdb`),在特定测试子集顺序下与其他类残留数据冲突 +- 单跑该类:**14/14 全绿** +- `mvn clean verify`(全量顺序):**2220/2220,0 Error** + +**未修复**(超出本批授权范围),未修改该既有测试。建议后续单独处理。 + +--- + +## 8. 复验过程中的自暴露问题(非产品缺陷) + +| # | 问题 | 处置 | +|---|---|---| +| 1 | 独立测试首轮 `nonAdminOnAdminEndpoints_is403` 失败:`/admin/companies/verifications` 因 Service 为 `@MockBean` 未 stub 而 500 | 改为**显式 stub 角色层抛 `FORBIDDEN`** 并断言 403,这才是验证"角色语义未漂移"的正确方式 | +| 2 | 接线探针 `verify(notificationService).list(42L)` 参数个数错误(实际为 3 参) | 改为 `list(eq(42L), anyInt(), anyInt())` | + +--- + +## 9. 结论与下一步约束 + +> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。** + +**必须遵守的下一步约束**: + +1. **按文件与 hunk 精确拆分**为两个独立提交: + - 提交 1(A2):`ApplicationController`、`CompanyController`、`MessagingController`、`OfferController`、`InterviewController`、`CandidateScoringController`、`CandidateScoreCardController`、`CompanyScoringDashboardController`(8 个)+ A2 测试 + A2 报告 + - 提交 2(A3):`AccountDeletionController`、`CompanyVerificationController`、`AdminRiskController`、`AdminReportController`、`ReportController`、`BlockController`、`NotificationController`、`JobRecommendationController`(8 个)+ A3 测试 + A3 报告 + - `docs/backend-development-roadmap.md` 同时被两批修改,**必须用 `git add -p` 拆分 hunk** +2. **不得合并提交**。 +3. `GlobalExceptionHandler.java` 仅 stat 缓存标记(内容零差异),**不得提交**。 +4. 既有污染测试缺陷**不属 A2/A3**,若修复应作为独立提交,不得混入。 + +**复验完成前,未暂存、未提交、未推送、未进入 A4。** diff --git a/docs/r01-a3-request-context-migration-report.md b/docs/r01-a3-request-context-migration-report.md new file mode 100644 index 0000000..b1a3438 --- /dev/null +++ b/docs/r01-a3-request-context-migration-report.md @@ -0,0 +1,276 @@ +# R01-A3 第三批:请求身份与 requestId 收束报告 + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445` +范围:**仅 8 个 Controller**(AccountDeletionController、CompanyVerificationController、AdminRiskController、AdminReportController、ReportController、BlockController、NotificationController、JobRecommendationController) + +--- + +## 0. 结论 + +> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。** + +8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生任何漂移。 + +--- + +## 1. 开始前检查与一处预期偏差 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | +| 与上游同步 | `0 / 0` ✔ | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| 8 个目标文件在开始前 | **均未改动**(无 A3 以外的真实改动)✔ | +| `GlobalExceptionHandler.java` | 工作区/索引/HEAD blob 均为 `1fdc341e…`,**内容零差异**(stat 缓存标记),未处理、未暂存 ✔ | + +**预期偏差(如实记录)**:任务书要求「HEAD 必须包含 A2 提交」。**实际 HEAD 为 `52c8026`(R01-D4 提交),A2 尚未提交** —— 上一轮 A2 独立复验明确「未暂存、未提交、未推送」,其改动仍在工作区。因此本批是在 A2 未提交的工作区之上继续实施的。A2 与 A3 同属请求上下文收束、互不冲突;两者将一并进入后续暂存检查点。 + +--- + +## 2. 迁移前契约表 + +### 2.1 私有解析方法(迁移前 → 迁移后) + +| Controller | 迁移前私有方法 | 迁移后 | +|---|---|---| +| AccountDeletionController | `resolveUserId(HttpServletRequest)` + `getRequestId(HttpServletRequest)` | `currentUserAccessor.requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 | +| CompanyVerificationController | `resolveUserId` + `getRequestId` | ×6;×6 | +| AdminRiskController | `resolveUserId` + `requestId` + `riskAuditContext` 内直读 header | `requireUserId()` ×1(在 `requireAdmin` 内);`requestIdAccessor.get(request)` ×8(含 `riskAuditContext`) | +| AdminReportController | `resolveUserId` + `requestId` | ×1;×3 | +| ReportController | `resolveUserId` + `requestId` | ×3;×3 | +| BlockController | `resolveUserId` + `requestId` | ×3;×3 | +| NotificationController | `resolveUserId` + `getRequestId` | ×4;×4 | +| JobRecommendationController | `userId()`(**无参紧凑式**)+ `requestId(HttpServletRequest)` | ×7;×8 | + +**共删除私有解析方法 16 个**(8 个 Controller × 2:7× `resolveUserId` + 3× `getRequestId` + 4× `requestId` + 1× `userId()` + 1× `requestId(r)`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import(8 个文件均移除)。每个 Controller 新增: + +```java +/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ +private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; +/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ +private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; +``` + +### 2.2 每个端点的错误码契约(迁移前后一致) + +| Controller | 代表端点 | 401 | 403 | 404 | 业务码 | +|---|---|---|---|---|---| +| AccountDeletionController | `GET /profile/me/deletion` | 未登录 | 9002 保留(未使用) | — | 409:9301/9305/9306 | +| CompanyVerificationController | `GET /companies/me/verification` | 未登录 | `assertAdminRole` → 403 | 8501 | — | +| AdminRiskController | `GET /admin/scoring/risk/overview` | 未登录 | `requireAdmin` → 403 | 4001 | 9001 非法过滤 | +| AdminReportController | `GET /admin/reports` | 未登录 | `requireAdmin` → 403 | 4001 | — | +| ReportController | `GET /reports/me` | 未登录 | 举报自身 9608 | 4001 | — | +| BlockController | `GET /blocks/me` | 未登录 | — | 4001 目标不存在 | 400/9608 屏蔽自己 | +| NotificationController | `GET /notifications` | 未登录 | — | — | — | +| JobRecommendationController | `GET /profile/me/job-recommendations` | 未登录 | Service 层角色校验 | — | — | + +**角色检查位置未移动**:`CompanyVerificationController` 的 `verificationService.assertAdminRole(userId)`×4、`AdminRiskController`/`AdminReportController`/`ReportController` 内的 `userRepository.findById → getRoles → FORBIDDEN` 判断、`JobRecommendationController` 的 Service 层角色校验,全部**原样保留**。 + +**失败请求前的数据库写入**:8 个 Controller 的写操作仅出现在**身份解析之后**;身份失败在任何 Service 调用之前抛出,不存在"先写后拒"。 + +--- + +## 3. 修复前失败测试(TDD 证据) + +新增 `R01A3RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/`),使用真实 `SecurityFilterChain` + `MockMvc`。 + +**修复前实测(仅加入测试、未改生产代码):`Tests run: 8, Failures: 2`** + +| 失败测试 | 修复前实际 | +|---|---| +| `illegalPrincipals_areAlways401` | `path=/profile/me/deletion principal='anonymousUser'` → **`expected: 401 but was: 500`** | +| `errorBody_isSafeAndDoesNotEchoPrincipal` | 同一路径 **`expected: 401 but was: 500`** | + +根因:`Long.valueOf("anonymousUser")` 抛 `NumberFormatException` 逃逸为 HTTP 500。 + +**修复后:`Tests run: 8, Failures: 0`** + +--- + +## 4. 逐文件静态门禁 + +任务书指定模式在 8 个文件中的命中: + +| Controller | 命中 | +|---|---| +| AccountDeletionController | **0** | +| CompanyVerificationController | **0** | +| AdminRiskController | **0** | +| AdminReportController | **0** | +| ReportController | **0** | +| BlockController | **0** | +| NotificationController | **0** | +| JobRecommendationController | **0** | + +模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor` + +**全部为 0,无任何需要解释的业务参数命中。身份/requestId 私有解析残留为 0。** + +--- + +## 5. 全仓剩余数量(独立复验后的修正统计) + +> **修正说明**:本节初稿使用的 requestId 模式(`getRequestId|resolveRequestId|request.getHeader("X-Request-Id")`)为**宽口径**,会额外匹配 `AuditContext#getRequestId` 等**非请求入口**的调用,因此得出的「36 个文件」**不是真实剩余数**。以下为 R01-A3 独立复验用更完整模式重新核对后的权威统计。 + +**身份解析**(模式:`SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)`) + +| 分类 | 数量 | +|---|---| +| 全仓命中文件 | **20** | +| —— 合理基础设施 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | +| —— **尚未迁移的业务 Controller** | **17** | +| —— 误报 | 0 | + +**requestId 解析**(模式:`request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(`) + +| 分类 | 数量 | +|---|---| +| 全仓命中文件 | **25** | +| —— 合理基础设施 | **4**(`audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor`) | +| —— **尚未迁移的业务 Controller** | **21** | +| —— Service 或非请求入口 | **0** | +| —— 误报 | **0** | + +**A2 + A3 合计已迁移 16 个业务 Controller**,本批(A3)未覆盖的剩余业务 Controller 为**身份解析 17 个、requestId 解析 21 个**,均未修改。 + +--- + +## 6. 角色与副作用验证(真实 HTTP) + +只读实例(`SCORING_REFRESH_ENABLED=false`)PID 22228,验证后已停止。 + +| 场景 | 结果 | +|---|---| +| STUDENT → ADMIN 风险概览 | **403** ✔ | +| STUDENT → ADMIN 举报管理 | **403** ✔ | +| STUDENT → ADMIN 认证审核 | **403** ✔ | +| COMPANY → ADMIN 风险概览 | **403** ✔ | +| ADMIN → 风险概览(合法路径) | **200** ✔ | +| STUDENT → 企业认证查询 | **403** ✔ | +| STUDENT → 通知列表(合法路径) | **200** ✔ | +| 无 token → 通知列表 | **401** ✔ | + +**副作用**:上述全部请求前后 **22 张表逐字段比较,全部零变化** ✔ —— 覆盖 `account_deletions`、`company_verifications`、`scoring_risk_actions`、`reports`、`block_relations`、`notifications`、推荐反馈等业务表;失败请求**未新增审计、通知或 RabbitMQ 消息**(`messages` 表亦零变化)。 + +--- + +## 7. requestId 同源证据 + +| 输入 | 结果 | +|---|---| +| 合法值 `a3-legal-0001` | 响应头 == 响应体,原样保留 ✔ | +| 空白 `" "` | 重新生成非空值,响应头 == 响应体 ✔ | +| 超长(80 字符) | 收敛至 ≤32,响应头 == 响应体 ✔ | +| 非法字符 ``、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格;响应体 == 响应头 ✔ | +| 已存在规范化 request attribute | **attribute 优先于 header** ✔ | +| CR / LF / CRLF(直接经 Accessor 验证) | 不进入规范化结果 ✔ | + +覆盖 8 个端点的全部上述输入组合。 + +--- + +## 8. 测试数量与 OpenAPI + +| 命令 | 结果 | +|---|---| +| A3 定向(`R01A3…,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **38 / 38**,F=0 E=0 **S=0** | +| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **251 / 251**,F=0 E=0 **S=0**(1 项既有污染详见 §9) | +| `mvn test` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | +| `mvn clean verify` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | + +- 较 A2 轮次(2199)新增 **8** 个测试,全部为本批新增的 `R01A3RequestContextMigrationIntegrationTest` +- **`docs/openapi-rc.json` 零修改** ✔ +- **未新增公网接口、迁移、表、索引或 ErrorCode** ✔ + +--- + +## 9. 一处既有测试污染(非本批引入,已用基线 worktree 证伪) + +关联回归首次运行出现 1 个 Error: + +``` +AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75 +DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000' +``` + +**归因验证**:用 `git worktree add --detach HEAD`(HEAD 不含本批改动)在**基线代码**上运行**完全相同**的测试批次: + +``` +基线(HEAD):Tests run: 223, Failures: 0, Errors: 1 ← 完全相同的失败 +``` + +**结论**:该失败是**既有的测试间交叉污染**(`AccountDeletionControllerTest` 的 `PHONE` 为 static 计数器、基数 `930000000`,配合共享 H2 库 `integrationdb`,在特定测试子集顺序下与其他类残留数据冲突),**与本批改动无关**。 + +- 单独运行该类:**14/14 全绿** ✔ +- `mvn test` 与 `mvn clean verify` 全量运行:**2207/2207,0 Error** ✔(全量顺序下不触发) + +该缺陷未在本批修复(属既有测试脆弱性,超出本批授权范围),建议后续单独处理;本批未修改任何既有测试。 + +--- + +## 10. 精确修改文件 + +**生产代码(8 个,全部在任务书指定清单内)** + +``` +mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java +mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java +mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java +mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java +mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java +mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java +mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java +mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java +``` + +(`8 files changed, 100 insertions(+), 154 deletions(-)`) + +**测试(新增 1 个)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java +``` + +**文档(1 个)** + +``` +docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节 +``` + +**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、`UserRoleGuard`、既有测试、"匿名 401 无 requestId"观察项。 + +--- + +## 11. Git 状态 + +``` +分支:refactor/r01-structure-performance +HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变) +暂存区:空 +git add -A / commit / push:均未执行 +reset / restore / checkout / clean:均未执行 +git worktree:临时基线 worktree 已用 --force 移除并 prune(无残留) +历史未跟踪文件(247 个,含 docs/acceptance、backend-infra 等):完整保留 +Docker:未操作 +``` + +验收临时用户(8025/8026)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。 + +--- + +## 12. 明确声明 + +- **未暂存、未提交、未推送。** +- 未修改角色检查位置、资源归属检查、Service、DTO、ErrorCode、Flyway、SecurityConfig、Filter 顺序、既有测试。 +- 未进入 `UserRoleGuard`;未处理"匿名 401 无 requestId"观察项。 +- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。 +- 全仓剩余身份/requestId 解析数量已如实报告(含 7 而非 8 的差异说明),未声称全仓完成。 + +--- + +> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java index 271801c..d5c707a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java @@ -14,8 +14,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,6 +25,11 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class AccountDeletionController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final AccountDeletionService accountDeletionService; @Operation( @@ -57,9 +60,9 @@ public class AccountDeletionController { }) @PostMapping public ResponseEntity> initiate(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - accountDeletionService.initiate(userId), getRequestId(request))); + accountDeletionService.initiate(userId), requestIdAccessor.get(request))); } @Operation( @@ -82,9 +85,9 @@ public class AccountDeletionController { }) @GetMapping public ResponseEntity> getStatus(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - accountDeletionService.getCurrentStatus(userId), getRequestId(request))); + accountDeletionService.getCurrentStatus(userId), requestIdAccessor.get(request))); } @Operation( @@ -112,20 +115,9 @@ public class AccountDeletionController { }) @PostMapping("/cancel") public ResponseEntity> cancel(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - accountDeletionService.cancel(userId), getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); + accountDeletionService.cancel(userId), requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java index 2951e31..2c0a9c3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java @@ -7,8 +7,6 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -21,6 +19,11 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class NotificationController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final NotificationService notificationService; @Operation(summary = "通知列表", description = "当前用户的通知,分页,按时间倒序") @@ -29,7 +32,7 @@ public class NotificationController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page result = notificationService.list(userId, page, size); Map data = new HashMap<>(); data.put("items", result.getContent().stream().map(notificationService::toView).collect(Collectors.toList())); @@ -37,7 +40,7 @@ public class NotificationController { data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "标记已读", description = "标记单条通知已读(幂等)") @@ -45,36 +48,25 @@ public class NotificationController { public ResponseEntity>> markRead( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); notificationService.markRead(userId, id); - return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), requestIdAccessor.get(request))); } @Operation(summary = "全部已读", description = "标记当前用户全部通知已读(幂等)") @PostMapping("/read-all") public ResponseEntity>> markAllRead(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); int marked = notificationService.markAllRead(userId); - return ResponseEntity.ok(ApiResponse.ok(Map.of("marked", marked), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(Map.of("marked", marked), requestIdAccessor.get(request))); } @Operation(summary = "未读数", description = "当前用户未读通知数") @GetMapping("/unread-count") public ResponseEntity>> unreadCount(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); long count = notificationService.unreadCount(userId); - return ResponseEntity.ok(ApiResponse.ok(Map.of("unreadCount", count), getRequestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - return Long.valueOf(auth.getName()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("unreadCount", count), requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java index d182d1c..05ed3cf 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java @@ -10,7 +10,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.security.core.*; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -18,49 +17,52 @@ import java.util.List; @RestController @RequiredArgsConstructor public class JobRecommendationController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final JobRecommendationService service; @GetMapping("/job-recommendation-taxonomies") public ApiResponse> taxonomies(HttpServletRequest request) { - return ApiResponse.ok(service.taxonomies(), requestId(request)); + return ApiResponse.ok(service.taxonomies(), requestIdAccessor.get(request)); } @GetMapping("/profile/me/job-recommendations") public ApiResponse> recommendations(@RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="20") int size, HttpServletRequest request) { - Page result = service.recommend(userId(), page, size); - return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request)); + Page result = service.recommend(currentUserAccessor.requireUserId(), page, size); + return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestIdAccessor.get(request)); } @PostMapping("/profile/me/job-recommendations/{jobId}/feedback") public ApiResponse feedback(@PathVariable Long jobId,@Valid @RequestBody RecommendationFeedbackRequest body,HttpServletRequest request) { - return ApiResponse.ok(service.feedback(userId(),jobId,body,AuditContext.from(request)),requestId(request)); + return ApiResponse.ok(service.feedback(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request)); } @DeleteMapping("/profile/me/job-recommendations/{jobId}/feedback") public ApiResponse revokeFeedback(@PathVariable Long jobId,@RequestParam Long expectedVersion,HttpServletRequest request) { - service.revokeNotInterested(userId(),jobId,expectedVersion,AuditContext.from(request)); return ApiResponse.ok(null,requestId(request)); + service.revokeNotInterested(currentUserAccessor.requireUserId(),jobId,expectedVersion,AuditContext.from(request)); return ApiResponse.ok(null,requestIdAccessor.get(request)); } @GetMapping("/companies/me/jobs/{jobId}/recommendation-profile") public ApiResponse profile(@PathVariable Long jobId,HttpServletRequest request) { - return ApiResponse.ok(service.companyProfile(userId(),jobId),requestId(request)); + return ApiResponse.ok(service.companyProfile(currentUserAccessor.requireUserId(),jobId),requestIdAccessor.get(request)); } @PutMapping("/companies/me/jobs/{jobId}/recommendation-profile") public ApiResponse updateProfile(@PathVariable Long jobId,@Valid @RequestBody RecommendationProfileRequest body,HttpServletRequest request) { - return ApiResponse.ok(service.upsertCompanyProfile(userId(),jobId,body,AuditContext.from(request)),requestId(request)); + return ApiResponse.ok(service.upsertCompanyProfile(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request)); } @GetMapping("/admin/job-recommendation-profiles") public ApiResponse> adminProfiles(@RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="20") int size,@RequestParam(required=false) String status,HttpServletRequest request) { - Page result=service.adminProfiles(userId(),page,size,status); return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request)); + Page result=service.adminProfiles(currentUserAccessor.requireUserId(),page,size,status); return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestIdAccessor.get(request)); } @PostMapping("/admin/job-recommendation-profiles/{jobId}/decision") public ApiResponse decide(@PathVariable Long jobId,@Valid @RequestBody RecommendationDecisionRequest body,HttpServletRequest request) { - return ApiResponse.ok(service.decide(userId(),jobId,body,AuditContext.from(request)),requestId(request)); + return ApiResponse.ok(service.decide(currentUserAccessor.requireUserId(),jobId,body,AuditContext.from(request)),requestIdAccessor.get(request)); } - private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); if(a==null||!a.isAuthenticated())throw new BusinessException(ErrorCode.UNAUTHORIZED); return Long.valueOf(a.getName()); } - private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");} } 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 02e7ab3..3f1fedc 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java @@ -22,8 +22,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -49,6 +47,11 @@ import java.util.List; @RequiredArgsConstructor public class AdminRiskController { + /** 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; @@ -66,7 +69,7 @@ public class AdminRiskController { HttpServletRequest request) { requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok( - riskGovernanceService.overview(parse(from), parse(to)), requestId(request))); + riskGovernanceService.overview(parse(from), parse(to)), requestIdAccessor.get(request))); } @Operation(summary = "风险趋势(ADMIN)") @@ -84,7 +87,7 @@ public class AdminRiskController { HttpServletRequest request) { requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok( - riskGovernanceService.trend(bucket, parse(from), parse(to)), requestId(request))); + riskGovernanceService.trend(bucket, parse(from), parse(to)), requestIdAccessor.get(request))); } @Operation(summary = "风险分布(ADMIN)") @@ -101,7 +104,7 @@ public class AdminRiskController { HttpServletRequest request) { requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok( - riskGovernanceService.distribution(parse(from), parse(to)), requestId(request))); + riskGovernanceService.distribution(parse(from), parse(to)), requestIdAccessor.get(request))); } @Operation(summary = "风险事件分页(ADMIN)") @@ -126,7 +129,7 @@ public class AdminRiskController { page, size, status, severity, ruleCode, parse(from), parse(to)); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "风险规则列表(ADMIN)") @@ -138,7 +141,7 @@ public class AdminRiskController { @GetMapping("/rules") public ResponseEntity>> rules(HttpServletRequest request) { requireAdmin(request); - return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.listRules(), requestIdAccessor.get(request))); } @Operation(summary = "更新风险规则(ADMIN)") @@ -156,7 +159,7 @@ public class AdminRiskController { HttpServletRequest request) { Long adminUserId = requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.updateRule( - ruleCode, req, adminUserId, riskAuditContext(request)), requestId(request))); + ruleCode, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request))); } @Operation(summary = "风险事件人工操作(ADMIN)") @@ -174,11 +177,11 @@ public class AdminRiskController { HttpServletRequest request) { Long adminUserId = requireAdmin(request); return ResponseEntity.ok(ApiResponse.ok(riskGovernanceService.actionEvent( - eventId, req, adminUserId, riskAuditContext(request)), requestId(request))); + eventId, req, adminUserId, riskAuditContext(request)), requestIdAccessor.get(request))); } private Long requireAdmin(HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); User admin = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); boolean isAdmin = admin.getRoles() != null @@ -189,21 +192,9 @@ public class AdminRiskController { return userId; } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } - /** 风险审计上下文:仅保留 requestId,禁止写入 IP/UA/请求体等。 */ private AuditContext riskAuditContext(HttpServletRequest request) { - return AuditContext.builder().requestId(request.getHeader("X-Request-Id")).build(); + return AuditContext.builder().requestId(requestIdAccessor.get(request)).build(); } private LocalDateTime parse(String s) { 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 067fa6d..a99cd9e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java @@ -17,8 +17,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; /** @@ -32,6 +30,11 @@ import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor public class AdminReportController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final ReportService reportService; private final UserRepository userRepository; @@ -54,7 +57,7 @@ public class AdminReportController { Page result = reportService.adminList(status, targetType, category, page, size); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "举报详情(ADMIN)", @@ -70,7 +73,7 @@ public class AdminReportController { public ResponseEntity> adminDetail(@PathVariable Long id, HttpServletRequest request) { requireAdmin(request); - return ResponseEntity.ok(ApiResponse.ok(reportService.adminDetail(id), requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(reportService.adminDetail(id), requestIdAccessor.get(request))); } @Operation(summary = "举报处理决定(ADMIN)", @@ -95,11 +98,11 @@ public class AdminReportController { ? (admin.getRole() == null ? "ADMIN" : admin.getRole().name()) : "ADMIN"; ReportResponse data = reportService.decide(adminUserId, role, id, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } private Long requireAdmin(HttpServletRequest request) { - Long userId = resolveUserId(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); @@ -109,15 +112,4 @@ public class AdminReportController { return userId; } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java index 5dd597b..37482d2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java @@ -13,8 +13,6 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; /** @@ -26,6 +24,11 @@ import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor public class BlockController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final BlockService blockService; @Operation(summary = "屏蔽用户", @@ -40,9 +43,9 @@ public class BlockController { @PutMapping("/blocks/{blockedUserId}") public ResponseEntity> block( @PathVariable Long blockedUserId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); BlockRelationResponse data = blockService.block(userId, blockedUserId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "解除屏蔽", @@ -55,9 +58,9 @@ public class BlockController { @DeleteMapping("/blocks/{blockedUserId}") public ResponseEntity> unblock( @PathVariable Long blockedUserId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); blockService.unblock(userId, blockedUserId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(null, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(null, requestIdAccessor.get(request))); } @Operation(summary = "我的屏蔽列表", @@ -71,22 +74,11 @@ public class BlockController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page result = blockService.listMine(userId, page, size); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); - } - - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); + requestIdAccessor.get(request))); } - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java index c2d047c..f21687f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java @@ -17,8 +17,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; /** @@ -32,6 +30,11 @@ import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor public class ReportController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final ReportService reportService; private final UserRepository userRepository; @@ -51,9 +54,9 @@ public class ReportController { @PostMapping("/reports") public ResponseEntity> create( @Valid @RequestBody CreateReportRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ReportResponse data = reportService.create(userId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } /** /reports/me 必须在 /reports/{id} 之前注册(路由回归测试覆盖) */ @@ -69,11 +72,11 @@ public class ReportController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page result = reportService.listMine(userId, status, page, size); return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), - requestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "我的举报详情", @@ -88,9 +91,9 @@ public class ReportController { @GetMapping("/reports/{id}") public ResponseEntity> getDetail( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ReportResponse data = reportService.getDetail(userId, id, isAdmin(userId)); - return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } private boolean isAdmin(Long userId) { @@ -100,15 +103,4 @@ public class ReportController { .orElse(false); } - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java index fb4995c..80eed45 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java @@ -14,8 +14,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -34,6 +32,11 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class CompanyVerificationController { + /** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final CompanyVerificationService verificationService; // ── 企业:提交认证 ────────────────────────────────────────── @@ -43,10 +46,10 @@ public class CompanyVerificationController { public ResponseEntity>> submit( @Valid @RequestBody VerificationSubmitRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CompanyVerification verification = verificationService.submit(userId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 企业:查询认证状态 ────────────────────────────────────── @@ -55,9 +58,9 @@ public class CompanyVerificationController { @GetMapping("/companies/me/verification") public ResponseEntity>> getMyVerification( HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CompanyVerification verification = verificationService.getMyVerification(userId); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 管理员:待审核列表 ────────────────────────────────────── @@ -71,7 +74,7 @@ public class CompanyVerificationController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); Page result = verificationService.listPending(status, from, to, page, size); Map data = new HashMap<>(); @@ -80,7 +83,7 @@ public class CompanyVerificationController { data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 管理员:认证详情 ──────────────────────────────────────── @@ -90,10 +93,10 @@ public class CompanyVerificationController { public ResponseEntity>> getDetail( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); CompanyVerification verification = verificationService.getDetail(id); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 管理员:审核决定 ──────────────────────────────────────── @@ -104,11 +107,11 @@ public class CompanyVerificationController { @PathVariable Long id, @Valid @RequestBody VerificationDecisionRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); CompanyVerification verification = verificationService.decide(userId, id, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), requestIdAccessor.get(request))); } // ── 管理员:审核历史 ──────────────────────────────────────── @@ -118,22 +121,12 @@ public class CompanyVerificationController { public ResponseEntity>>> listHistory( @RequestParam Long verificationId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); verificationService.assertAdminRole(userId); List> data = verificationService.getHistory(verificationId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 辅助 ──────────────────────────────────────────────────── - private Long resolveUserId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - return Long.valueOf(auth.getName()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java new file mode 100644 index 0000000..9ab4625 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3AccessorWiringProbeTest.java @@ -0,0 +1,128 @@ +package com.mydreamjob.common.r01a3; + +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.deletion.AccountDeletionService; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.recommendation.JobRecommendationService; +import com.mydreamjob.risk.RiskGovernanceService; +import com.mydreamjob.safety.BlockService; +import com.mydreamjob.safety.ReportService; +import com.mydreamjob.security.CurrentUserAccessor; +import com.mydreamjob.verification.CompanyVerificationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-A3 独立复验:**接线变异检测**。 + * + *

将 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 替换为 mock,证明 + * 8 个 Controller 确实调用了这两个既有 bean。若某 Controller 仍保留自有解析 + * (未接线),mock 抛未登录异常时它不会返回 401,测试即失败。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A3AccessorWiringProbeTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private CurrentUserAccessor currentUserAccessor; + @MockBean private RequestIdAccessor requestIdAccessor; + + @MockBean private AccountDeletionService accountDeletionService; + @MockBean private CompanyVerificationService verificationService; + @MockBean private RiskGovernanceService riskGovernanceService; + @MockBean private ReportService reportService; + @MockBean private BlockService blockService; + @MockBean private NotificationService notificationService; + @MockBean private JobRecommendationService jobRecommendationService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + private static final List PATHS = List.of( + "/profile/me/deletion", + "/companies/me/verification", + "/admin/scoring/risk/overview", + "/admin/reports", + "/reports/me", + "/blocks/me", + "/notifications", + "/profile/me/job-recommendations"); + + private static final UsernamePasswordAuthenticationToken LEGAL = + new UsernamePasswordAuthenticationToken("42", "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + + @Test + @DisplayName("A3 接线检测:Accessor 抛未登录时八端点必须 401") + void accessorThrowsUnauthenticated_endpointsReturn401() throws Exception { + when(currentUserAccessor.requireUserId()) + .thenThrow(new InsufficientAuthenticationException("authentication required")); + + for (String path : PATHS) { + int st = mockMvc.perform(get(path).with(authentication(LEGAL))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s 若未接线 Accessor,不会是 401", path).isEqualTo(401); + } + verify(currentUserAccessor, times(PATHS.size())).requireUserId(); + } + + @Test + @DisplayName("A3 接线检测:响应体 requestId 来自 Accessor mock") + void requestIdComesFromAccessorBean() throws Exception { + when(currentUserAccessor.requireUserId()).thenReturn(42L); + when(requestIdAccessor.get(any())).thenReturn("a3-probe-from-accessor"); + + for (String path : PATHS) { + String body = mockMvc.perform(get(path).with(authentication(LEGAL))) + .andReturn().getResponse().getContentAsString(); + assertThat(body).as("path=%s 响应体 requestId 应来自 Accessor", path) + .contains("\"requestId\":\"a3-probe-from-accessor\""); + } + Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any()); + } + + @Test + @DisplayName("A3 接线检测:合法主体经 Accessor 后可达 Service") + void legalPrincipalReachesService() throws Exception { + when(currentUserAccessor.requireUserId()).thenReturn(42L); + when(requestIdAccessor.get(any())).thenReturn("a3-probe"); + + mockMvc.perform(get("/notifications").with(authentication(LEGAL))).andReturn(); + + verify(notificationService).list(eq(42L), anyInt(), anyInt()); + } + + @Test + @DisplayName("A3 接线检测:无 Token 仍 401(Security 链,不经 Controller)") + void noToken_is401() throws Exception { + for (String path : PATHS) { + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java new file mode 100644 index 0000000..e23c3f7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/A3IndependentVerificationTest.java @@ -0,0 +1,220 @@ +package com.mydreamjob.common.r01a3; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.deletion.AccountDeletionService; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.recommendation.JobRecommendationService; +import com.mydreamjob.risk.RiskGovernanceService; +import com.mydreamjob.safety.BlockService; +import com.mydreamjob.safety.ReportService; +import com.mydreamjob.verification.CompanyVerificationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-A3 独立复验(不复用实施者的测试类与断言)。 + * + *

超长/溢出主体不做整响应数字搜索,解析 JSON 后仅检查业务字段。

+ * + *

说明:无 Token 请求由 Security 链提前返回 401,本批不要求其携带 requestId, + * 因此对该类请求**只断言 401**。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A3IndependentVerificationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private RequestIdAccessor requestIdAccessor; + + @MockBean private AccountDeletionService accountDeletionService; + @MockBean private CompanyVerificationService verificationService; + @MockBean private RiskGovernanceService riskGovernanceService; + @MockBean private ReportService reportService; + @MockBean private BlockService blockService; + @MockBean private NotificationService notificationService; + @MockBean private JobRecommendationService jobRecommendationService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final List PATHS = List.of( + "/profile/me/deletion", + "/companies/me/verification", + "/admin/scoring/risk/overview", + "/admin/reports", + "/reports/me", + "/blocks/me", + "/notifications", + "/profile/me/job-recommendations"); + + private static final List ILLEGAL = List.of( + "anonymousUser", "", " ", "not-a-number", "abc123", + "12345678901234567890", "0", "-1"); + + private static Authentication principal(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + @Test + @DisplayName("独立复验:八端点 × 八非法主体 一律 401、无 500") + void illegalPrincipals_are401_never500() throws Exception { + for (String path : PATHS) { + for (String p : ILLEGAL) { + int st = mockMvc.perform(get(path).with(authentication(principal(p)))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s principal='%s'", path, p).isEqualTo(401); + } + } + } + + @Test + @DisplayName("独立复验:匿名 token 与无 Token 均 401(无 Token 只断言 401)") + void anonymousAndNoToken_are401() throws Exception { + Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + for (String path : PATHS) { + assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn() + .getResponse().getStatus()).as(path).isEqualTo(401); + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + } + + @Test + @DisplayName("独立复验:错误体安全,不回显非数字标记/类名/堆栈") + void errorBody_isSafe() throws Exception { + for (String path : PATHS) { + for (String p : ILLEGAL) { + String body = mockMvc.perform(get(path).with(authentication(principal(p)))) + .andReturn().getResponse().getContentAsString(); + JsonNode json = MAPPER.readTree(body); + assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401); + String msg = json.path("message").asText(); + assertThat(msg).isIn("authentication required", "not authenticated"); + assertThat(msg).doesNotContain("Exception").doesNotContain("java.") + .doesNotContain("principal").doesNotContain("at com.mydreamjob"); + assertThat(json.has("stackTrace")).isFalse(); + assertThat(json.has("trace")).isFalse(); + if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { + assertThat(msg).doesNotContain(p); + } + } + } + } + + @Test + @DisplayName("独立复验:Long 溢出主体无 NumberFormatException") + void overflow_hasNoNumberFormatException() throws Exception { + for (String path : PATHS) { + String body = mockMvc.perform(get(path) + .with(authentication(principal("12345678901234567890")))) + .andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain("NumberFormatException"); + } + } + + @Test + @DisplayName("独立复验:合法主体不被身份层拒绝") + void legalPrincipal_notRejectedByIdentity() throws Exception { + for (String path : PATHS) { + int st = mockMvc.perform(get(path).with(authentication(principal("7")))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s", path).isNotEqualTo(401); + } + } + + @Test + @DisplayName("独立复验:角色层拒绝仍映射 403(身份通过后由角色层裁决)") + void roleDenial_stillMapsTo403() throws Exception { + when(redisTemplate.opsForValue()) + .thenReturn(org.mockito.Mockito.mock( + org.springframework.data.redis.core.ValueOperations.class)); + + // CompanyVerificationController 的角色裁决委托给 Service,显式让其抛 FORBIDDEN, + // 验证"身份通过之后由角色层裁决"的语义未漂移为 401。 + org.mockito.Mockito.doThrow(new com.mydreamjob.common.exception.BusinessException( + com.mydreamjob.common.exception.ErrorCode.FORBIDDEN)) + .when(verificationService).assertAdminRole(anyLong()); + + int st = mockMvc.perform(get("/admin/companies/verifications") + .with(authentication(principal("7")))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("角色拒绝必须仍为 403,而非 401").isEqualTo(403); + + // AdminRiskController 的角色裁决在 Controller 内(userRepository → FORBIDDEN) + when(userRepository.findById(anyLong())) + .thenReturn(java.util.Optional.of(com.mydreamjob.auth.User.builder() + .id(7L).phone("1").role(com.mydreamjob.auth.User.Role.STUDENT) + .roles(java.util.Set.of(com.mydreamjob.auth.User.Role.STUDENT)) + .active(true).build())); + int st2 = mockMvc.perform(get("/admin/scoring/risk/overview") + .with(authentication(principal("7")))) + .andReturn().getResponse().getStatus(); + assertThat(st2).as("非 ADMIN 必须仍为 403,而非 401").isEqualTo(403); + } + + @Test + @DisplayName("独立复验:request attribute 优先于原始 header") + void requestAttributeWins() { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-a3-wins"); + req.addHeader("X-Request-Id", "header-a3-loses"); + assertThat(requestIdAccessor.get(req)).isEqualTo("attr-a3-wins"); + } + + @Test + @DisplayName("独立复验:响应 body 使用规范化 requestId(与响应头同源)") + void responseBodyUsesNormalizedRequestId() throws Exception { + for (String sent : List.of("a3v-legal-1", " ", "z".repeat(90), "x", "a b")) { + for (String path : PATHS) { + var r = mockMvc.perform(get(path).header("X-Request-Id", sent) + .with(authentication(principal("7")))).andReturn(); + String hdr = r.getResponse().getHeader("X-Request-Id"); + assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull() + .doesNotContain("<").doesNotContain(">").doesNotContain(" ") + .doesNotContain("\r").doesNotContain("\n"); + assertThat(hdr.length()).isLessThanOrEqualTo(32); + String body = r.getResponse().getContentAsString(); + assertThat(body).as("path=%s", path) + .contains("\"requestId\":\"" + hdr + "\""); + } + } + } + + @Test + @DisplayName("独立复验:CR/LF 不入规范化结果(直接经 Accessor)") + void crlfNeverEntersValue() { + for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "x\r\nInjected: 1")) { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader("X-Request-Id", bad); + String v = requestIdAccessor.get(req); + assertThat(v).as("输入 %s", bad).isNotBlank() + .doesNotContain("\r").doesNotContain("\n"); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..da611ee --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java @@ -0,0 +1,195 @@ +package com.mydreamjob.common.r01a3; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.deletion.AccountDeletionService; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.recommendation.JobRecommendationService; +import com.mydreamjob.risk.RiskGovernanceService; +import com.mydreamjob.safety.BlockService; +import com.mydreamjob.safety.ReportService; +import com.mydreamjob.security.CurrentUserAccessor; +import com.mydreamjob.verification.CompanyVerificationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-A3 第三批:请求身份与 requestId 收束的特征测试。 + * + *

覆盖 8 个 Controller 的代表端点(以源码真实 Mapping 为准)。

+ * + *

对溢出/非数字主体**不做整响应数字子串搜索**,而是解析 JSON 后只检查业务字段。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class R01A3RequestContextMigrationIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private RequestIdAccessor requestIdAccessor; + + @MockBean private AccountDeletionService accountDeletionService; + @MockBean private CompanyVerificationService verificationService; + @MockBean private RiskGovernanceService riskGovernanceService; + @MockBean private ReportService reportService; + @MockBean private BlockService blockService; + @MockBean private NotificationService notificationService; + @MockBean private JobRecommendationService jobRecommendationService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 8 个 Controller 的真实代表端点。 */ + private static final List PATHS = List.of( + "/profile/me/deletion", // AccountDeletionController + "/companies/me/verification", // CompanyVerificationController + "/admin/scoring/risk/overview", // AdminRiskController + "/admin/reports", // AdminReportController + "/reports/me", // ReportController + "/blocks/me", // BlockController + "/notifications", // NotificationController + "/profile/me/job-recommendations"); // JobRecommendationController + + private static final List ILLEGAL = List.of( + "anonymousUser", "", " ", "not-a-number", "abc123", + "12345678901234567890", "0", "-1"); + + private static Authentication auth(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + // ── 身份矩阵 ───────────────────────────────────────────── + + @Test + @DisplayName("A3:八端点 × 八非法主体 一律 401(非 400/500)") + void illegalPrincipals_areAlways401() throws Exception { + for (String path : PATHS) { + for (String p : ILLEGAL) { + int st = mockMvc.perform(get(path).with(authentication(auth(p)))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401); + } + } + } + + @Test + @DisplayName("A3:匿名 token 与无认证均 401") + void anonymousAndMissing_are401() throws Exception { + Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + for (String path : PATHS) { + assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn() + .getResponse().getStatus()).as(path).isEqualTo(401); + assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) + .as(path).isEqualTo(401); + } + } + + @Test + @DisplayName("A3:错误体为固定安全文案,不回显类名/堆栈/principal") + void errorBody_isSafeAndDoesNotEchoPrincipal() throws Exception { + for (String path : PATHS) { + for (String p : ILLEGAL) { + String body = mockMvc.perform(get(path).with(authentication(auth(p)))) + .andReturn().getResponse().getContentAsString(); + JsonNode json = MAPPER.readTree(body); + assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401); + String msg = json.path("message").asText(); + assertThat(msg).isIn("authentication required", "not authenticated"); + assertThat(msg).doesNotContain("Exception").doesNotContain("java.") + .doesNotContain("principal").doesNotContain("at com.mydreamjob"); + assertThat(json.has("stackTrace")).isFalse(); + assertThat(json.has("trace")).isFalse(); + // 非数字标记不回显(纯数字主体与状态码/时间戳碰撞,不参与此断言) + if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { + assertThat(msg).doesNotContain(p); + } + } + } + } + + @Test + @DisplayName("A3:溢出主体不产生 NumberFormatException(响应中无该异常)") + void overflowPrincipal_hasNoNumberFormatException() throws Exception { + for (String path : PATHS) { + String body = mockMvc.perform(get(path) + .with(authentication(auth("12345678901234567890")))) + .andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain("NumberFormatException"); + } + } + + @Test + @DisplayName("A3:合法主体继续进入原角色/归属检查(不被身份层拒绝)") + void legalPrincipal_reachesRoleLayer() throws Exception { + for (String path : PATHS) { + int st = mockMvc.perform(get(path).with(authentication(auth("7")))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401); + } + } + + // ── requestId 矩阵 ─────────────────────────────────────── + + @Test + @DisplayName("A3:requestId 合法/空白/超长/非法字符 均同源") + void requestId_consistentAcrossInputs() throws Exception { + for (String sent : List.of("a3-legal-0001", " ", "y".repeat(80), "", "a b")) { + for (String path : PATHS) { + var r = mockMvc.perform(get(path).header("X-Request-Id", sent) + .with(authentication(auth("7")))).andReturn(); + String hdr = r.getResponse().getHeader("X-Request-Id"); + assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull() + .doesNotContain("\r").doesNotContain("\n") + .doesNotContain("<").doesNotContain(">").doesNotContain(" "); + assertThat(hdr.length()).isLessThanOrEqualTo(32); + String body = r.getResponse().getContentAsString(); + assertThat(body).as("path=%s 响应体 requestId 应与响应头同源", path) + .contains("\"requestId\":\"" + hdr + "\""); + } + } + } + + @Test + @DisplayName("A3:已存在规范化 request attribute 时以 attribute 为准") + void requestAttributeWinsOverHeader() { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-wins"); + req.addHeader("X-Request-Id", "header-loses"); + assertThat(requestIdAccessor.get(req)).isEqualTo("attr-wins"); + } + + @Test + @DisplayName("A3:CR/LF 不进入规范化结果(直接调用 Accessor)") + void crlfNeverEntersNormalizedValue() { + for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "a\r\nX-Injected: 1")) { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader("X-Request-Id", bad); + String v = requestIdAccessor.get(req); + assertThat(v).as("输入 %s", bad).isNotBlank() + .doesNotContain("\r").doesNotContain("\n"); + } + } +}