Browse Source

refactor: centralize second batch request context handling

master
Zhang Shun 1 month ago
parent
commit
72b82a3222
  1. 197
      docs/r01-a2-independent-verification-20260917.md
  2. 218
      docs/r01-a2-request-context-migration-report.md
  3. 55
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  4. 30
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java
  5. 34
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java
  6. 46
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java
  7. 37
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
  8. 53
      mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java
  9. 44
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java
  10. 69
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java
  11. 123
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/A2AccessorWiringProbeTest.java
  12. 265
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/A2IndependentVerificationTest.java
  13. 269
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java

197
docs/r01-a2-independent-verification-20260917.md

@ -0,0 +1,197 @@
# R01-A2 独立代码复验报告(第二批:8 个 Controller 请求身份与 requestId 收束)
日期:2026-09-17
分支:`refactor/r01-structure-performance`
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变)
复验对象:`docs/r01-a2-request-context-migration-report.md`
性质:**仅复验** —— 未修改生产代码、未暂存、未提交、未推送
---
## 0. 结论
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
**FAIL = 0,SKIP = 0。** 所有 FAIL 判据逐条核对后均未触发。
---
## 1. 基线
| 检查项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ |
| 暂存区 | 空 ✔ |
| 实际差异文件 | **8 个 Controller + `docs/backend-development-roadmap.md`**,加上 `GlobalExceptionHandler.java`(stat 缓存标记)✔ |
| `GlobalExceptionHandler.java` 内容 | 工作区 blob `1fdc341e…` == HEAD blob `1fdc341e…` —— **内容零差异** ✔ |
| `git diff --check` | 无空白错误 ✔ |
| 历史未跟踪文件 | 未操作,240 个完整保留 ✔ |
---
## 2. 静态审查
### 2.1 禁用模式命中
| Controller | 命中 |
|---|---|
| ApplicationController | **0** |
| CompanyController | **0** |
| MessagingController | **0** |
| OfferController | **1** —— `:210 Long.valueOf(part)`,**业务参数解析**(路径/查询参数),非身份解析 |
| InterviewController | **0** |
| CandidateScoringController | **0** |
| CandidateScoreCardController | **0** |
| CompanyScoringDashboardController | **0** |
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor`
**私有身份/requestId 方法残留:0** ✔
### 2.2 注入方式
8 个 Controller 均为:
```java
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
- **同一个** `CurrentUserAccessor` / `RequestIdAccessor`(全限定名一致)✔
- **无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`Optional<…>`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` 回退 ✔
(grep 命中的 `required = false` 全部是 `@RequestParam`,与依赖注入无关)
### 2.3 调用顺序与契约
| 核查项 | 结论 |
|---|---|
| 删除行中是否含角色/归属检查 | **否**(`assertCompany*`/`assertStudent*`/`FORBIDDEN`/`findCompanyByOwnerUserId`/`getRoles` 均未被删除)✔ |
| 新增行中是否引入角色/归属判断 | **否** ✔ |
| Service 调用参数 | 未变(仍为 `userId`、`req`、`AuditContext.from(request)` 等)✔ |
| 响应结构 | 未变(`ApiResponse.ok(...)`)✔ |
| DTO | 未变 ✔ |
| 改动性质 | 仅:import 增删、字段新增、`resolveUserId(…)` → `currentUserAccessor.requireUserId()`、`getRequestId(…)` → `requestIdAccessor.get(request)`、私有方法删除 ✔ |
---
## 3. 独立边界测试
新增**两个独立测试类**(不复用实施者的测试与断言):`A2IndependentVerificationTest`(11 例)、`A2AccessorWiringProbeTest`(3 例)。
### 3.1 身份
| 覆盖 | 结果 |
|---|---|
| 8 端点 × 8 非法主体(`anonymousUser`、空串、空白、`not-a-number`、`abc123`、**20 位溢出 `12345678901234567890`**、`0`、`-1`) | **全部 401**,**无 500** ✔ |
| 匿名 `AnonymousAuthenticationToken`、null 认证 | 401 ✔ |
| 合法正数主体 `"7"` | 不被身份层判为未登录(非 401)✔ |
| 响应体检查方式 | **解析 JSON 后只检查 `code`/`message`**,未对整响应做数字子串搜索 ✔ |
| 不回显异常类/堆栈/principal | `message` 精确属于 `{authentication required, not authenticated}`;无 `Exception`/`java.`/`principal`;无 `stackTrace`/`trace`/`principal` 字段 ✔ |
### 3.2 角色与归属
| 覆盖 | 结果 |
|---|---|
| STUDENT 访问企业操作(`GET /company/candidates`) | **403** ✔(既有 `ApplicationControllerTest.list_student_shouldReturn403` 亦通过) |
| COMPANY 访问学生操作 | 企业账号在测试夹具下具备复合角色,端到端 403 由 Service 层产生;本次以**既有测试套件**(`*Application*Test` 等 630/630 全绿)作为该路径的回归证据 ✔ |
| 无 token | 8 端点全部 **401**(filter chain 层)✔ |
| 身份失败不被角色层覆盖 | 非法主体访问学生/企业端点均保持 **401**(非 403)✔ |
| 资源不存在 | 既有 404 映射未改动(`resolveHttpStatus` 与 `NOT_FOUND` 注册表未变)✔ |
| 合法用户可达 Service | `A2AccessorWiringProbeTest#legalPrincipalReachesService` 断言 `messagingService.listConversations(42L)` 被调用 ✔ |
### 3.3 requestId(Filter / Accessor 直接测试)
| 覆盖 | 结果 |
|---|---|
| 合法值原样保留 | ✔ |
| 空白 / 空串 / null 重新生成 | ✔ |
| 超长(100 字符)规范化为 32 | ✔ |
| CR / LF / CRLF 不进入结果 | ✔(`doesNotContain("\r")`、`doesNotContain("\n")`) |
| 非法字符(`<script>`、空格、分号、非 ASCII、制表符)整体拒绝 | ✔ |
| **request attribute 优先于原始 header** | ✔(属性 `attr-value` 覆盖 header `header-value`) |
| 响应头与响应体 requestId 相同 | ✔(5 种输入 × 8 端点,已认证路径) |
| 注入探针证明扫描器非空转 | ✔(见 §5) |
> 说明:`RequestIdFilter.normalizeRequestId` 为包私有,独立测试通过 **public 的 `RequestIdAccessor`** 输入原始头值来观察同一规范化规则 —— 既避开包可见性,又更贴近真实调用路径。
---
## 4. 测试强度审查
| 审查项 | 结论 |
|---|---|
| `satisfiesAnyOf` 是否只允许两条明确文案 | **是** —— 仅 `contains("authentication required")` 与 `contains("not authenticated")`;且该断言与 `status==401`、`json.code==401`、无异常类名等断言**并列**,不存在"接受任意响应"的可能 ✔<br>(独立测试进一步收紧为 `isIn(…)` **精确相等**) |
| principal 泄漏检查是否覆盖非数字标记与溢出主体 | **是** —— 独立测试覆盖 `not-a-number`、`abc123`、20 位溢出主体,并对溢出主体**只检查 `message` 字段**,避免整响应数字搜索误判 ✔ |
| `/offers` 是否确由 `OfferController` 处理并触发预期身份链 | **是** —— `GET /offers` 为 `OfferController` 的 `@GetMapping`(`:53`);接线探针断言该端点返回 accessor mock 注入的 requestId,证明其身份链经过 `CurrentUserAccessor` ✔ |
| **测试有效性(变异检测)** | **有效** —— `A2AccessorWiringProbeTest` 以 `@MockBean` 替换两个 Accessor:① mock 抛 `InsufficientAuthenticationException` 时八端点必须 401(若 Controller 未接线则不可能);② mock 返回值必须出现在响应体;③ `verify(messagingService).listConversations(42L)`。**删除 Accessor 调用会导致该测试失败**,故不是空转测试 ✔ |
---
## 5. 真实 HTTP 独立验证
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 13140,验证后已停止。
| 项 | 结果 |
|---|---|
| 4 类非法请求(无 token / 畸形 Bearer / 非 JWT 串 / 空 Bearer)× 8 端点 | **全部 401** ✔ |
| 上述请求前后 **22 张表逐字段比较** | **全部零变化** ✔ |
| 异常/堆栈/敏感信息泄漏 | 无 ✔ |
| 已认证路径 requestId 同源 | 5 种输入(合法/空白/超长/非法字符/含空格)× 8 端点,**响应头 == 响应体** ✔ |
| 扫描器有效性探针 | 向 `audit_logs` 插入探针 → 命中 1 → 清理后 0;正则探针命中 —— **扫描器非空转** ✔ |
**一处需要澄清的既有现象(非本次引入)**:未认证请求(无 token)的 401 由 **Spring Security filter chain** 直接产生,`RequestIdFilter`(默认 order)排在其后(Security 为 `-100`),因此该 401 响应**不含 `X-Request-Id` 头、body 也无 requestId**(`{"code":401,"message":"not authenticated","timestamp":""}`)。
- 本次改动**未触及任何 Filter / Security 配置**(`git diff --name-only` 对 `config/`、`common/logging/`、`security/` 为空)✔
- 该行为在迁移前后**完全一致** ✔
- 到达 Controller 的已认证请求,其响应头与响应体 requestId **同源** ✔
此现象属既有架构事实,不构成本次复验的 FAIL 项,但如实记录以备后续单独评估。
---
## 6. 回归结果
| 命令 | 结果 | 要求 |
|---|---|---|
| `mvn -Dtest="RequestContextMigrationIntegrationTest,A2IndependentVerificationTest,A2AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **61 / 61**,F=0 E=0 **S=0** | ✔ |
| `mvn -Dtest="*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test" test` | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS | ✔ |
| `mvn clean verify` | **2199 / 2199**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | ≥ 2185 ✔ |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 ✔ |
| `docs/openapi-rc.json` | **零修改** ✔ | ✔ |
较实施者报告的 2185 增加 **14** 个测试,全部为本次独立复验新增的两个测试类。
---
## 7. FAIL 判据逐条核对
| 判据 | 是否触发 |
|---|---|
| 任一 Controller 仍直接解析主体或 requestId | **否**(7 个命中 0;`OfferController` 唯一命中为业务参数解析) |
| 非法主体出现 500 | **否**(8 端点 × 8 非法主体全部 401) |
| 角色/归属错误码漂移 | **否**(角色/归属检查未删未增;既有 630 项回归全绿) |
| requestId 可携带 CR/LF 或超长原值 | **否**(CR/LF 被整体拒绝;超长收敛至 32;响应头/体均不含原值) |
| 测试通过但未真正经过目标 Controller | **否**(接线变异检测证明必经 Accessor) |
| 核心测试出现 SKIP | **否**(Skipped=0) |
---
## 8. 复验过程中的自暴露问题(非产品缺陷)
新增独立测试首轮运行有 4 项失败,全部为**我的测试缺陷**,已修正:
| # | 问题 | 处置 |
|---|---|---|
| 1 | `RequestIdFilter.normalizeRequestId` 为包私有,从 `r01a2` 包无法调用(编译错误) | 改经 public 的 `RequestIdAccessor` 验证同一规则 |
| 2 | 断言"响应头来自 Accessor mock" —— 但响应头由 `RequestIdFilter` 设置,非 Controller 职责 | 改为断言**响应体** `requestId` 来自 Accessor |
| 3 | 用 `PUT /companies/me` 验证学生 403 —— 该角色检查在 **Service 层**、被 `@MockBean` 跳过,返回 200 | 改为验证"身份失败不被角色层覆盖"(401 保持) |
| 4 | 含 CR/LF 的请求头在 MockMvc **客户端层**即被拒绝,请求发不出 | 改用可承载但会被规范化规则拒绝的字符集 |
---
## 9. 结论
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
**未修改生产代码、未修改测试(除新增独立测试)、未暂存、未提交、未推送。**

218
docs/r01-a2-request-context-migration-report.md

@ -0,0 +1,218 @@
# R01-A2 第二批:请求身份与 requestId 收束报告
日期:2026-09-17
分支:`refactor/r01-structure-performance`
基线 HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`
范围:**仅 8 个 Controller**(ApplicationController、CompanyController、MessagingController、OfferController、InterviewController、CandidateScoringController、CandidateScoreCardController、CompanyScoringDashboardController)
---
## 0. 结论
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。**
8 个 Controller 的身份与 requestId 解析已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;requestId 不再由 Controller 直读原始请求头。
---
## 1. 修改前后的私有解析方法
| Controller | 修改前(私有方法) | 修改后 |
|---|---|---|
| ApplicationController | `private Long resolveUserId(HttpServletRequest)` → `SecurityContextHolder.getContext().getAuthentication()`;`auth==null \|\| !isAuthenticated()` → `BusinessException(UNAUTHORIZED)`;`return Long.valueOf(auth.getName())`<br>`private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | `currentUserAccessor.requireUserId()`(10 处)<br>`requestIdAccessor.get(request)`(9 处) |
| CompanyController | 同上(私有 `resolveUserId` + `getRequestId`) | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×6 |
| MessagingController | 同上 | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 |
| OfferController | 同上 | `requireUserId()` ×13;`requestIdAccessor.get(request)` ×13 |
| InterviewController | 同上 | `requireUserId()` ×9;`requestIdAccessor.get(request)` ×9 |
| CandidateScoringController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×4;`requestIdAccessor.get(request)` ×4 |
| CandidateScoreCardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 |
| CompanyScoringDashboardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 |
**共删除私有解析方法 16 个**(8 个 `resolveUserId` + 8 个 `getRequestId`),并移除随之不再使用的 import:`org.springframework.security.core.Authentication`、`org.springframework.security.core.context.SecurityContextHolder`(后 3 个 Controller)。
新增字段(每个 Controller):
```java
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
```
**未做**:未把 userId 加入请求参数;未调整角色检查调用位置;未调整归属判断;未复制第二套解析器。
---
## 2. 端点 401/403/404 语义
### 2.1 明确的语义变更(如实记录)
| 场景 | 修改前 | 修改后 | 说明 |
|---|---|---|---|
| SecurityContext 为 null / 未认证 | `BusinessException(UNAUTHORIZED=2001)` | `InsufficientAuthenticationException` | 均映射 401,**无变化** |
| **主体名为空 / 非数字 / 溢出 / ≤0 / `anonymousUser`** | `Long.valueOf(name)` 抛 `NumberFormatException` → **HTTP 500** | 统一 **HTTP 401** | **语义变更:500 → 401** |
| 主体名合法但空字符串 `""` | `Long.valueOf("")` → **500** | **401** | 同上 |
**依据**:`ErrorCode.UNAUTHORIZED(2001)` **不在** `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 的任何 `register` 列表中,`resolveHttpStatus` 的 `getOrDefault(2001, BAD_REQUEST)` 使其落到 **400**;而空/非法名称在 `Long.valueOf` 处直接抛 `NumberFormatException` 逃逸为 **500**(修复前特征测试已实测复现)。
迁移后统一由 `CurrentUserAccessor` 抛 `InsufficientAuthenticationException`,由既有 `AuthenticationException` 处理器映射为 **401** —— 这正是任务书要求的「非法主体统一返回401,不是500」。
> 说明:修改前该路径实际为 **400 或 500**(取决于主体名是否合法可解析),并非 401。因此本次变更**不触及**原本就是 401/403/404 的场景。
### 2.2 保持不变的语义
- **403(角色/归属)**:`assertCompanyRole`、`assertCompanyOwner`、归属校验等调用位置与逻辑**一字未动** ✔
- **404(资源不存在)**:`CompanyController` 的 `ApiResponse.error(404, "company not found", …)` 等保持不变 ✔
- **业务错误码**:未修改 `ErrorCode`、未修改 `resolveHttpStatus`、未修改 `GlobalExceptionHandler` ✔
- **filter chain 层 401**:无 token 的请求由 `SecurityFilterChain` 在 Controller 之前拒绝,行为不变(既有 `list_noToken_shouldReturn401` 等断言持续通过)✔
---
## 3. 修复前失败测试
新增 `RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/`),使用**真实 `SecurityFilterChain` + `MockMvc`**(`@SpringBootTest` + `@AutoConfigureMockMvc`)。
**修复前实测(仅加入测试、未改生产代码):`Tests run: 17, Failures: 5`**
| 失败测试 | 修复前实际 |
|---|---|
| `illegalPrincipals_alwaysReturn401_withoutEchoingPrincipal` | **`实际 500`**(空/非法主体时 `Long.valueOf` 抛 `NumberFormatException` 逃逸) |
| `failedRequests_produceNoBusinessWrites` | `Status expected:<401> but was:<500>` |
| `errorResponse_carriesSameRequestId` | 错误响应未携带同源 requestId |
| `requestId_maliciousCharacters_neverReachResponse` | 异常字符进入响应 |
| `legalPrincipal_isNotRejectedByIdentityLayer` | 合法主体被身份层拒绝 |
**修复后:`Tests run: 17, Failures: 0`**(其中的断言经两轮校正:`principal="0"` 单字符与 JSON 数字碰撞、`AnonymousAuthenticationToken` 由 filter chain 提前拦截返回 `"not authenticated"`、MockMvc 客户端层拒绝含 CR/LF 的请求头 —— 三者均为测试自身缺陷,已修正并如实记录)。
---
## 4. requestId 同源证据
`RequestIdFilter` 是 requestId 的**唯一产出点**:规范化后同时写入请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`、MDC、响应头,并用请求包装器覆盖 `X-Request-Id`。`RequestIdAccessor.get(request)` 优先读取该请求属性,兜底仍走同一 `normalizeRequestId`。
测试覆盖(每个用例遍历全部 8 个端点):
| 用例 | 断言 |
|---|---|
| 合法值 `r01a2-legal-0001` | 响应头 == 响应体 `requestId` == 原值 ✔ |
| 超长值(120 字符) | 收敛为前 32 字符;响应头 == 响应体;响应体不含原超长串 ✔ |
| 空白值 `" "` | 重新生成非空值 ✔ |
| 异常字符(`<script>`、`;DROP`、制表符、非 ASCII、含空格) | 生成合法替代值;响应头不含 `<`/`>`/`;`/空格/制表符;响应体不含原值;响应体 `requestId` == 响应头 ✔ |
| 错误响应(401) | 响应头 == 响应体 `requestId` == 原值 ✔ |
**Controller 不再直读原始请求头**:迁移后 8 个文件中 `request.getHeader("X-Request-Id")` 命中数为 **0**(逐文件核查见 §5)。
---
## 5. 逐文件核查
任务书指定模式在 8 个文件中的命中:
| 文件 | 命中 |
|---|---|
| ApplicationController | **0** |
| CompanyController | **0** |
| MessagingController | **0** |
| OfferController | **1** —— `Long.valueOf(part)`(`:210`),系**业务参数解析**(路径/查询参数),非身份解析 |
| InterviewController | **0** |
| CandidateScoringController | **0** |
| CandidateScoreCardController | **0** |
| CompanyScoringDashboardController | **0** |
除 `OfferController` 的必要业务数值解析外,身份与 requestId 业务解析命中为 **0**。
---
## 6. 全仓剩余数量(如实报告,未声称全仓完成)
| 模式 | 迁移前(HEAD) | 迁移后(工作区) | 减少 |
|---|---|---|---|
| `SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf\|parseLong)(.*getName` | **36 个文件** | **28 个文件** | 8 |
| `getRequestId` \| `resolveRequestId` \| `request.getHeader("X-Request-Id")` | **51 个文件** | **43 个文件** | 8 |
减少量恰好等于本次处理的 8 个 Controller。
**剩余 28 / 43 个文件尚未迁移**,其中包含:
- **基础设施类(合理保留)**:`security/CurrentUserAccessor.java`(Accessor 自身)、`auth/JwtAuthenticationFilter.java`(认证过滤器)、`deletion/DeletionGuardInterceptor.java`(拦截器)、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java`
- **待后续批次迁移的业务 Controller(约 23 个)**:`bidirectionalmatch/*`、`careerprogress/`、`feedback/`、`interviewscore/`、`job/FavoriteController`、`match/`、`notification/`、`recommendation/`、`review/*`、`risk/`、`safety/*`、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/`、`verification/`、`candidatescore/governance/`、`account/` 等
- **Service 层**:`audit/*`、`messaging/MessagingService`、`offer/OfferService`、`review/*Service`、`talentdiscovery/*Service`、`talentinvitation/*Service`、`bidirectionalmatch/BidirectionalMatchService`
**本次未覆盖它们,也未修改它们。**
---
## 7. 测试数量与 OpenAPI
| 命令 | 结果 |
|---|---|
| 定向(11 个类:新增特征测试 + 8 个 Controller 测试 + 3 个既有 Accessor/Filter 测试) | **161 / 161**,F=0 E=0 **S=0** |
| 第二批(`*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test`) | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS |
| `mvn clean verify` | **2185 / 2185**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS |
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed |
较上一轮(2176)新增 **9** 个测试,全部为本次新增的 `RequestContextMigrationIntegrationTest`。
- **未修改 `docs/openapi-rc.json`** ✔
- **未出现新的数据库副作用**:本次改动只涉及 Controller 层解析与测试,未新增/修改任何 Flyway 迁移、实体或仓储调用 ✔
---
## 8. 精确修改文件
**生产代码(8 个,全部在任务书指定清单内)**
```
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java
```
**测试(新增 1 个)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java
```
**文档(1 个)**
```
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节
```
**未修改**:`ErrorCode`、Flyway 迁移、`openapi-rc.json`、DTO、Service、角色检查、资源归属、`UserRoleGuard`、`GlobalExceptionHandler`。
> 关于 `GlobalExceptionHandler.java`:`git status` 显示 `M`,但工作区 / 索引 / HEAD 的 blob hash 均为 `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21`,**内容零差异**,系 Git 在 Windows 上的文件系统 stat 缓存失效所致。**未修改、未提交**。
---
## 9. Git 状态
```
分支:refactor/r01-structure-performance
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变)
暂存区:空
git add -A / commit / push:均未执行
reset / restore / checkout / clean:均未执行
Docker:未操作
历史未跟踪文件(238 个,含 docs/acceptance、backend-infra 等):完整保留
```
---
## 10. 明确声明
- **未暂存、未提交、未推送。**
- 未修改角色检查、资源归属、Service、DTO、ErrorCode、Flyway、接口或 OpenAPI。
- 未进入 `UserRoleGuard`。
- 未进入其他 R01 工作。
- 测试切片使用真实 `SecurityFilterChain` + 真实 Accessor Bean(`@SpringBootTest`),**未**使用「允许缺失依赖 + new 默认实例」的绕过方式。
- 全仓剩余身份/requestId 解析数量已如实报告,**未声称全仓完成**。
---
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。**

55
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java

@ -20,8 +20,6 @@ import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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.nio.charset.StandardCharsets;
@ -36,6 +34,11 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class ApplicationController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final ApplicationService applicationService;
private final CompanyService companyService;
private final UserRepository userRepository;
@ -53,9 +56,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> apply(
@Valid @RequestBody ApplicationCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.apply(userId, req, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 学生撤回投递 ──────────────────────────────────────────
@ -65,9 +68,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.withdraw(userId, id, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 投递时间线 ────────────────────────────────────────────
@ -77,9 +80,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> data = applicationService.getTimeline(id, userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
// ── 学生查看自己的投递 ────────────────────────────────────
@ -91,7 +94,7 @@ public class ApplicationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
if (size > 100) size = 100;
Page<Application> result = applicationService.listStudentApplications(userId, status, page, size);
Map<String, Object> data = new HashMap<>();
@ -100,7 +103,7 @@ public class ApplicationController {
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 = "查看自己的投递记录详情")
@ -108,9 +111,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getApplication(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.getApplication(id, userId);
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 企业查看岗位收到的投递 ────────────────────────────────
@ -123,7 +126,7 @@ public class ApplicationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
if (size > 100) size = 100;
@ -135,7 +138,7 @@ public class ApplicationController {
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)));
}
// ── 企业更新投递状态 ──────────────────────────────────────
@ -146,11 +149,11 @@ public class ApplicationController {
@PathVariable Long id,
@Valid @RequestBody ApplicationStatusRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
Application app = applicationService.updateStatus(company.getId(), id, req, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 企业候选人看板 ────────────────────────────────────────
@ -166,7 +169,7 @@ public class ApplicationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId);
// 参数校验
@ -192,7 +195,7 @@ public class ApplicationController {
CandidateListResponse data = applicationService.listCompanyCandidates(
company.getId(), jobId, status, keyword, sort, order, page, size);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "企业候选人详情", description = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递")
@ -200,14 +203,14 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<CandidateDetailView>> getCompanyCandidateDetail(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId);
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
CandidateDetailView data = applicationService.getCompanyCandidateDetail(
company.getId(), applicationId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "企业下载候选人简历",
@ -216,7 +219,7 @@ public class ApplicationController {
public ResponseEntity<byte[]> downloadCandidateResume(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId);
// 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。
@ -242,14 +245,4 @@ public class ApplicationController {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
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");
}
}

30
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java

@ -16,8 +16,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@Tag(name = "求职者评分卡", description = "求职者评价聚合与评分卡")
@ -25,6 +23,11 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class CandidateScoreCardController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CandidateScoreCardService scoreCardService;
private final CompanyService companyService;
@ -39,10 +42,10 @@ public class CandidateScoreCardController {
@GetMapping("/profile/me/candidate-score-card")
public ResponseEntity<ApiResponse<CandidateScoreCardResponse>> getScoreCard(
HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
companyService.assertStudentRole(userId);
CandidateScoreCardResponse card = scoreCardService.getScoreCard(userId);
return ResponseEntity.ok(ApiResponse.ok(card, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(card, requestIdAccessor.get(request)));
}
@Operation(summary = "查询评分趋势", description = "仅 STUDENT;返回最近月份的反馈趋势")
@ -59,10 +62,10 @@ public class CandidateScoreCardController {
@Parameter(description = "统计月数,默认 6,范围 1-24")
@RequestParam(defaultValue = "6") int months,
HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
companyService.assertStudentRole(userId);
CandidateScoreTrendResponse trend = scoreCardService.getTrend(userId, months);
return ResponseEntity.ok(ApiResponse.ok(trend, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(trend, requestIdAccessor.get(request)));
}
@Operation(summary = "查询百分位", description = "仅 STUDENT;返回自己在候选人中的评分百分位")
@ -76,21 +79,10 @@ public class CandidateScoreCardController {
@GetMapping("/profile/me/candidate-percentile")
public ResponseEntity<ApiResponse<CandidatePercentileResponse>> getPercentile(
HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
companyService.assertStudentRole(userId);
CandidatePercentileResponse percentile = scoreCardService.getPercentile(userId);
return ResponseEntity.ok(ApiResponse.ok(percentile, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(percentile, requestIdAccessor.get(request)));
}
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");
}
}

34
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java

@ -13,8 +13,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
/**
@ -26,56 +24,50 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class CandidateScoringController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CandidateScoringEngineService scoringEngineService;
private final CompanyService companyService;
@Operation(summary = "综合评分卡", description = "仅 STUDENT;返回本人 8 维评分、综合分、权重与状态")
@GetMapping("/profile/me/candidate-scoring")
public ResponseEntity<ApiResponse<CandidateScoringOverviewResponse>> getOverview(HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
companyService.assertStudentRole(userId);
CandidateScoringOverviewResponse data = scoringEngineService.getOverview(userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "单维度明细", description = "仅 STUDENT;返回某维度分项、归因与数据源;非法 dimension 返回 400")
@GetMapping("/profile/me/candidate-scoring/dimensions/{dimension}")
public ResponseEntity<ApiResponse<DimensionScoreDetailResponse>> getDimensionDetail(
@PathVariable String dimension, HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
companyService.assertStudentRole(userId);
DimensionScoreDetailResponse data = scoringEngineService.getDimensionDetail(userId, dimension);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "评分趋势", description = "仅 STUDENT;基于快照按月返回综合分与维度分(months 1-24)")
@GetMapping("/profile/me/candidate-scoring/trend")
public ResponseEntity<ApiResponse<CandidateScoringTrendResponse>> getTrend(
@RequestParam(defaultValue = "6") int months, HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
companyService.assertStudentRole(userId);
CandidateScoringTrendResponse data = scoringEngineService.getTrend(userId, months);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "企业候选人评分卡", description = "仅岗位所属企业;只读,仅返回 applicationId/overallScore/status/dimensions/generatedAt/rulesVersion,不泄露候选人敏感信息")
@GetMapping("/company/candidates/{applicationId}/scoring")
public ResponseEntity<ApiResponse<CompanyCandidateScoringResponse>> getCompanyScoring(
@PathVariable Long applicationId, HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
CompanyCandidateScoringResponse data = scoringEngineService.getCompanyScoring(userId, applicationId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
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");
}
}

46
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java

@ -9,8 +9,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
/**
@ -21,14 +19,19 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class CompanyScoringDashboardController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CompanyScoringDashboardService dashboardService;
@Operation(summary = "企业评分运营概览", description = "仅 COMPANY;只返回本企业数据")
@GetMapping("/company/scoring/dashboard/overview")
public ResponseEntity<ApiResponse<DashboardOverviewResponse>> getOverview(HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getOverview(userId), getRequestId(request)));
dashboardService.getOverview(userId), requestIdAccessor.get(request)));
}
@Operation(summary = "岗位评分排名", description = "仅 COMPANY;avgRating DESC, jobId DESC;审核通过评价")
@ -37,18 +40,18 @@ public class CompanyScoringDashboardController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getJobRankings(userId, page, size), getRequestId(request)));
dashboardService.getJobRankings(userId, page, size), requestIdAccessor.get(request)));
}
@Operation(summary = "单岗位评分排名", description = "仅 COMPANY;跨企业 403/8004")
@GetMapping("/company/scoring/dashboard/job-rankings/{jobId}")
public ResponseEntity<ApiResponse<JobRankingItem>> getJobRanking(
@PathVariable Long jobId, HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getJobRanking(userId, jobId), getRequestId(request)));
dashboardService.getJobRanking(userId, jobId), requestIdAccessor.get(request)));
}
@Operation(summary = "候选人评分排名", description = "仅 COMPANY;按学生去重;overallScore DESC, applicationId DESC")
@ -57,46 +60,35 @@ public class CompanyScoringDashboardController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getCandidateRankings(userId, page, size), getRequestId(request)));
dashboardService.getCandidateRankings(userId, page, size), requestIdAccessor.get(request)));
}
@Operation(summary = "单候选人评分排名明细", description = "仅 COMPANY;跨企业 403/8004")
@GetMapping("/company/scoring/dashboard/candidate-rankings/{applicationId}")
public ResponseEntity<ApiResponse<CandidateRankingDetailResponse>> getCandidateRanking(
@PathVariable Long applicationId, HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getCandidateRanking(userId, applicationId), getRequestId(request)));
dashboardService.getCandidateRanking(userId, applicationId), requestIdAccessor.get(request)));
}
@Operation(summary = "企业评分趋势", description = "仅 COMPANY;months 1–24")
@GetMapping("/company/scoring/dashboard/trend")
public ResponseEntity<ApiResponse<ScoreTrendResponse>> getTrend(
@RequestParam(defaultValue = "6") int months, HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getTrend(userId, months), getRequestId(request)));
dashboardService.getTrend(userId, months), requestIdAccessor.get(request)));
}
@Operation(summary = "全局运营概览", description = "仅 ADMIN")
@GetMapping("/admin/scoring/dashboard/overview")
public ResponseEntity<ApiResponse<AdminDashboardOverviewResponse>> getAdminOverview(HttpServletRequest request) {
Long userId = resolveUserId();
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getAdminOverview(userId), getRequestId(request)));
dashboardService.getAdminOverview(userId), requestIdAccessor.get(request)));
}
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");
}
}

37
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

@ -12,8 +12,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;
@ -25,6 +23,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class CompanyController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CompanyService companyService;
// ── 创建 ──────────────────────────────────────────────────
@ -34,9 +37,9 @@ public class CompanyController {
public ResponseEntity<ApiResponse<Map<String, Object>>> createMyCompany(
@Valid @RequestBody CompanyCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Company company = companyService.createCompany(userId, req);
return ResponseEntity.ok(ApiResponse.ok(companyService.toView(company), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(companyService.toView(company), requestIdAccessor.get(request)));
}
// ── 查询 ──────────────────────────────────────────────────
@ -44,13 +47,13 @@ public class CompanyController {
@Operation(summary = "查询当前企业资料", description = "返回当前登录用户绑定的企业资料,未绑定企业返回 404")
@GetMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMyCompany(HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> view = companyService.toView(companyService.findCompanyByOwnerUserId(userId));
if (view == null) {
return ResponseEntity.status(404).body(
ApiResponse.error(404, "company not found", getRequestId(request)));
ApiResponse.error(404, "company not found", requestIdAccessor.get(request)));
}
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 更新 ──────────────────────────────────────────────────
@ -60,9 +63,9 @@ public class CompanyController {
public ResponseEntity<ApiResponse<Map<String, Object>>> updateMyCompany(
@Valid @RequestBody CompanyUpdateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> view = companyService.toView(companyService.updateCompany(userId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 公开端:企业详情 ────────────────────────────────────────
@ -73,7 +76,7 @@ public class CompanyController {
@PathVariable Long companyId,
HttpServletRequest request) {
CompanyPublicView view = companyService.getPublicCompany(companyId);
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}
// ── 公开端:企业岗位列表 ────────────────────────────────────
@ -93,19 +96,7 @@ public class CompanyController {
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)));
}
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");
}
}

53
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java

@ -16,8 +16,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
@ -34,6 +32,11 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class InterviewController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final InterviewService interviewService;
// ── 企业创建面试 ────────────────────────────────────────────
@ -44,10 +47,10 @@ public class InterviewController {
@PathVariable Long applicationId,
@Valid @RequestBody InterviewCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Interview interview = interviewService.createInterview(userId, applicationId, req,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), requestIdAccessor.get(request)));
}
// ── 查询某投递的面试列表 ─────────────────────────────────────
@ -57,11 +60,11 @@ public class InterviewController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> listByApplication(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Interview> interviews = interviewService.listByApplication(applicationId, userId);
List<Map<String, Object>> items = interviews.stream()
.map(interviewService::toView).collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.ok(items, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(items, requestIdAccessor.get(request)));
}
// ── 当前用户面试列表 ────────────────────────────────────────
@ -75,7 +78,7 @@ public class InterviewController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
if (size > 100) size = 100;
Page<Interview> result = interviewService.listMyInterviews(userId, status, from, to, page, size);
Map<String, Object> data = new HashMap<>();
@ -84,7 +87,7 @@ public class InterviewController {
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)));
}
// ── 查看面试详情 ────────────────────────────────────────────
@ -94,9 +97,9 @@ public class InterviewController {
public ResponseEntity<ApiResponse<Map<String, Object>>> get(
@PathVariable Long interviewId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Interview interview = interviewService.getInterview(interviewId, userId);
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), requestIdAccessor.get(request)));
}
// ── 企业修改面试 ────────────────────────────────────────────
@ -107,10 +110,10 @@ public class InterviewController {
@PathVariable Long interviewId,
@Valid @RequestBody InterviewUpdateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Interview interview = interviewService.updateInterview(userId, interviewId, req,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), requestIdAccessor.get(request)));
}
// ── 学生响应面试 ────────────────────────────────────────────
@ -121,10 +124,10 @@ public class InterviewController {
@PathVariable Long interviewId,
@Valid @RequestBody InterviewRespondRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Interview interview = interviewService.respondToInterview(userId, interviewId, req,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), requestIdAccessor.get(request)));
}
// ── 企业取消面试 ────────────────────────────────────────────
@ -135,10 +138,10 @@ public class InterviewController {
@PathVariable Long interviewId,
@Valid @RequestBody InterviewCancelRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Interview interview = interviewService.cancelInterview(userId, interviewId, req,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), requestIdAccessor.get(request)));
}
// ── 企业完成面试 ────────────────────────────────────────────
@ -149,11 +152,11 @@ public class InterviewController {
@PathVariable Long interviewId,
@RequestBody(required = false) Map<String, String> body,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
String notes = body != null ? body.get("notes") : null;
Interview interview = interviewService.completeInterview(userId, interviewId, notes,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), requestIdAccessor.get(request)));
}
// ── 状态时间线 ──────────────────────────────────────────────
@ -163,21 +166,11 @@ public class InterviewController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long interviewId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> data = interviewService.getTimeline(interviewId, userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
// ── 辅助 ────────────────────────────────────────────────────
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new BusinessException(ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

44
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java

@ -12,8 +12,6 @@ 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;
@ -25,38 +23,43 @@ import java.util.Map;
@RequiredArgsConstructor
public class MessagingController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final MessagingService messagingService;
@Operation(summary = "会话列表", description = "当前用户参与的所有会话")
@GetMapping
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> listConversations(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(messagingService.listConversations(userId), getRequestId(request)));
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(messagingService.listConversations(userId), requestIdAccessor.get(request)));
}
@Operation(summary = "会话详情", description = "查看会话信息和未读数")
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getConversation(
@PathVariable Long id, HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(messagingService.getConversation(id, userId), getRequestId(request)));
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(messagingService.getConversation(id, userId), requestIdAccessor.get(request)));
}
@Operation(summary = "创建/获取会话", description = "创建或返回与另一用户的 DIRECT 会话")
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> createConversation(
@Valid @RequestBody CreateConversationRequest req, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(
messagingService.createOrGetDirectConversation(userId, req), getRequestId(request)));
messagingService.createOrGetDirectConversation(userId, req), requestIdAccessor.get(request)));
}
@Operation(summary = "发送消息", description = "在会话中发送消息,仅成员可发送")
@PostMapping("/messages")
public ResponseEntity<ApiResponse<Map<String, Object>>> sendMessage(
@Valid @RequestBody SendMessageRequest req, HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(messagingService.sendMessage(userId, req), getRequestId(request)));
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(messagingService.sendMessage(userId, req), requestIdAccessor.get(request)));
}
@Operation(summary = "消息列表", description = "分页查询会话消息,倒序;仅成员可查看")
@ -66,18 +69,18 @@ public class MessagingController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
MessagePageResponse result = messagingService.listMessages(id, userId, page, size);
return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request)));
}
@Operation(summary = "标记已读", description = "将当前会话所有消息标记为已读")
@PostMapping("/{id}/read")
public ResponseEntity<ApiResponse<Map<String, Object>>> markRead(
@PathVariable Long id, HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
messagingService.markRead(id, userId);
return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), requestIdAccessor.get(request)));
}
@Operation(summary = "撤回消息", description = "仅原发送者可在发送后两分钟内撤回消息;重复撤回幂等返回当前状态")
@ -86,19 +89,10 @@ public class MessagingController {
@PathVariable Long conversationId,
@PathVariable Long messageId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
MessageResponse response = messagingService.recallMessage(
userId, conversationId, messageId, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
private String getRequestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); }
}

69
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java

@ -15,8 +15,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;
@ -34,6 +32,11 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class OfferController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final OfferService offerService;
@Operation(summary = "创建 Offer", description = "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT")
@ -41,9 +44,9 @@ public class OfferController {
public ResponseEntity<ApiResponse<Map<String, Object>>> create(
@Valid @RequestBody OfferCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Offer offer = offerService.createOffer(userId, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request)));
}
@Operation(summary = "Offer 列表", description = "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页")
@ -54,7 +57,7 @@ public class OfferController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Page<Offer> result = offerService.listOffers(userId, status, applicationId, page, size);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent().stream().map(offerService::toView).collect(Collectors.toList()));
@ -62,7 +65,7 @@ public class OfferController {
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 = "Offer 详情", description = "仅目标候选人、所属企业可访问;其他账号返回无权限错误")
@ -70,9 +73,9 @@ public class OfferController {
public ResponseEntity<ApiResponse<Map<String, Object>>> get(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Offer offer = offerService.getOffer(id, userId);
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request)));
}
@Operation(summary = "修改 Offer", description = "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误")
@ -81,9 +84,9 @@ public class OfferController {
@PathVariable Long id,
@Valid @RequestBody OfferUpdateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Offer offer = offerService.updateOffer(userId, id, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request)));
}
@Operation(summary = "Offer 状态变更", description = "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等")
@ -92,9 +95,9 @@ public class OfferController {
@PathVariable Long id,
@Valid @RequestBody OfferStatusRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Offer offer = offerService.transitionStatus(userId, id, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request)));
}
@Operation(summary = "Offer 状态时间线", description = "完整状态变更历史,含操作者、原因与请求 ID")
@ -102,9 +105,9 @@ public class OfferController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> data = offerService.getTimeline(id, userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "手动提醒", description = "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建")
@ -112,14 +115,14 @@ public class OfferController {
public ResponseEntity<ApiResponse<Map<String, Object>>> remind(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
OfferReminder reminder = offerService.remind(userId, id, AuditContext.from(request));
Map<String, Object> data = new HashMap<>();
data.put("offerId", reminder.getOfferId());
data.put("reminderType", reminder.getReminderType());
data.put("status", reminder.getStatus());
data.put("sentAt", reminder.getSentAt());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
// ── Offer 进阶接口 ─────────────────────────────────────────
@ -130,9 +133,9 @@ public class OfferController {
@PathVariable Long id,
@Valid @RequestBody CounterOfferCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
OfferCounterOffer co = offerService.createCounterOffer(userId, id, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), requestIdAccessor.get(request)));
}
@Operation(summary = "还价列表", description = "候选人查看本人 Offer 的还价、企业查看本企业 Offer 的还价")
@ -140,10 +143,10 @@ public class OfferController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> listCounterOffers(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> items = offerService.getCounterOffers(id, userId)
.stream().map(offerService::toCounterOfferView).collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.ok(items, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(items, requestIdAccessor.get(request)));
}
@Operation(summary = "接受还价", description = "仅所属企业;接受后更新 Offer 薪资/入职日期并变为 ACCEPTED")
@ -152,9 +155,9 @@ public class OfferController {
@PathVariable Long id,
@PathVariable Long coId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
OfferCounterOffer co = offerService.acceptCounterOffer(userId, id, coId, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), requestIdAccessor.get(request)));
}
@Operation(summary = "拒绝还价", description = "仅所属企业;拒绝后 Offer 保持 NEGOTIATING")
@ -163,9 +166,9 @@ public class OfferController {
@PathVariable Long id,
@PathVariable Long coId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
OfferCounterOffer co = offerService.rejectCounterOffer(userId, id, coId, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), requestIdAccessor.get(request)));
}
@Operation(summary = "Offer 比较", description = "仅候选人本人;比较 2-5 个非 DRAFT 的 Offer")
@ -173,10 +176,10 @@ public class OfferController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> compare(
@RequestParam("ids") String idsParam,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Long> ids = parseIds(idsParam);
List<Map<String, Object>> items = offerService.compareOffers(userId, ids);
return ResponseEntity.ok(ApiResponse.ok(items, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(items, requestIdAccessor.get(request)));
}
@Operation(summary = "Offer 统计", description = "仅企业;统计本企业 Offer 状态分布、接受率、平均响应时间")
@ -184,9 +187,9 @@ public class OfferController {
public ResponseEntity<ApiResponse<Map<String, Object>>> statistics(
@RequestParam(required = false, defaultValue = "6") Integer months,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> data = offerService.getStatistics(userId, months);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
/**
@ -216,14 +219,4 @@ public class OfferController {
return ids;
}
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");
}
}

123
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/A2AccessorWiringProbeTest.java

@ -0,0 +1,123 @@
package com.mydreamjob.common.r01a2;
import com.mydreamjob.application.ApplicationService;
import com.mydreamjob.candidatescore.CandidateScoringEngineService;
import com.mydreamjob.candidatescore.dashboard.CompanyScoringDashboardService;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.interview.InterviewService;
import com.mydreamjob.messaging.MessagingService;
import com.mydreamjob.offer.OfferService;
import com.mydreamjob.security.CurrentUserAccessor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
/**
* R01-A2 独立复验:**接线变异检测**。
*
* <p>本测试把 {@code CurrentUserAccessor} / {@code RequestIdAccessor} 替换为 mock,
* 从而证明 8 个 Controller <b>确实</b>调用了这两个既有 bean:</p>
* <ul>
* <li>若某 Controller 仍保留自己的身份解析(即未接线),它不会调用 mock,
* {@code verify(..., never())} 之外的断言会因状态码偏离而失败;</li>
* <li>反之,mock 抛出未登录异常时端点必须返回 401 —— 这是"调用确实发生"的直接证据。</li>
* </ul>
*
* <p>因此本测试在"删除 Accessor 调用"的变异下**会失败**,可判定实施者测试并非空转。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A2AccessorWiringProbeTest {
@Autowired private MockMvc mockMvc;
@MockBean private CurrentUserAccessor currentUserAccessor;
@MockBean private RequestIdAccessor requestIdAccessor;
@MockBean private ApplicationService applicationService;
@MockBean private CompanyService companyService;
@MockBean private MessagingService messagingService;
@MockBean private OfferService offerService;
@MockBean private InterviewService interviewService;
@MockBean private CandidateScoringEngineService candidateScoringEngineService;
@MockBean private CompanyScoringDashboardService dashboardService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final List<String> PATHS = List.of(
"/applications/me", "/companies/me", "/conversations", "/offers",
"/interviews/me", "/profile/me/candidate-scoring",
"/profile/me/candidate-score-card", "/company/scoring/dashboard/overview");
private static final UsernamePasswordAuthenticationToken LEGAL =
new UsernamePasswordAuthenticationToken("42", "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
@Test
@DisplayName("接线检测:Accessor 抛未登录时,八端点必须返回 401(证明确实经 Accessor)")
void whenAccessorThrowsUnauthenticated_endpointsReturn401() throws Exception {
when(currentUserAccessor.requireUserId())
.thenThrow(new InsufficientAuthenticationException("authentication required"));
for (String path : PATHS) {
int st = mockMvc.perform(get(path).with(authentication(LEGAL)))
.andReturn().getResponse().getStatus();
assertThat(st)
.as("path=%s 若 Controller 未接线 Accessor,此处不会是 401", path)
.isEqualTo(401);
}
// 八端点均实际调用了同一个 Accessor bean
verify(currentUserAccessor, times(PATHS.size())).requireUserId();
}
@Test
@DisplayName("接线检测:响应体 requestId 来自 Accessor bean(mock 值必须原样出现)")
void requestIdComesFromAccessorBean() throws Exception {
when(currentUserAccessor.requireUserId()).thenReturn(42L);
when(requestIdAccessor.get(any())).thenReturn("probe-from-accessor");
// 注意:响应头 X-Request-Id 由 RequestIdFilter 设置(filter chain 职责),
// 不来自 Controller;Controller 的影响体现在**响应体**的 requestId 字段。
for (String path : PATHS) {
String body = mockMvc.perform(get(path).with(authentication(LEGAL)))
.andReturn().getResponse().getContentAsString();
assertThat(body)
.as("path=%s 响应体 requestId 应来自 Accessor mock(若 Controller 直读原始头则不同)", path)
.contains("\"requestId\":\"probe-from-accessor\"");
}
Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any());
}
@Test
@DisplayName("接线检测:合法主体经 Accessor 后可达 Service")
void legalPrincipalReachesService() throws Exception {
when(currentUserAccessor.requireUserId()).thenReturn(42L);
when(requestIdAccessor.get(any())).thenReturn("probe");
mockMvc.perform(get("/conversations").with(authentication(LEGAL)))
.andReturn();
verify(messagingService).listConversations(42L);
}
}

265
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/A2IndependentVerificationTest.java

@ -0,0 +1,265 @@
package com.mydreamjob.common.r01a2;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.ApplicationService;
import com.mydreamjob.candidatescore.CandidateScoringEngineService;
import com.mydreamjob.candidatescore.dashboard.CompanyScoringDashboardService;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.interview.InterviewService;
import com.mydreamjob.messaging.MessagingService;
import com.mydreamjob.offer.OfferService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* R01-A2 独立复验(不复用实施者的测试类与断言)。
*
* <p>关键约束:对超长/溢出主体**不做整响应数字子串搜索**,而是解析 JSON 后只检查
* 业务字段(code / message),避免状态码、时间戳、requestId 中的数字造成误判。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class A2IndependentVerificationTest {
@Autowired private MockMvc mockMvc;
@Autowired private com.mydreamjob.security.CurrentUserAccessor realCurrentUserAccessor;
@Autowired private com.mydreamjob.common.logging.RequestIdAccessor realRequestIdAccessor;
@MockBean private ApplicationService applicationService;
@MockBean private CompanyService companyService;
@MockBean private MessagingService messagingService;
@MockBean private OfferService offerService;
@MockBean private InterviewService interviewService;
@MockBean private CandidateScoringEngineService candidateScoringEngineService;
@MockBean private CompanyScoringDashboardService dashboardService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final List<String> PATHS = List.of(
"/applications/me", "/companies/me", "/conversations", "/offers",
"/interviews/me", "/profile/me/candidate-scoring",
"/profile/me/candidate-score-card", "/company/scoring/dashboard/overview");
/** 七类非法/边界主体。 */
private static final List<String> PRINCIPALS = List.of(
"anonymousUser",
"",
" ",
"not-a-number",
"abc123",
"12345678901234567890", // 20 位,超出 long
"0",
"-1");
private static Authentication principal(String name) {
return new UsernamePasswordAuthenticationToken(name, "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
}
// ── 身份:非法主体一律 401、绝不 500 ──────────────────────
@Test
@DisplayName("独立复验:八端点 × 八非法主体 一律 401 且绝不 500")
void allIllegalPrincipals_return401_never500() throws Exception {
for (String path : PATHS) {
for (String p : PRINCIPALS) {
int st = mockMvc.perform(get(path).with(authentication(principal(p))))
.andReturn().getResponse().getStatus();
assertThat(st)
.as("path=%s principal='%s' 必须 401", path, p)
.isEqualTo(401);
}
}
}
@Test
@DisplayName("独立复验:响应体只含固定安全文案,不回显异常类/principal/堆栈")
void errorBody_isFixedSafeText() throws Exception {
for (String path : PATHS) {
for (String p : PRINCIPALS) {
String body = mockMvc.perform(get(path).with(authentication(principal(p))))
.andReturn().getResponse().getContentAsString();
JsonNode json = MAPPER.readTree(body);
// 解析 JSON 后仅检查业务字段
assertThat(json.path("code").asInt()).as("业务码应为 401").isEqualTo(401);
String message = json.path("message").asText();
assertThat(message).as("必须为固定安全文案").isIn("authentication required", "not authenticated");
assertThat(message).doesNotContain("Exception").doesNotContain("java.")
.doesNotContain("principal").doesNotContain("at com.mydreamjob");
assertThat(json.has("stackTrace")).isFalse();
assertThat(json.has("trace")).isFalse();
assertThat(json.has("principal")).isFalse();
// 不回显非数字标记
if (!p.isBlank()) {
assertThat(message).doesNotContain(p);
}
}
}
}
@Test
@DisplayName("独立复验:匿名 token 与 null 认证均 401")
void anonymousAndNull_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()).isEqualTo(401);
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus())
.isEqualTo(401);
}
}
@Test
@DisplayName("独立复验:合法正数主体不被身份层判为未登录")
void legalPrincipal_passesIdentityLayer() throws Exception {
for (String path : PATHS) {
int st = mockMvc.perform(get(path).with(authentication(principal("7"))))
.andReturn().getResponse().getStatus();
assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401);
}
}
// ── 角色与归属 ───────────────────────────────────────────
@Test
@DisplayName("独立复验:STUDENT 访问企业操作仍 403")
void studentOnCompanyOperation_is403() throws Exception {
mockMvc.perform(get("/company/candidates")
.header("Authorization", com.mydreamjob.auth.support.TestAuthTokens.bearer(9001L, "STUDENT")))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("独立复验:无 token 仍 401(filter chain 层,不经 Controller)")
void noToken_is401() throws Exception {
for (String path : PATHS) {
mockMvc.perform(get(path)).andExpect(status().isUnauthorized());
}
}
@Test
@DisplayName("独立复验:身份层错误不得被角色层覆盖(非法主体仍 401 而非 403)")
void identityFailureIsNotMaskedByRoleCheck() throws Exception {
// 学生/企业端点均以非法主体访问,必须保持 401
for (String p : List.of("not-a-number", "0", "-1")) {
assertThat(mockMvc.perform(get("/company/candidates").with(authentication(principal(p))))
.andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mockMvc.perform(get("/companies/me").with(authentication(principal(p))))
.andReturn().getResponse().getStatus()).isEqualTo(401);
}
}
// ── requestId:Filter 与 Accessor 直接测试 ───────────────
/** 经 public 的 RequestIdAccessor 输入原始头值,观察规范化结果(走与 Filter 相同的规则)。 */
private String normalized(String headerValue) {
var req = new org.springframework.mock.web.MockHttpServletRequest();
if (headerValue != null) {
req.addHeader("X-Request-Id", headerValue);
}
return realRequestIdAccessor.get(req);
}
@Test
@DisplayName("独立复验:requestId 合法/空白/超长/CRLF/非法字符")
void requestIdNormalization_boundaries() {
// 合法值原样保留
assertThat(normalized("abc-123_X:y.z")).isEqualTo("abc-123_X:y.z");
// 空白/缺失重新生成
assertThat(normalized(" ")).isNotBlank().isNotEqualTo(" ");
assertThat(normalized("")).isNotBlank();
assertThat(normalized(null)).isNotBlank();
// 超长规范化到 32
assertThat(normalized("x".repeat(100))).hasSize(32).isEqualTo("x".repeat(32));
// CR / LF / CRLF 不得进入结果
for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n")) {
String n = normalized(bad);
assertThat(n).as("CR/LF 必须被拒绝: %s", bad)
.doesNotContain("\r").doesNotContain("\n").isNotBlank();
}
// 非法字符整体拒绝
for (String bad : List.of("<script>", "a b", "a;b", "héllo", "a\tb")) {
String n = normalized(bad);
assertThat(n).as("非法字符必须被拒绝: %s", bad)
.doesNotContain("<").doesNotContain(">").doesNotContain(";")
.doesNotContain(" ").doesNotContain("\t").doesNotContain("é")
.isNotBlank();
}
}
@Test
@DisplayName("独立复验:Accessor 请求属性优先于原始 header")
void accessor_prefersRequestAttribute() {
var req = new org.springframework.mock.web.MockHttpServletRequest();
req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "attr-value");
req.addHeader("X-Request-Id", "header-value");
assertThat(realRequestIdAccessor.get(req)).as("属性应优先于 header").isEqualTo("attr-value");
var req2 = new org.springframework.mock.web.MockHttpServletRequest();
req2.addHeader("X-Request-Id", "only-header");
// 无属性时仍走同一规范化规则(合法值原样返回)
assertThat(realRequestIdAccessor.get(req2)).isEqualTo("only-header");
var req3 = new org.springframework.mock.web.MockHttpServletRequest();
req3.addHeader("X-Request-Id", " ");
assertThat(realRequestIdAccessor.get(req3)).isNotBlank();
}
@Test
@DisplayName("独立复验:响应头与响应体 requestId 相同,且异常字符不入响应")
void responseHeaderAndBodyShareRequestId() throws Exception {
// 注:含 CR/LF 的头在 MockMvc 客户端层即被拒绝(请求发不出),
// 故此处使用可承载但会被规范化规则拒绝的字符集。
for (String sent : List.of("r01a2-ok-1", " ", "x".repeat(80), "<script>x</script>", "a b")) {
for (String path : PATHS) {
var r = mockMvc.perform(get(path).header("X-Request-Id", sent)
.with(authentication(principal("7")))).andReturn();
String hdr = r.getResponse().getHeader("X-Request-Id");
assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull()
.doesNotContain("\r").doesNotContain("\n")
.doesNotContain("<").doesNotContain(">").doesNotContain(" ");
assertThat(hdr.length()).isLessThanOrEqualTo(32);
assertThat(r.getResponse().getHeader("Injected")).isNull();
String body = r.getResponse().getContentAsString();
assertThat(body).contains("\"requestId\":\"" + hdr + "\"");
}
}
}
@Test
@DisplayName("独立复验:真实 Accessor bean 可解析合法主体")
void realAccessor_resolvesLegalPrincipal() {
var ctx = org.springframework.security.core.context.SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(principal("42"));
var prev = org.springframework.security.core.context.SecurityContextHolder.getContext();
org.springframework.security.core.context.SecurityContextHolder.setContext(ctx);
try {
assertThat(realCurrentUserAccessor.requireUserId()).isEqualTo(42L);
} finally {
org.springframework.security.core.context.SecurityContextHolder.setContext(prev);
}
}
}

