Browse Source

refactor: centralize fifth batch request context handling

refactor/r01-structure-performance
Zhang Shun 1 month ago
parent
commit
534afe686b
  1. 2
      docs/backend-development-roadmap.md
  2. 279
      docs/r01-a5-independent-verification-20260917.md
  3. 79
      docs/r01-a5-request-context-migration-report.md
  4. 55
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java
  5. 36
      mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java
  6. 31
      mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java
  7. 30
      mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java
  8. 31
      mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java
  9. 39
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java
  10. 39
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java
  11. 34
      mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java
  12. 194
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java
  13. 148
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java
  14. 124
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java
  15. 124
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/R01A5RequestContextMigrationIntegrationTest.java

2
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节为准,移出一期 |

279
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:<path>`) | `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独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**

79
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。

55
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<ApiResponse<AppealResponse>> 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<AppealResponse> 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<ApiResponse<AppealResponse>> 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<ApiResponse<AppealResponse>> 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<AppealResponse> 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<ApiResponse<AppealResponse>> 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<ApiResponse<AppealResponse>> 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<ApiResponse<List<AppealHistoryItem>>> getAppealHistory(
@PathVariable Long appealId, HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
List<AppealHistoryItem> 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<SnapshotHistoryItem> 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");
}
}

36
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<ApiResponse<InterviewScoreResponse>> 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<ApiResponse<InterviewScoreResponse>> 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<ApiResponse<List<InterviewScoreHistoryItem>>> 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)));
}
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");
interviewScoreService.getHistory(userId, interviewId), requestIdAccessor.get(request)));
}
}

31
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<ApiResponse<Map<String, Object>>> favorite(
@PathVariable Long jobId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> 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<ApiResponse<Map<String, Object>>> unfavorite(
@PathVariable Long jobId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> 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<Map<String, Object>> result = favoriteService.listFavorites(userId, page, size);
Map<String, Object> 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)));
}
}

30
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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> toPage(Page<Map<String, Object>> 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");
}
}

31
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)));
}
}

39
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<ApiResponse<CompanyReviewDetailResponse>> 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)));
}
}

39
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<String, Object> 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<String, Object> 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<String, Object> 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<Long> jobIds = parseIds(ids);
List<Map<String, Object>> data = reviewService.compareJobs(jobIds);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
private List<Long> 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<String, Object> 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)));
}
}

34
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<ApiResponse<List<WeightTemplateResponse>>> 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<ApiResponse<ReviewWeightResponse>> 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<ApiResponse<ReviewWeightResponse>> 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<ApiResponse<PersonalizedScoreResponse>> getPersonalizedScore(
@PathVariable Long jobId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(weightService.getPersonalizedScore(userId, jobId), 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());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(weightService.getPersonalizedScore(userId, jobId), requestIdAccessor.get(request)));
}
}

194
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 独立复验:**接线变异检测**(不复用实施者断言)。
*
* <p>把 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 与 8 个 Service 全部替换为 mock,
* 从而证明 8 个 Controller 确实通过既有 Accessor 获取身份与 requestId:
* 若删除 Controller 中的 Accessor 调用,本测试必然失败。</p>
*/
@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<String, Object> 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<String> 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);
}
}

148
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)。
*
* <p>验证非法主体在业务调用前安全失败,且合法主体仍进入角色/归属判断。</p>
*/
@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<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final List<String> 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<String> 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);
}
}

124
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 独立复验:**失败请求的业务副作用**。
*
* <p>使用 H2 行级快照(显式列 + 稳定 ORDER BY,逐字段比较,非 COUNT/SUM),
* 验证非法身份请求前后与本批端点相关的核心表完全不变。</p>
*
* <p>说明:本轮不启动 Docker,H2 独立复验;不声称真实 MySQL 结论。</p>
*/
@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<String, Object> redisTemplate;
/** (表, 主键列, 全列) —— 逐字段快照。 */
private static final List<String[]> 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<String, List<Map<String, Object>>> snapshot() {
Map<String, List<Map<String, Object>>> out = new LinkedHashMap<>();
for (String[] t : TABLES) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT " + t[2] + " FROM " + t[0] + " ORDER BY " + t[2]);
out.put(t[0], rows);
}
return out;
}
private static final List<String> 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<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before = snapshot();
for (String path : PATHS) {
mockMvc.perform(get(path)).andReturn();
}
mockMvc.perform(delete(DELETE_REVIEW)).andReturn();
Map<String, List<Map<String, Object>>> after = snapshot();
for (String[] t : TABLES) {
assertThat(after.get(t[0])).as("%s", t[0]).isEqualTo(before.get(t[0]));
}
}
}

124
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<String> 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<String, Object> redisTemplate;
private record Endpoint(String controller, Function<Authentication, RequestBuilder> request) { }
private static List<Endpoint> 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/<unsafe value>" + "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 + "\"");
}
}
}
Loading…
Cancel
Save