diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 565e019..8337c17 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | -| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施与独立代码复验**(报告 `docs/r01-a4-request-context-migration-report.md`、`docs/r01-a4-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 + A4 共迁移 24 个 Controller**(三批各 8 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。每批各删除 **16 个**私有解析方法(8 Controller × 2)。**剩余业务 Controller:身份解析 9 个、requestId 解析 13 个**(全仓身份 12 文件 = 基础设施 3 + 业务 9;全仓 requestId 19 文件 = 基础设施/非业务入口 6 + 业务 13),待后续批次 | +| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成实施与自动化验证,待独立代码复验**(报告 `docs/r01-a5-request-context-migration-report.md`)。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A5 共迁移 32 个 Controller**(四批各 8 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。每批各删除 **16 个**私有解析方法(8 Controller × 2)。**剩余业务 Controller:身份解析 1 个、requestId 解析 5 个**(全仓身份 4 文件 = 基础设施 3 + 业务 1;全仓 requestId 11 文件 = 基础设施/非业务入口 6 + 业务 5),待最终 A6 批次 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-a5-independent-verification-20260917.md b/docs/r01-a5-independent-verification-20260917.md new file mode 100644 index 0000000..f70c44f --- /dev/null +++ b/docs/r01-a5-independent-verification-20260917.md @@ -0,0 +1,279 @@ +# R01-A5 独立代码复验报告(第五批:8 个 Controller 请求身份与 requestId 收束) + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新提交,复验全程未变) +复验对象:`docs/r01-a5-request-context-migration-report.md` +性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送 + +--- + +## 0. 结论 + +> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** + +**FAIL = 0;SKIP = 0。未发现生产缺陷。** + +--- + +## 1. 开始前状态 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新)✔ | +| 与上游同步 | `0 / 0` ✔ | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| 工作区改动 | **仅 8 个 A5 Controller + 路线图**,无 A5 之外的生产代码改动 ✔ | +| 历史未跟踪文件 | 全部保留 ✔ | +| reset/restore/checkout/clean、`git add -A`、Docker | 均未执行 ✔ | + +### `GlobalExceptionHandler.java` 三方 blob 比对 + +| 来源 | blob | +|---|---| +| worktree(`git hash-object`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | +| index(`git ls-files -s`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | +| HEAD(`git rev-parse HEAD:`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | + +**三者完全一致 → 纯 Windows stat 缓存现象,未触碰该文件** ✔ + +--- + +## 2. 8 个 Controller 静态审查 + +| Controller | 代表端点 | Accessor 调用(auth / reqId) | +|---|---|---| +| CandidateScoringGovernanceController | `GET /profile/me/candidate-scoring/appeals` | 9 / 9 | +| InterviewScoreController | `GET /interviews/{id}/score` | 4 / 4 | +| FavoriteController | `GET /jobs/favorites` | 3 / 3 | +| AdminReviewController | `GET /admin/reviews/pending` | 3 / 3 | +| AdminReviewReplyController | `GET /admin/review-replies/pending` | 2 / 2 | +| CompanyReviewManagementController | `GET /companies/me/job-reviews` | 4 / 4 | +| JobReviewController | `DELETE /jobs/{jobId}/reviews/{reviewId}` | 3 / 7 | +| StudentReviewWeightController | `GET /profile/me/review-weights` | 3 / 4 | + +(`JobReviewController` / `StudentReviewWeightController` 的 reqId 调用数高于 auth 属正常 —— 部分只读端点只需 requestId。) + +**改动性质**:`8 files changed, 101 insertions(+), 198 deletions(-)`,删除的 `ErrorCode.UNAUTHORIZED` 共 10 处,**全部来自被移除的私有解析方法**;**角色检查(`assertAdminRole`/`assertCompanyOwner`/`assertStudentRole`)、资源归属、`FORBIDDEN`/`NOT_FOUND` 及 Service 调用顺序未删未增、未移动** ✔ + +**`JobReviewController.parseIds` 完整保留** ✔ —— 它解析 `ids` 查询参数(业务参数,非认证主体),并以 `catch (NumberFormatException) → BAD_REQUEST` 处理,属正确的业务参数语义,未被误删或误判。 + +--- + +## 3. 禁止模式扫描 + +任务书指定模式在 8 个文件中的命中:**全部为 0** ✔ + +覆盖:`SecurityContextHolder`、`AnonymousAuthenticationToken`、`anonymousUser`、`auth.getName()`、`authentication.getName()`、`Long.(valueOf|parseLong)(...(auth|authentication)`、`private (resolveUserId|getRequestId|requestId)(`、`getHeader(...X-Request-Id|REQUEST_ID_HEADER)`、`@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor`、`new RequestIdAccessor` + +--- + +## 4. 构造器注入与无回退证明 + +8 个 Controller 均声明: + +```java +private final CurrentUserAccessor currentUserAccessor; +private final RequestIdAccessor requestIdAccessor; +``` + +- 全部为 **`private final` 构造器注入**(Lombok `@RequiredArgsConstructor`)✔ +- **无** `@Autowired(required = false)` / `@Nullable` / `ObjectProvider` / `new CurrentUserAccessor()` / `new RequestIdAccessor()` 回退 ✔ +- 身份统一为 `currentUserAccessor.requireUserId()`;requestId 统一为 `requestIdAccessor.get(request)` ✔ + +--- + +## 5. 独立接线变异测试(Accessor 真实生效的证明) + +新增 `A5AccessorWiringIndependentTest`(4 例),**不复用实施者断言**,使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与 8 个 Service: + +| 用例 | 证明 | +|---|---| +| `allEightEndpoints_useAccessorUserId` | mock 返回 `4242L` → 逐一验证 **8 个 Service 均收到 `4242L`**(`listMyAppeals` / `getScore` / `listFavorites` / `listPendingReviews` / `listPending` / `listMine` / `deleteReview` / `getMyWeights`),并 `verify(currentUserAccessor, times(8)).requireUserId()` | +| `illegalIdentity_rejectedBeforeService` | mock 抛 `InsufficientAuthenticationException` → 8 端点全部 401、无 500,且各 Service **零调用** | +| `responseBodyRequestId_comesFromAccessor` | mock 返回 `a5-independent-request` → 8 个端点响应体 `requestId` 均为该值,`verify(requestIdAccessor, times(8)).get(...)` | +| `noToken_is401` | 无 Token → Security 链 401 | + +**变异有效性**:若删除任一 Controller 中的 `currentUserAccessor.requireUserId()` 或 `requestIdAccessor.get(request)` 调用,对应 `verify` 与断言必然失败 —— 即**删除 Accessor 调用会使本测试失败** ✔ + +> 说明:响应头 `X-Request-Id` 由 `RequestIdFilter` 设置,**不来自 Controller**;本测试只断言**响应体**的 `requestId` 来源于 Accessor,未错误声称响应头来源。 + +--- + +## 6. 八端点非法主体结果(独立身份边界复验) + +`A5IndependentVerificationTest` 使用**真实 `CurrentUserAccessor` 与真实 `SecurityFilterChain`**(不 mock Accessor): + +| 主体 | 结果 | +|---|---| +| `anonymousUser` | **401** ✔ | +| `not-a-number` | **401** ✔ | +| `99999999999999999999`(20 位溢出) | **401** ✔ | +| `0` | **401** ✔ | +| `-1` | **401** ✔ | +| `AnonymousAuthenticationToken` / 无 Token | **401** ✔ | + +逐端点断言(7 个 GET 端点 + `DELETE /jobs/1/reviews/1`): +- **HTTP 401,无 500** ✔ +- **业务 Service 零调用** ✔(`verify(..., never())`) +- **不回显非法主体** —— 仅解析 JSON 后检查 `code`/`message` 稳定字段,**未对整段 JSON 做数字搜索** ✔ +- 响应不包含 `Exception` / `java.` / `principal` / `anonymousUser` / `com.mydreamjob` / `NumberFormat` ✔ + +**未使用**任何捕获 `NumberFormatException` 的方式"修复"测试。 + +--- + +## 7. 合法主体传入 Service 的 userId 证据 + +§5 的 `verify` 系列即为证据:mock Accessor 返回 `4242L` 时,**8 个 Service 各自收到 `4242L`**(逐一 `eq(4242L)` 断言)✔ + +同时 `A5IndependentVerificationTest#legalPrincipal_reachesService` 使用**真实 Accessor**(数字主体 `"7"`)验证 8 端点**不被身份层判为未登录**(非 401)✔ + +--- + +## 8. requestId Accessor 接线证据 + +见 §5 第 3 项:mock `RequestIdAccessor#get` 返回固定值 → 8 个端点(7 个 GET + 1 个 DELETE,均返回 `ApiResponse`)响应体 `requestId` 均为 `a5-independent-request`,并 `verify(requestIdAccessor, times(8)).get(any(HttpServletRequest.class))` ✔ + +--- + +## 9. 401 / 403 / 404 分层保持 + +| 检查项 | 结果 | +|---|---| +| 静态审查:角色/归属检查是否被删改或移动 | **否**(`git diff` 确认未删未增)✔ | +| `FORBIDDEN` / `NOT_FOUND` 错误码 | **未修改** ✔ | +| `ErrorCode.java` | **零改动** ✔ | +| 合法主体不被判为未登录 | ✔(§7) | + +**401 与 403/404 的分层未被统一**:身份失败为 401,角色/归属失败仍由既有 Service 逻辑产生 403/404,语义未漂移。 + +--- + +## 10. 失败请求的业务副作用 + +新增 `A5SideEffectSnapshotTest`(2 例),使用 H2 **行级快照**(显式列 + 稳定 `ORDER BY`,**逐字段**比较,**非 COUNT/SUM**): + +快照覆盖 9 张核心表:`JOB_FAVORITES`、`JOB_REVIEWS`、`COMPANY_REVIEW_REPLIES`、`INTERVIEW_SCORES`、`CANDIDATE_SCORING_APPEALS`、`STUDENT_REVIEW_WEIGHT_PREFERENCES`、`NOTIFICATIONS`、`MESSAGES`、`AUDIT_LOGS`。 + +| 用例 | 结果 | +|---|---| +| 非法身份请求(`anonymousUser` + `not-a-number` × 7 端点 + DELETE)前后 | **9 张表逐字段零变化** ✔ | +| 无 Token 请求前后 | **9 张表逐字段零变化** ✔ | + +**结论**:非法身份请求不写收藏、评价、回复、面试评分、治理申诉、权重;不发通知;不发 RabbitMQ 消息;不写审计成功记录(`AUDIT_LOGS` 零变化)✔ + +> 本轮**未启动 Docker**,以上为 H2 独立复验结论;**不声称真实 MySQL 结论**。 + +--- + +## 11. 测试数 + +| 命令 | 结果 | +|---|---| +| `mvn -Dtest="A5AccessorWiringIndependentTest,A5IndependentVerificationTest" test` | **8 / 8**,F=0 E=0 **S=0** | +| `mvn -Dtest="A5SideEffectSnapshotTest" test` | **2 / 2**,F=0 E=0 **S=0** | +| A5 定向(`R01A5…,A5*Test,` 8 个 Controller 测试 + 3 个 Accessor/Filter 测试) | **98 / 98**,F=0 E=0 **S=0**,BUILD SUCCESS | +| `mvn clean verify` | **2255 / 2255**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | + +- 实施基线 2245 → 复验后 **2255**(新增 **10** 个独立测试:4 + 4 + 2),**不低于基线** ✔ +- 未见新的基线测试污染 ✔ + +--- + +## 12. OpenAPI + +`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔ + +**`docs/openapi-rc.json` 零修改** ✔ + +--- + +## 13. Flyway / ErrorCode / DTO / 接口 + +| 项 | 结果 | +|---|---| +| V1–V45 迁移 | **零修改**(`git diff --name-only -- db/migration` 为空)✔ | +| `ErrorCode.java` | **零改动** ✔ | +| DTO | 零改动 ✔ | +| 公网接口 / OpenAPI | 零新增零修改 ✔ | +| 新增表 / 索引 | 无 ✔ | + +--- + +## 14. 全仓剩余统计(逐文件分类) + +**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **4 文件** + +| 分类 | 文件 | +|---|---| +| 基础设施(3) | `security/CurrentUserAccessor`(Accessor 自身)、`auth/JwtAuthenticationFilter`(认证过滤器)、`deletion/DeletionGuardInterceptor`(拦截器) | +| **业务 Controller(1)** | `scoringmonitoring/FairnessMonitoringController` | +| 误报 | 0 | + +**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **10 文件** + +| 分类 | 文件 | +|---|---| +| 基础设施 / 非业务入口(5) | `audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestIdAccessor`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor` | +| **业务 Controller(5)** | `controller/ApiController`、`controller/AuthController`、`controller/SystemController`、`job/JobController`、`scoringmonitoring/FairnessMonitoringController` | +| 误报 | 0 | + +**与路线图记数的一处差异(如实记录)**:路线图 R01 行记「全仓 requestId 11 文件 = 基础设施/非业务入口 6 + 业务 5」。本次独立复验用任务书指定模式实测为 **10 文件 = 基础设施 5 + 业务 5**。**业务 Controller 数一致(5)**;差异在基础设施计数(5 vs 6),推测为路线图对"基础设施/非业务入口"的分类口径略有不同(如是否计入某调用点)。**不应据此修改代码或路线图**;建议后续批次统一口径。 + +--- + +## 15. 精确新增文件 + +``` +新增测试(3) + mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java + mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java + mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java + +复验报告(1) + docs/r01-a5-independent-verification-20260917.md +``` + +**未修改**:8 个 A5 Controller 生产代码、既有测试断言、`GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 + +--- + +## 16. Git 状态 + +``` +分支:refactor/r01-structure-performance +HEAD:0226977063243fce101e3f6c8dcf66492d306ef7(未变) +暂存区:空 +git add -A / commit / push:均未执行 +reset / restore / checkout / clean:均未执行 +Docker:未操作 +工作区:8 个 A5 Controller + 路线图(实施者改动,仍未暂存)+ 本报告与 3 个独立测试(未跟踪) +历史未跟踪文件:全部保留 +``` + +--- + +## 17. 是否发现生产缺陷 + +**未发现生产缺陷。** 8 个 Controller 的迁移完整、正确: +- 禁止模式 0 命中;构造器注入无回退;`parseIds` 业务解析正确保留 +- 角色/归属/403/404 语义未漂移 +- 独立接线变异测试证明 Accessor 真实生效(删除调用即失败) +- 独立身份边界测试证明非法主体在业务调用前被拒(401、无 500、零 Service 调用)且不回显主体 +- 失败请求 9 张核心表逐字段零变化 + +--- + +## 18. 明确声明 + +- **未暂存、未提交、未推送。** +- 未修改 8 个 Controller 或任何生产代码;未修改、删除或放宽既有测试断言;未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 +- 未进入 R01-A6;未删除历史未跟踪文件。 +- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。 + +--- + +> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** diff --git a/docs/r01-a5-request-context-migration-report.md b/docs/r01-a5-request-context-migration-report.md new file mode 100644 index 0000000..f81cc8a --- /dev/null +++ b/docs/r01-a5-request-context-migration-report.md @@ -0,0 +1,79 @@ +# R01-A5 第五批请求上下文收束实施报告 + +实施日期:2026-09-17 + +分支:`refactor/r01-structure-performance` + +基线 HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7` + +## 结论 + +R01-A5 第五批请求身份与 requestId 收束已完成自动化验证,待独立代码复验。 + +## 修改范围 + +本批仅修改以下 8 个 Controller: + +1. `CandidateScoringGovernanceController` +2. `InterviewScoreController` +3. `FavoriteController` +4. `AdminReviewController` +5. `AdminReviewReplyController` +6. `CompanyReviewManagementController` +7. `JobReviewController` +8. `StudentReviewWeightController` + +每个 Controller 均执行相同的受控迁移: + +- 构造器注入既有 `CurrentUserAccessor` 与 `RequestIdAccessor`; +- 身份统一调用 `currentUserAccessor.requireUserId()`; +- requestId 统一调用 `requestIdAccessor.get(request)`; +- 删除本地身份解析方法和 requestId 读取方法; +- 保留原有角色、资源归属、Service 参数、DTO 和响应结构。 + +`JobReviewController.parseIds` 是岗位 ID 业务参数解析,予以保留。 + +## 静态门禁 + +8 个目标文件对以下模式均为零命中: + +- `SecurityContextHolder` / `Authentication` / `anonymousUser`; +- 身份 `Long.valueOf(auth.getName())`; +- `resolveUserId` / `getRequestId` / 私有 `requestId(...)`; +- 原始 `X-Request-Id` header 读取; +- 可选注入、`ObjectProvider` 或手工 `new Accessor()` 回退。 + +## 新增验证 + +新增 `R01A5RequestContextMigrationIntegrationTest`,使用真实 Spring Security 与 MVC 映射: + +- 8 个 Controller 各选一个真实端点; +- 非法主体覆盖 `anonymousUser`、非数字、20 位 Long 溢出、0、负数; +- 所有组合必须返回 HTTP 401,且响应不含异常类、堆栈或非法主体; +- 合法主体下验证规范化 requestId 的响应头与响应体同源。 + +定向与关联测试:90/90,Failures=0,Errors=0,Skipped=0。 + +## 全量验证 + +- `mvn clean verify`:BUILD SUCCESS,2245/2245,Failures=0,Errors=0,Skipped=0; +- `python docs/verify_openapi.py`:176 paths / 214 operations,All checks passed; +- V1-V45 无差异; +- `docs/openapi-rc.json` 无差异; +- `git diff --check` 无空白错误,仅既有 LF/CRLF 提示。 + +## 全仓剩余 + +完成 A5 后: + +- 身份解析共 4 个文件:基础设施 3 个,业务 Controller 仅 `FairnessMonitoringController` 1 个; +- requestId 入口共 11 个文件:基础设施/非业务入口 6 个,业务 Controller 5 个; +- 最终 A6 业务白名单为 `FairnessMonitoringController`、`ApiController`、`AuthController`、`SystemController`、`JobController`。 + +## 边界 + +- 未新增或修改 Flyway、ErrorCode、接口、DTO、表、索引或依赖; +- 未修改评分、申诉、面试评分、岗位收藏或评价业务规则; +- 未暂存、未提交、未推送; +- 未删除或移动历史未跟踪文件; +- 未进入 A6。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java index 722bc22..2d99f29 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java @@ -7,8 +7,8 @@ import com.mydreamjob.candidatescore.governance.dto.AppealResponse; import com.mydreamjob.candidatescore.governance.dto.CreateAppealRequest; import com.mydreamjob.candidatescore.governance.dto.SnapshotHistoryItem; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -16,8 +16,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.*; import java.util.HashMap; @@ -33,14 +31,16 @@ import java.util.Map; public class CandidateScoringGovernanceController { private final CandidateScoringGovernanceService governanceService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "提交评分申诉", description = "仅 STUDENT 本人;同一学生+维度+周期 PENDING/APPROVED 唯一") @PostMapping("/profile/me/candidate-scoring/appeals") public ResponseEntity> createAppeal( @Valid @RequestBody CreateAppealRequest req, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.createAppeal(userId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "我的申诉列表", description = "仅 STUDENT 本人") @@ -50,27 +50,27 @@ public class CandidateScoringGovernanceController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); Page result = governanceService.listMyAppeals(userId, status, page, size); - return ResponseEntity.ok(ApiResponse.ok(pageView(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(pageView(result), requestIdAccessor.get(request))); } @Operation(summary = "申诉详情", description = "本人或 ADMIN") @GetMapping("/profile/me/candidate-scoring/appeals/{appealId}") public ResponseEntity> getAppeal( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.getAppeal(userId, appealId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "撤销申诉", description = "仅本人;仅 PENDING 可撤销") @PostMapping("/profile/me/candidate-scoring/appeals/{appealId}/cancel") public ResponseEntity> cancelAppeal( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.cancelAppeal(userId, appealId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "全部申诉列表", description = "仅 ADMIN") @@ -80,9 +80,9 @@ public class CandidateScoringGovernanceController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); Page result = governanceService.listAllAppeals(userId, status, page, size); - return ResponseEntity.ok(ApiResponse.ok(pageView(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(pageView(result), requestIdAccessor.get(request))); } @Operation(summary = "审核决定", description = "仅 ADMIN;APPROVE/REJECT,REJECT 必须填 reason") @@ -90,27 +90,27 @@ public class CandidateScoringGovernanceController { public ResponseEntity> decideAppeal( @PathVariable Long appealId, @Valid @RequestBody AppealDecisionRequest req, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.decideAppeal(userId, appealId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "执行回滚", description = "仅 ADMIN;记录前态+确定性重算+新版本;年 3 次上限") @PostMapping("/admin/candidate-scoring/appeals/{appealId}/rollback") public ResponseEntity> rollbackAppeal( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); AppealResponse data = governanceService.rollbackAppeal(userId, appealId, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "申诉历史", description = "本人(脱敏,无 actorUserId)或 ADMIN(全量);此路径的本人放行先于 ADMIN 通用规则") @GetMapping("/admin/candidate-scoring/appeals/{appealId}/history") public ResponseEntity>> getAppealHistory( @PathVariable Long appealId, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); List data = governanceService.getAppealHistory(userId, appealId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "快照变更审计", description = "仅 ADMIN;按学生+维度+周期查看回滚前后完整变化") @@ -120,10 +120,10 @@ public class CandidateScoringGovernanceController { @PathVariable String dimension, @PathVariable String period, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); List data = governanceService.getSnapshotHistory( userId, candidateUserId, dimension, period); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 辅助 ──────────────────────────────────────────────────── @@ -138,15 +138,4 @@ public class CandidateScoringGovernanceController { return data; } - private Long resolveUserId() { - 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java index 0dfb616..68bf1a2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java @@ -2,19 +2,17 @@ package com.mydreamjob.interviewscore; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem; import com.mydreamjob.interviewscore.dto.InterviewScoreResponse; import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; 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.*; import java.util.List; @@ -31,6 +29,8 @@ import java.util.List; public class InterviewScoreController { private final InterviewScoreService interviewScoreService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "保存评分草稿", description = "企业为已完成的面试保存评分草稿(支持重复保存)") @PutMapping("/interviews/{interviewId}/score") @@ -38,10 +38,10 @@ public class InterviewScoreController { @PathVariable Long interviewId, @Valid @RequestBody InterviewScoreSaveRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); InterviewScoreResponse data = interviewScoreService.saveDraft(userId, interviewId, req, AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "查看评分", description = "企业 owner 可查看草稿与已提交;学生只能在提交后查看") @@ -49,9 +49,9 @@ public class InterviewScoreController { public ResponseEntity> get( @PathVariable Long interviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - interviewScoreService.getScore(userId, interviewId), getRequestId(request))); + interviewScoreService.getScore(userId, interviewId), requestIdAccessor.get(request))); } @Operation(summary = "提交评分", description = "将草稿提交为终态,提交后不可修改") @@ -59,10 +59,10 @@ public class InterviewScoreController { public ResponseEntity> submit( @PathVariable Long interviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( interviewScoreService.submitScore(userId, interviewId, AuditContext.from(request)), - getRequestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "评分历史", description = "查看评分快照历史;无历史返回空数组") @@ -70,20 +70,8 @@ public class InterviewScoreController { public ResponseEntity>> history( @PathVariable Long interviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( - interviewScoreService.getHistory(userId, interviewId), getRequestId(request))); + interviewScoreService.getHistory(userId, interviewId), 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/main/java/com/mydreamjob/job/FavoriteController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java index 127a127..bdd95da 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java @@ -1,14 +1,14 @@ package com.mydreamjob.job; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.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; @@ -20,15 +20,17 @@ import java.util.Map; public class FavoriteController { private final JobFavoriteService favoriteService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "收藏岗位", description = "仅 STUDENT;仅 PUBLISHED;幂等") @PostMapping("/jobs/{jobId}/favorite") public ResponseEntity>> favorite( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map result = favoriteService.favorite(userId, jobId); - return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request))); } @Operation(summary = "取消收藏", description = "仅 STUDENT;幂等,未收藏也返回 200") @@ -36,9 +38,9 @@ public class FavoriteController { public ResponseEntity>> unfavorite( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map result = favoriteService.unfavorite(userId, jobId); - return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request))); } @Operation(summary = "我的收藏列表", description = "仅 STUDENT;分页,按收藏时间倒序") @@ -47,7 +49,7 @@ public class FavoriteController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page> result = favoriteService.listFavorites(userId, page, size); Map data = new HashMap<>(); data.put("items", result.getContent()); @@ -55,19 +57,6 @@ public class FavoriteController { data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); - return ResponseEntity.ok(ApiResponse.ok(data, 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()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java index 2981663..b0cdeb9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java @@ -1,9 +1,9 @@ package com.mydreamjob.review; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.ReviewDecisionRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -12,8 +12,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; @@ -30,6 +28,8 @@ import java.util.Map; public class AdminReviewController { private final JobReviewService reviewService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "待审评价列表", description = "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA") @GetMapping("/pending") @@ -43,10 +43,10 @@ public class AdminReviewController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page> result = reviewService.listPendingReviews( userId, jobId, riskLevel, createdFrom, createdTo, page, size); - return ResponseEntity.ok(ApiResponse.ok(toPage(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(toPage(result), requestIdAccessor.get(request))); } @Operation(summary = "评价审核决策", description = "仅 ADMIN;仅 PENDING 可审,重复提交 409;决策写入历史与 audit_logs") @@ -55,11 +55,11 @@ public class AdminReviewController { @PathVariable Long reviewId, @Valid @RequestBody ReviewDecisionRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = reviewService.decideReview( userId, reviewId, req.getDecision(), req.getReason(), com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "审核历史", description = "仅 ADMIN;分页返回审核结果、审核人、时间与原因") @@ -68,9 +68,9 @@ public class AdminReviewController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Page> result = reviewService.listModerationHistory(userId, page, size); - return ResponseEntity.ok(ApiResponse.ok(toPage(result), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(toPage(result), requestIdAccessor.get(request))); } private Map toPage(Page> result) { @@ -83,14 +83,4 @@ public class AdminReviewController { return data; } - 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java index 6d6dd70..7ef8a63 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java @@ -2,11 +2,11 @@ package com.mydreamjob.review; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.AdminReviewReplyPageResponse; import com.mydreamjob.review.dto.AdminReviewReplyResponse; import com.mydreamjob.review.dto.ReviewReplyDecisionRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -14,8 +14,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; 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.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -31,6 +29,8 @@ import java.time.LocalDateTime; public class AdminReviewReplyController { private final AdminReviewReplyService adminService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "待审官方回复", description = "仅 ADMIN;分页查看风险回复") @GetMapping("/admin/review-replies/pending") @@ -45,9 +45,9 @@ public class AdminReviewReplyController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(adminService.listPending(userId, companyId, jobId, riskLevel, - createdFrom, createdTo, page, size), requestId(request))); + createdFrom, createdTo, page, size), requestIdAccessor.get(request))); } @Operation(summary = "官方回复审核决策", description = "仅 ADMIN;PENDING 回复可 APPROVE 或 REJECT") @@ -56,23 +56,8 @@ public class AdminReviewReplyController { @PathVariable Long replyId, @Valid @RequestBody ReviewReplyDecisionRequest requestBody, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(adminService.decide(userId, replyId, requestBody, - AuditContext.from(request)), requestId(request))); - } - - private Long resolveUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - try { - return Long.valueOf(auth.getName()); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + AuditContext.from(request)), requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java index e9299c1..70b47d4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java @@ -2,13 +2,13 @@ package com.mydreamjob.review; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.CompanyReviewDetailResponse; import com.mydreamjob.review.dto.CompanyReviewReplyResponse; import com.mydreamjob.review.dto.CreateCompanyReviewReplyRequest; import com.mydreamjob.review.dto.ReviewReplyPageResponse; import com.mydreamjob.review.dto.UpdateCompanyReviewReplyRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -16,8 +16,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; 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.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -35,6 +33,8 @@ public class CompanyReviewManagementController { private final CompanyReviewManagementService managementService; private final CompanyReviewReplyService replyService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "企业评价列表", description = "仅岗位所属 COMPANY;分页查看本企业岗位评价与回复状态") @GetMapping("/companies/me/job-reviews") @@ -50,17 +50,17 @@ public class CompanyReviewManagementController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(managementService.listMine(userId, jobId, rating, - reviewModerationStatus, replyStatus, createdFrom, createdTo, page, size), requestId(request))); + reviewModerationStatus, replyStatus, createdFrom, createdTo, page, size), requestIdAccessor.get(request))); } @Operation(summary = "企业评价详情", description = "仅岗位所属 COMPANY;不返回评价作者身份") @GetMapping("/companies/me/job-reviews/{reviewId}") public ResponseEntity> detail( @PathVariable Long reviewId, HttpServletRequest request) { - Long userId = resolveUserId(); - return ResponseEntity.ok(ApiResponse.ok(managementService.getMine(userId, reviewId), requestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(managementService.getMine(userId, reviewId), requestIdAccessor.get(request))); } @Operation(summary = "创建官方回复", description = "仅岗位所属 COMPANY;安全文本自动公开,风险文本进入管理员审核") @@ -69,9 +69,9 @@ public class CompanyReviewManagementController { @PathVariable Long reviewId, @Valid @RequestBody CreateCompanyReviewReplyRequest requestBody, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(replyService.create(userId, reviewId, requestBody, - AuditContext.from(request)), requestId(request))); + AuditContext.from(request)), requestIdAccessor.get(request))); } @Operation(summary = "修订被驳回回复", description = "仅 REJECTED 回复可修订;使用 expectedVersion 防并发覆盖") @@ -80,23 +80,8 @@ public class CompanyReviewManagementController { @PathVariable Long reviewId, @Valid @RequestBody UpdateCompanyReviewReplyRequest requestBody, HttpServletRequest request) { - Long userId = resolveUserId(); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(replyService.revise(userId, reviewId, requestBody, - AuditContext.from(request)), requestId(request))); - } - - private Long resolveUserId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) - throw new BusinessException(ErrorCode.UNAUTHORIZED); - try { - return Long.valueOf(auth.getName()); - } catch (NumberFormatException ex) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - } - - private String requestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + AuditContext.from(request)), requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java index 63bf36b..c9de4e4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java @@ -3,8 +3,10 @@ package com.mydreamjob.review; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.JobReviewCreateRequest; import com.mydreamjob.review.dto.JobReviewUpdateRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -12,8 +14,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.*; import java.util.ArrayList; @@ -27,6 +27,8 @@ import java.util.Map; public class JobReviewController { private final JobReviewService reviewService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; @Operation(summary = "创建岗位评价", description = "仅 STUDENT;同一学生对同一岗位只能评价一次") @PostMapping("/jobs/{jobId}/reviews") @@ -34,10 +36,10 @@ public class JobReviewController { @PathVariable Long jobId, @Valid @RequestBody JobReviewCreateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = reviewService.createReview(userId, jobId, req, com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "查询岗位评价列表", description = "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分") @@ -54,7 +56,7 @@ public class JobReviewController { 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 = "公开访问;返回单条 PUBLISHED 评价与八维评分") @@ -64,7 +66,7 @@ public class JobReviewController { @PathVariable Long reviewId, HttpServletRequest request) { Map view = reviewService.getReview(jobId, reviewId); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "评分汇总", description = "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签") @@ -73,7 +75,7 @@ public class JobReviewController { @PathVariable Long jobId, HttpServletRequest request) { Map data = reviewService.getReviewSummary(jobId); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "岗位对比", description = "公开访问;ids 为 2-3 个公开岗位;返回公开岗位字段与公开评分") @@ -83,7 +85,7 @@ public class JobReviewController { HttpServletRequest request) { List jobIds = parseIds(ids); List> data = reviewService.compareJobs(jobIds); - return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } private List parseIds(String ids) { @@ -108,10 +110,10 @@ public class JobReviewController { @PathVariable Long reviewId, @Valid @RequestBody JobReviewUpdateRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map view = reviewService.updateReview(userId, jobId, reviewId, req, com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } @Operation(summary = "删除评价", description = "仅评价本人可以删除") @@ -120,21 +122,8 @@ public class JobReviewController { @PathVariable Long jobId, @PathVariable Long reviewId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); reviewService.deleteReview(userId, jobId, reviewId, com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "review deleted"), 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()); - } - - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "review deleted"), requestIdAccessor.get(request))); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java index e0d8775..c4f7838 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java @@ -1,20 +1,18 @@ package com.mydreamjob.review; import com.mydreamjob.common.api.ApiResponse; -import com.mydreamjob.common.exception.BusinessException; -import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.review.dto.PersonalizedScoreResponse; import com.mydreamjob.review.dto.ReviewWeightResponse; import com.mydreamjob.review.dto.SaveWeightRequest; import com.mydreamjob.review.dto.WeightTemplateResponse; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; 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.*; import java.util.List; @@ -24,6 +22,8 @@ import java.util.List; public class StudentReviewWeightController { private final StudentReviewWeightService weightService; + private final CurrentUserAccessor currentUserAccessor; + private final RequestIdAccessor requestIdAccessor; // ── 模板列表(登录即可) ────────────────────────────────── @@ -35,7 +35,7 @@ public class StudentReviewWeightController { }) public ResponseEntity>> getTemplates( HttpServletRequest request) { - return ResponseEntity.ok(ApiResponse.ok(weightService.getTemplates(), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(weightService.getTemplates(), requestIdAccessor.get(request))); } // ── 获取我的权重配置(仅 STUDENT,Service 层校验) ────────── @@ -50,8 +50,8 @@ public class StudentReviewWeightController { }) public ResponseEntity> getMyWeights( HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(weightService.getMyWeights(userId), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(weightService.getMyWeights(userId), requestIdAccessor.get(request))); } // ── 保存我的权重配置(仅 STUDENT,Service 层校验) ────────── @@ -67,8 +67,8 @@ public class StudentReviewWeightController { public ResponseEntity> saveWeights( @RequestBody SaveWeightRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(weightService.saveWeights(userId, req), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(weightService.saveWeights(userId, req), requestIdAccessor.get(request))); } // ── 个性化评分(仅 STUDENT,Service 层校验) ──────────────── @@ -84,19 +84,7 @@ public class StudentReviewWeightController { public ResponseEntity> getPersonalizedScore( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(weightService.getPersonalizedScore(userId, jobId), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(weightService.getPersonalizedScore(userId, jobId), 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/r01a5/A5AccessorWiringIndependentTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java new file mode 100644 index 0000000..306c533 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java @@ -0,0 +1,194 @@ +package com.mydreamjob.common.r01a5; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.interviewscore.InterviewScoreService; +import com.mydreamjob.job.JobFavoriteService; +import com.mydreamjob.review.AdminReviewReplyService; +import com.mydreamjob.review.CompanyReviewManagementService; +import com.mydreamjob.review.JobReviewService; +import com.mydreamjob.review.StudentReviewWeightService; +import com.mydreamjob.security.CurrentUserAccessor; +import jakarta.servlet.http.HttpServletRequest; +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.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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +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.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * R01-A5 独立复验:**接线变异检测**(不复用实施者断言)。 + * + *

把 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 与 8 个 Service 全部替换为 mock, + * 从而证明 8 个 Controller 确实通过既有 Accessor 获取身份与 requestId: + * 若删除 Controller 中的 Accessor 调用,本测试必然失败。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A5AccessorWiringIndependentTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private CurrentUserAccessor currentUserAccessor; + @MockBean private RequestIdAccessor requestIdAccessor; + + @MockBean private CandidateScoringGovernanceService governanceService; + @MockBean private InterviewScoreService interviewScoreService; + @MockBean private JobFavoriteService favoriteService; + @MockBean private JobReviewService reviewService; + @MockBean private AdminReviewReplyService adminService; + @MockBean private CompanyReviewManagementService managementService; + @MockBean private StudentReviewWeightService weightService; + @MockBean private RedisTemplate redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String APPEALS = "/profile/me/candidate-scoring/appeals"; + private static final String INTERVIEW_SCORE = "/interviews/1/score"; + private static final String FAVORITES = "/jobs/favorites"; + private static final String ADMIN_REVIEWS = "/admin/reviews/pending"; + private static final String ADMIN_REPLIES = "/admin/review-replies/pending"; + private static final String COMPANY_REVIEWS = "/companies/me/job-reviews"; + private static final String DELETE_REVIEW = "/jobs/1/reviews/1"; + private static final String REVIEW_WEIGHTS = "/profile/me/review-weights"; + + private static final UsernamePasswordAuthenticationToken LEGAL = + new UsernamePasswordAuthenticationToken("42", "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + + // ── 1. 身份 Accessor 必须真实参与调用 ──────────────────── + + @Test + @DisplayName("独立复验:8 端点均调用 requireUserId(),且 Service 收到 Accessor 返回的 4242") + void allEightEndpoints_useAccessorUserId() throws Exception { + when(currentUserAccessor.requireUserId()).thenReturn(4242L); + when(requestIdAccessor.get(any())).thenReturn("a5-independent-request"); + + mockMvc.perform(get(APPEALS).with(authentication(LEGAL))).andReturn(); + verify(governanceService).listMyAppeals(eq(4242L), any(), anyInt(), anyInt()); + + mockMvc.perform(get(INTERVIEW_SCORE).with(authentication(LEGAL))).andReturn(); + verify(interviewScoreService).getScore(eq(4242L), any()); + + mockMvc.perform(get(FAVORITES).with(authentication(LEGAL))).andReturn(); + verify(favoriteService).listFavorites(eq(4242L), anyInt(), anyInt()); + + mockMvc.perform(get(ADMIN_REVIEWS).with(authentication(LEGAL))).andReturn(); + verify(reviewService).listPendingReviews(eq(4242L), any(), any(), any(), any(), anyInt(), anyInt()); + + mockMvc.perform(get(ADMIN_REPLIES).with(authentication(LEGAL))).andReturn(); + verify(adminService).listPending(eq(4242L), any(), any(), any(), any(), any(), anyInt(), anyInt()); + + mockMvc.perform(get(COMPANY_REVIEWS).with(authentication(LEGAL))).andReturn(); + verify(managementService).listMine(eq(4242L), any(), any(), any(), any(), any(), any(), anyInt(), anyInt()); + + mockMvc.perform(delete(DELETE_REVIEW).with(authentication(LEGAL))).andReturn(); + verify(reviewService).deleteReview(eq(4242L), any(), any(), any()); + + mockMvc.perform(get(REVIEW_WEIGHTS).with(authentication(LEGAL))).andReturn(); + verify(weightService).getMyWeights(eq(4242L)); + + // 8 个端点各调用一次 Accessor(证明接线真实存在) + verify(currentUserAccessor, times(8)).requireUserId(); + } + + // ── 2. 非法身份必须在业务调用前拒绝 ────────────────────── + + @Test + @DisplayName("独立复验:Accessor 抛未登录时 8 端点全部 401、无 500、Service 零调用") + void illegalIdentity_rejectedBeforeService() throws Exception { + when(currentUserAccessor.requireUserId()) + .thenThrow(new InsufficientAuthenticationException("authentication required")); + + List paths = List.of(APPEALS, INTERVIEW_SCORE, FAVORITES, ADMIN_REVIEWS, + ADMIN_REPLIES, COMPANY_REVIEWS, REVIEW_WEIGHTS); + + for (String path : paths) { + var r = mockMvc.perform(get(path).with(authentication(LEGAL))).andReturn(); + assertThat(r.getResponse().getStatus()).as("path=%s 必须 401", path).isEqualTo(401); + assertThat(r.getResponse().getStatus()).as("path=%s 不得 500", path).isNotEqualTo(500); + + JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); + 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("anonymousUser") + .doesNotContain("com.mydreamjob"); + assertThat(json.has("stackTrace")).isFalse(); + assertThat(json.has("trace")).isFalse(); + } + + var dr = mockMvc.perform(delete(DELETE_REVIEW).with(authentication(LEGAL))).andReturn(); + assertThat(dr.getResponse().getStatus()).isEqualTo(401); + + // 业务 Service 零调用 + verify(governanceService, never()).listMyAppeals(any(), any(), anyInt(), anyInt()); + verify(interviewScoreService, never()).getScore(any(), any()); + verify(favoriteService, never()).listFavorites(any(), anyInt(), anyInt()); + verify(reviewService, never()).deleteReview(any(), any(), any(), any()); + verify(weightService, never()).getMyWeights(any()); + } + + // ── 3. requestId Accessor 必须真实参与调用 ─────────────── + + @Test + @DisplayName("独立复验:响应体 requestId 来自 RequestIdAccessor") + void responseBodyRequestId_comesFromAccessor() throws Exception { + when(currentUserAccessor.requireUserId()).thenReturn(42L); + when(requestIdAccessor.get(any(HttpServletRequest.class))).thenReturn("a5-independent-request"); + + for (String path : List.of(APPEALS, INTERVIEW_SCORE, FAVORITES, ADMIN_REVIEWS, + ADMIN_REPLIES, COMPANY_REVIEWS, REVIEW_WEIGHTS)) { + String body = mockMvc.perform(get(path).with(authentication(LEGAL))) + .andReturn().getResponse().getContentAsString(); + assertThat(body).as("path=%s 响应体 requestId 应来自 Accessor", path) + .contains("\"requestId\":\"a5-independent-request\""); + } + + // DELETE 端点同样返回 ApiResponse,含 requestId + String delBody = mockMvc.perform(delete(DELETE_REVIEW).with(authentication(LEGAL))) + .andReturn().getResponse().getContentAsString(); + assertThat(delBody).contains("\"requestId\":\"a5-independent-request\""); + + verify(requestIdAccessor, times(8)).get(any(HttpServletRequest.class)); + } + + @Test + @DisplayName("独立复验:无 Token 时 8 端点由 Security 链返回 401(不经 Controller)") + void noToken_is401() throws Exception { + for (String path : List.of(APPEALS, INTERVIEW_SCORE, FAVORITES, ADMIN_REVIEWS, + ADMIN_REPLIES, COMPANY_REVIEWS, REVIEW_WEIGHTS)) { + assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) + .as(path).isEqualTo(401); + } + assertThat(mockMvc.perform(delete(DELETE_REVIEW)).andReturn().getResponse().getStatus()) + .isEqualTo(401); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java new file mode 100644 index 0000000..095bde7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java @@ -0,0 +1,148 @@ +package com.mydreamjob.common.r01a5; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; +import com.mydreamjob.interviewscore.InterviewScoreService; +import com.mydreamjob.job.JobFavoriteService; +import com.mydreamjob.review.AdminReviewReplyService; +import com.mydreamjob.review.CompanyReviewManagementService; +import com.mydreamjob.review.JobReviewService; +import com.mydreamjob.review.StudentReviewWeightService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * R01-A5 独立复验:**身份边界**(使用真实 {@code CurrentUserAccessor} 与真实 SecurityFilterChain)。 + * + *

验证非法主体在业务调用前安全失败,且合法主体仍进入角色/归属判断。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A5IndependentVerificationTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private CandidateScoringGovernanceService governanceService; + @MockBean private InterviewScoreService interviewScoreService; + @MockBean private JobFavoriteService favoriteService; + @MockBean private JobReviewService reviewService; + @MockBean private AdminReviewReplyService adminService; + @MockBean private CompanyReviewManagementService managementService; + @MockBean private StudentReviewWeightService weightService; + @MockBean private RedisTemplate redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final List PATHS = List.of( + "/profile/me/candidate-scoring/appeals", + "/interviews/1/score", + "/jobs/favorites", + "/admin/reviews/pending", + "/admin/review-replies/pending", + "/companies/me/job-reviews", + "/profile/me/review-weights"); + + private static final String DELETE_REVIEW = "/jobs/1/reviews/1"; + + private static final List ILLEGAL = List.of( + "anonymousUser", "not-a-number", "99999999999999999999", "0", "-1"); + + private static Authentication principal(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + @Test + @DisplayName("独立复验:五类非法主体 × 端点 一律 401、无 500、不回显主体") + void illegalPrincipals_are401() throws Exception { + for (String path : PATHS) { + for (String p : ILLEGAL) { + var r = mockMvc.perform(get(path).with(authentication(principal(p)))).andReturn(); + int st = r.getResponse().getStatus(); + assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401); + + JsonNode json = MAPPER.readTree(r.getResponse().getContentAsString()); + assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401); + String msg = json.path("message").asText(); + assertThat(msg).isIn("authentication required", "not authenticated"); + // 只检查稳定字段,不对整段 JSON 做数字搜索 + assertThat(msg) + .doesNotContain("Exception").doesNotContain("java.") + .doesNotContain("principal").doesNotContain("anonymousUser") + .doesNotContain("com.mydreamjob").doesNotContain("NumberFormat"); + if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { + assertThat(msg).doesNotContain(p); + } + } + } + // DELETE 端点 + for (String p : ILLEGAL) { + assertThat(mockMvc.perform(delete(DELETE_REVIEW).with(authentication(principal(p)))) + .andReturn().getResponse().getStatus()).as("delete principal='%s'", p).isEqualTo(401); + } + } + + @Test + @DisplayName("独立复验:匿名 token 与无 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); + assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) + .as(path).isEqualTo(401); + } + } + + @Test + @DisplayName("独立复验:非法主体的失败请求不触达任何业务 Service") + void illegalPrincipals_produceNoServiceCall() throws Exception { + for (String p : ILLEGAL) { + for (String path : PATHS) { + mockMvc.perform(get(path).with(authentication(principal(p)))).andReturn(); + } + } + verify(governanceService, never()).listMyAppeals(any(), any(), anyInt(), anyInt()); + verify(interviewScoreService, never()).getScore(any(), any()); + verify(favoriteService, never()).listFavorites(any(), anyInt(), anyInt()); + verify(reviewService, never()).deleteReview(any(), any(), any(), any()); + verify(weightService, never()).getMyWeights(any()); + } + + @Test + @DisplayName("独立复验:合法数字主体仍能进入业务层(不被身份层拒绝)") + void legalPrincipal_reachesService() 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); + } + assertThat(mockMvc.perform(delete(DELETE_REVIEW).with(authentication(principal("7")))) + .andReturn().getResponse().getStatus()).isNotEqualTo(401); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java new file mode 100644 index 0000000..8ac3042 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java @@ -0,0 +1,124 @@ +package com.mydreamjob.common.r01a5; + +import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; +import com.mydreamjob.interviewscore.InterviewScoreService; +import com.mydreamjob.job.JobFavoriteService; +import com.mydreamjob.review.AdminReviewReplyService; +import com.mydreamjob.review.CompanyReviewManagementService; +import com.mydreamjob.review.JobReviewService; +import com.mydreamjob.review.StudentReviewWeightService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * R01-A5 独立复验:**失败请求的业务副作用**。 + * + *

使用 H2 行级快照(显式列 + 稳定 ORDER BY,逐字段比较,非 COUNT/SUM), + * 验证非法身份请求前后与本批端点相关的核心表完全不变。

+ * + *

说明:本轮不启动 Docker,H2 独立复验;不声称真实 MySQL 结论。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A5SideEffectSnapshotTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private CandidateScoringGovernanceService governanceService; + @MockBean private InterviewScoreService interviewScoreService; + @MockBean private JobFavoriteService favoriteService; + @MockBean private JobReviewService reviewService; + @MockBean private AdminReviewReplyService adminService; + @MockBean private CompanyReviewManagementService managementService; + @MockBean private StudentReviewWeightService weightService; + @MockBean private RedisTemplate redisTemplate; + + /** (表, 主键列, 全列) —— 逐字段快照。 */ + private static final List TABLES = List.of( + new String[]{"JOB_FAVORITES", "ID", "ID, JOB_ID, STUDENT_USER_ID, CREATED_AT"}, + new String[]{"JOB_REVIEWS", "ID", "ID, JOB_ID, STUDENT_USER_ID, RATING, CONTENT, STATUS, CREATED_AT, UPDATED_AT"}, + new String[]{"COMPANY_REVIEW_REPLIES", "ID", "ID, REVIEW_ID, COMPANY_ID, CONTENT, MODERATION_STATUS, VERSION, CREATED_AT, UPDATED_AT"}, + new String[]{"INTERVIEW_SCORES", "ID", "ID, INTERVIEW_ID, APPLICATION_ID, COMPANY_USER_ID, STUDENT_USER_ID, STATUS, CREATED_AT, UPDATED_AT"}, + new String[]{"CANDIDATE_SCORING_APPEALS", "ID", "ID, CANDIDATE_USER_ID, DIMENSION, PERIOD, STATUS, REASON, CREATED_AT, UPDATED_AT"}, + new String[]{"STUDENT_REVIEW_WEIGHT_PREFERENCES", "ID", "ID, STUDENT_USER_ID, SOURCE, TEMPLATE_CODE, VERSION, CREATED_AT, UPDATED_AT"}, + new String[]{"NOTIFICATIONS", "ID", "ID, USER_ID, TYPE, TITLE, IS_READ, CREATED_AT"}, + new String[]{"MESSAGES", "ID", "ID, CONVERSATION_ID, SENDER_USER_ID, CONTENT, CREATED_AT"}, + new String[]{"AUDIT_LOGS", "ID", "ID, USER_ID, ACTION, ENTITY_TYPE, ENTITY_ID, CREATED_AT"}); + + private Map>> snapshot() { + Map>> out = new LinkedHashMap<>(); + for (String[] t : TABLES) { + List> rows = jdbcTemplate.queryForList( + "SELECT " + t[2] + " FROM " + t[0] + " ORDER BY " + t[2]); + out.put(t[0], rows); + } + return out; + } + + private static final List PATHS = List.of( + "/profile/me/candidate-scoring/appeals", + "/interviews/1/score", + "/jobs/favorites", + "/admin/reviews/pending", + "/admin/review-replies/pending", + "/companies/me/job-reviews", + "/profile/me/review-weights"); + private static final String DELETE_REVIEW = "/jobs/1/reviews/1"; + + @Test + @DisplayName("独立复验:非法身份请求前后,九张核心表逐字段零变化") + void illegalIdentityRequests_produceNoBusinessWrite() throws Exception { + Map>> before = snapshot(); + + var bad = new UsernamePasswordAuthenticationToken("anonymousUser", "n/a", AuthorityUtils.NO_AUTHORITIES); + for (String path : PATHS) { + mockMvc.perform(get(path).with(authentication(bad))).andReturn(); + mockMvc.perform(get(path).with(authentication( + new UsernamePasswordAuthenticationToken("not-a-number", "n/a", AuthorityUtils.NO_AUTHORITIES)))).andReturn(); + } + mockMvc.perform(delete(DELETE_REVIEW).with(authentication(bad))).andReturn(); + + Map>> after = snapshot(); + + for (String[] t : TABLES) { + assertThat(after.get(t[0])) + .as("%s 必须逐字段零变化", t[0]) + .isEqualTo(before.get(t[0])); + } + } + + @Test + @DisplayName("独立复验:无 Token 请求前后同样零变化") + void noTokenRequests_produceNoBusinessWrite() throws Exception { + Map>> before = snapshot(); + for (String path : PATHS) { + mockMvc.perform(get(path)).andReturn(); + } + mockMvc.perform(delete(DELETE_REVIEW)).andReturn(); + Map>> after = snapshot(); + for (String[] t : TABLES) { + assertThat(after.get(t[0])).as("%s", t[0]).isEqualTo(before.get(t[0])); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/R01A5RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/R01A5RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..b844a8b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/R01A5RequestContextMigrationIntegrationTest.java @@ -0,0 +1,124 @@ +package com.mydreamjob.common.r01a5; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.governance.CandidateScoringGovernanceService; +import com.mydreamjob.interviewscore.InterviewScoreService; +import com.mydreamjob.job.JobFavoriteService; +import com.mydreamjob.review.AdminReviewReplyService; +import com.mydreamjob.review.CompanyReviewManagementService; +import com.mydreamjob.review.CompanyReviewReplyService; +import com.mydreamjob.review.JobReviewService; +import com.mydreamjob.review.StudentReviewWeightService; +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.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 org.springframework.test.web.servlet.RequestBuilder; + +import java.util.List; +import java.util.function.Function; + +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.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** R01-A5:八个真实 Controller 映射必须统一经过身份与 requestId 边界。 */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class R01A5RequestContextMigrationIntegrationTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final List ILLEGAL_PRINCIPALS = List.of( + "anonymousUser", "a5-not-a-number", "99999999999999999999", "0", "-1"); + + @Autowired private MockMvc mockMvc; + + @MockBean private CandidateScoringGovernanceService governanceService; + @MockBean private InterviewScoreService interviewScoreService; + @MockBean private JobFavoriteService favoriteService; + @MockBean private JobReviewService reviewService; + @MockBean private AdminReviewReplyService adminReviewReplyService; + @MockBean private CompanyReviewManagementService companyReviewManagementService; + @MockBean private CompanyReviewReplyService companyReviewReplyService; + @MockBean private StudentReviewWeightService studentReviewWeightService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + private record Endpoint(String controller, Function request) { } + + private static List endpoints() { + return List.of( + new Endpoint("CandidateScoringGovernanceController", + auth -> get("/profile/me/candidate-scoring/appeals").with(authentication(auth))), + new Endpoint("InterviewScoreController", + auth -> get("/interviews/1/score").with(authentication(auth))), + new Endpoint("FavoriteController", + auth -> get("/jobs/favorites").with(authentication(auth))), + new Endpoint("AdminReviewController", + auth -> get("/admin/reviews/pending").with(authentication(auth))), + new Endpoint("AdminReviewReplyController", + auth -> get("/admin/review-replies/pending").with(authentication(auth))), + new Endpoint("CompanyReviewManagementController", + auth -> get("/companies/me/job-reviews").with(authentication(auth))), + new Endpoint("JobReviewController", + auth -> delete("/jobs/1/reviews/1").with(authentication(auth))), + new Endpoint("StudentReviewWeightController", + auth -> get("/profile/me/review-weights").with(authentication(auth)))); + } + + private static Authentication principal(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + @Test + @DisplayName("A5:8/8 真实映射对非法主体统一返回安全的 401") + void allEightMappingsRejectIllegalPrincipals() throws Exception { + assertThat(endpoints()).hasSize(8); + for (Endpoint endpoint : endpoints()) { + for (String illegal : ILLEGAL_PRINCIPALS) { + var response = mockMvc.perform(endpoint.request().apply(principal(illegal))) + .andReturn().getResponse(); + assertThat(response.getStatus()) + .as("%s principal=%s", endpoint.controller(), illegal).isEqualTo(401); + JsonNode body = JSON.readTree(response.getContentAsString()); + assertThat(body.path("code").asInt()).isEqualTo(401); + assertThat(body.path("message").asText()) + .doesNotContain("Exception", "java.", "principal", "NumberFormatException", + "a5-not-a-number", "99999999999999999999"); + assertThat(body.has("stackTrace")).isFalse(); + assertThat(body.has("trace")).isFalse(); + } + } + } + + @Test + @DisplayName("A5:8/8 真实映射的 requestId 响应头与响应体同源") + void allEightMappingsUseNormalizedRequestId() throws Exception { + Authentication legal = principal("4242"); + for (Endpoint endpoint : endpoints()) { + RequestBuilder request = endpoint.request().apply(legal); + if (request instanceof org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder builder) { + builder.header("X-Request-Id", "a5/" + "x".repeat(80)); + } + var response = mockMvc.perform(request).andReturn().getResponse(); + assertThat(response.getStatus()).as(endpoint.controller()).isNotEqualTo(401); + String requestId = response.getHeader("X-Request-Id"); + assertThat(requestId).isNotBlank().hasSizeLessThanOrEqualTo(32) + .doesNotContain("<", ">", " ", "\r", "\n"); + assertThat(response.getContentAsString()).contains("\"requestId\":\"" + requestId + "\""); + } + } +}