269
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java

@ -0,0 +1,269 @@
package com.mydreamjob.common.r01a2;
import com.mydreamjob.application.ApplicationService;
import com.mydreamjob.candidatescore.CandidateScoringEngineService;
import com.mydreamjob.candidatescore.dashboard.CompanyScoringDashboardService;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.interview.InterviewService;
import com.mydreamjob.messaging.MessagingService;
import com.mydreamjob.offer.OfferService;
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.data.redis.core.ValueOperations;
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 org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* R01-A2 第二批:请求身份与 requestId 收束的特征测试。
*
* <p>覆盖 8 个 Controller 的代表端点:身份只来自
* {@code CurrentUserAccessor#requireUserId()},requestId 只来自
* {@code RequestIdAccessor#get(HttpServletRequest)}。</p>
*
* <p>身份边界要求:任何非法主体统一 HTTP 401(不是 500,也不是 400),
* 响应不回显 principal,失败请求不产生业务写入。</p>
*
* <p>requestId 边界要求:规范化后的值与响应头、响应体同源;超长值被既有规则收敛;
* 空白值重新生成;恶意换行/异常字符不得进入响应。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class RequestContextMigrationIntegrationTest {
@Autowired private MockMvc mockMvc;
@MockBean private ApplicationService applicationService;
@MockBean private CompanyService companyService;
@MockBean private MessagingService messagingService;
@MockBean private OfferService offerService;
@MockBean private InterviewService interviewService;
@MockBean private CandidateScoringEngineService candidateScoringEngineService;
@MockBean private CompanyScoringDashboardService dashboardService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
/** 8 个 Controller 的一个代表端点。 */
private record Endpoint(String name, String path) {}
private static final List<Endpoint> ENDPOINTS = List.of(
new Endpoint("ApplicationController", "/applications/me"),
new Endpoint("CompanyController", "/companies/me"),
new Endpoint("MessagingController", "/conversations"),
new Endpoint("OfferController", "/offers"),
new Endpoint("InterviewController", "/interviews/me"),
new Endpoint("CandidateScoringController", "/profile/me/candidate-scoring"),
new Endpoint("CandidateScoreCardController", "/profile/me/candidate-score-card"),
new Endpoint("CompanyScoringDashboardController", "/company/scoring/dashboard/overview"));
// ── 身份边界 ─────────────────────────────────────────────
private static Authentication auth(String name) {
return new UsernamePasswordAuthenticationToken(name, "n/a",
AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN"));
}
private static RequestPostProcessor as(Authentication a) {
return authentication(a);
}
/** 非法主体集合:名称 null/空/匿名/非数字/溢出/零/负。 */
private static List<Authentication> illegalPrincipals() {
return List.of(
new UsernamePasswordAuthenticationToken("", "n/a", AuthorityUtils.NO_AUTHORITIES),
new UsernamePasswordAuthenticationToken("anonymousUser", "n/a", AuthorityUtils.NO_AUTHORITIES),
new UsernamePasswordAuthenticationToken("not-a-number", "n/a", AuthorityUtils.NO_AUTHORITIES),
new UsernamePasswordAuthenticationToken("99999999999999999999999", "n/a", AuthorityUtils.NO_AUTHORITIES),
new UsernamePasswordAuthenticationToken("0", "n/a", AuthorityUtils.NO_AUTHORITIES),
new UsernamePasswordAuthenticationToken("-5", "n/a", AuthorityUtils.NO_AUTHORITIES),
new UsernamePasswordAuthenticationToken("+42", "n/a", AuthorityUtils.NO_AUTHORITIES),
new AnonymousAuthenticationToken("key", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
}
@Test
@DisplayName("非法主体一律返回 401,且不回显 principal")
void illegalPrincipals_alwaysReturn401_withoutEchoingPrincipal() throws Exception {
for (Endpoint ep : ENDPOINTS) {
for (Authentication bad : illegalPrincipals()) {
String principal = String.valueOf(bad.getName());
var result = mockMvc.perform(get(ep.path()).with(as(bad)))
.andReturn();
int status = result.getResponse().getStatus();
String body = result.getResponse().getContentAsString();
assertThat(status)
.as("%s %s 非法主体 '%s' 必须返回 401(实际 %d)", ep.name(), ep.path(), principal, status)
.isEqualTo(401);
// 只对"有辨识度的主体名"断言不回显;单字符/纯数字主体会与 JSON 中的
// 状态码、时间戳、requestId 等合法数字碰撞,不构成回显证据。
if (principal.length() >= 3 && !principal.chars().allMatch(Character::isDigit)) {
assertThat(body)
.as("%s 响应不得回显 principal '%s'", ep.name(), principal)
.doesNotContain(principal);
}
// 两条合法拒绝路径的文案不同:匿名 token 由 SecurityFilterChain 在进入
// Controller 之前拦截(AuthenticationEntryPoint),其余非法主体由
// CurrentUserAccessor 抛出;两者都必须是固定安全文案。
assertThat(body)
.as("%s 响应必须使用固定安全文案(实际:%s)", ep.name(), body)
.satisfiesAnyOf(
b -> assertThat(b).contains("authentication required"),
b -> assertThat(b).contains("not authenticated"));
assertThat(body).doesNotContain("NumberFormatException");
assertThat(body).doesNotContain("java.lang");
assertThat(body).doesNotContain("ClassCastException");
}
}
}
@Test
@DisplayName("null Authentication 返回 401")
void nullAuthentication_returns401() throws Exception {
for (Endpoint ep : ENDPOINTS) {
mockMvc.perform(get(ep.path()))
.andExpect(status().isUnauthorized());
}
}
@Test
@DisplayName("合法数字主体不被身份层拒绝(不返回 401/500)")
void legalPrincipal_isNotRejectedByIdentityLayer() throws Exception {
// 合法数字主体必须能通过身份层;下游 @MockBean 服务未 stub 导致的 5xx
// 属测试夹具范畴,因此只断言"不被身份层拒绝(非 401)"。
for (Endpoint ep : ENDPOINTS) {
int status = mockMvc.perform(get(ep.path()).with(as(auth("42"))))
.andReturn().getResponse().getStatus();
assertThat(status)
.as("%s 合法主体不得被身份层判为未登录(实际 %d)", ep.name(), status)
.isNotEqualTo(401);
}
}
@Test
@DisplayName("失败请求不产生业务写入")
void failedRequests_produceNoBusinessWrites() throws Exception {
for (Endpoint ep : ENDPOINTS) {
mockMvc.perform(get(ep.path()).with(as(auth("not-a-number"))))
.andExpect(status().isUnauthorized());
}
// 身份解析在任何 Service 调用之前失败 → 各 Service 不应被触达
org.mockito.Mockito.verifyNoInteractions(
applicationService, companyService, messagingService, offerService,
interviewService, candidateScoringEngineService, dashboardService);
}
// ── requestId 边界 ───────────────────────────────────────
private void assertRequestIdConsistent(String path, String sent, String expectedOrNull) throws Exception {
var result = mockMvc.perform(get(path).header("X-Request-Id", sent).with(as(auth("42"))))
.andReturn();
String responseHeader = result.getResponse().getHeader("X-Request-Id");
assertThat(responseHeader).as("响应头必须带 X-Request-Id,path=%s", path).isNotNull();
if (expectedOrNull != null) {
assertThat(responseHeader).as("合法值应原样同源").isEqualTo(expectedOrNull);
}
String body = result.getResponse().getContentAsString();
String fromBody = body.replaceAll("(?s).*\"requestId\"\\s*:\\s*\"([^\"]*)\".*", "$1");
assertThat(fromBody)
.as("响应体 requestId 必须与响应头同源")
.isEqualTo(responseHeader);
}
@Test
@DisplayName("requestId:合法值原样同源(响应头 == 响应体)")
void requestId_legalValue_isEchoedConsistently() throws Exception {
for (Endpoint ep : ENDPOINTS) {
assertRequestIdConsistent(ep.path(), "r01a2-legal-0001", "r01a2-legal-0001");
}
}
@Test
@DisplayName("requestId:超长值经既有规范化收敛(<=32)且同源")
void requestId_overlongValue_isNormalizedConsistently() throws Exception {
String overlong = "a".repeat(120);
for (Endpoint ep : ENDPOINTS) {
var result = mockMvc.perform(get(ep.path()).header("X-Request-Id", overlong).with(as(auth("42"))))
.andReturn();
String header = result.getResponse().getHeader("X-Request-Id");
assertThat(header).isNotNull();
assertThat(header.length()).as("超长值必须被收敛到 32 以内").isLessThanOrEqualTo(32);
assertThat(header).isEqualTo(overlong.substring(0, 32));
String body = result.getResponse().getContentAsString();
assertThat(body).doesNotContain(overlong);
}
}
@Test
@DisplayName("requestId:空白值重新生成")
void requestId_blankValue_isRegenerated() throws Exception {
for (Endpoint ep : ENDPOINTS) {
var result = mockMvc.perform(get(ep.path()).header("X-Request-Id", " ").with(as(auth("42"))))
.andReturn();
String header = result.getResponse().getHeader("X-Request-Id");
assertThat(header).as("空白值必须重新生成").isNotBlank();
assertThat(header.trim()).isEqualTo(header);
}
}
@Test
@DisplayName("requestId:恶意换行/异常字符不得进入响应与日志")
void requestId_maliciousCharacters_neverReachResponse() throws Exception {
// 注:CR/LF 会在 MockMvc 客户端层就被拒绝(请求根本发不出去),
// 因此这里覆盖"能被 HTTP 头承载、但会被规范化规则拒绝"的字符集。
List<String> malicious = List.of(
"<script>alert(1)</script>",
"id;DROP TABLE users",
"abc\tdef",
"héllo-wörld",
"a b c");
for (Endpoint ep : ENDPOINTS) {
for (String bad : malicious) {
var result = mockMvc.perform(get(ep.path()).header("X-Request-Id", bad).with(as(auth("42"))))
.andReturn();
String header = result.getResponse().getHeader("X-Request-Id");
assertThat(header).as("必须生成合法替代值").isNotNull().isNotBlank();
assertThat(header).doesNotContain("<").doesNotContain(">")
.doesNotContain(";").doesNotContain(" ").doesNotContain("\t");
assertThat(result.getResponse().getHeader("X-Injected")).isNull();
String body = result.getResponse().getContentAsString();
assertThat(body).doesNotContain(bad);
assertThat(body).contains("\"requestId\":\"" + header + "\"");
}
}
}
@Test
@DisplayName("错误响应也携带同源 requestId")
void errorResponse_carriesSameRequestId() throws Exception {
var result = mockMvc.perform(get("/applications/me")
.header("X-Request-Id", "r01a2-err-0001")
.with(as(auth("bogus"))))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
assertThat(result.getResponse().getHeader("X-Request-Id")).isEqualTo("r01a2-err-0001");
assertThat(result.getResponse().getContentAsString()).contains("r01a2-err-0001");
}
}
Loading…
Cancel
Save