diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 73e9dab..565e019 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | -| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a322205da7cf70ffdb1a71933a690a38e12`);**R01-A3(第三批 8 个 Controller)已完成实施与独立复验,待提交**(报告 `docs/r01-a3-request-context-migration-report.md`、`docs/r01-a3-independent-verification-20260917.md`)。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 + A3 共迁移 16 个 Controller**(各 8 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A3 共删除 **16 个**私有解析方法(8 Controller × 2)。**剩余业务 Controller:身份解析 17 个、requestId 解析 21 个**,待后续批次 | +| 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),待后续批次 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-a4-independent-verification-20260917.md b/docs/r01-a4-independent-verification-20260917.md new file mode 100644 index 0000000..85f505e --- /dev/null +++ b/docs/r01-a4-independent-verification-20260917.md @@ -0,0 +1,72 @@ +# R01-A4 第四批请求上下文收束独立复验 + +复验日期:2026-09-17 + +分支:`refactor/r01-structure-performance` + +基线 HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1` + +## 结论 + +**R01-A4 独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** + +FAIL=0,SKIP=0。复验未修改生产代码,未暂存、未提交、未推送。 + +## 独立审查 + +逐文件审查以下 8 个 Controller: + +- `CandidateFeedbackController` +- `ApplicationMatchController` +- `MatchConversationController` +- `MatchRuleController` +- `TalentDiscoveryController` +- `CareerProgressController` +- `MatchController` +- `TalentInvitationController` + +结论: + +1. 身份统一由构造器注入的 `CurrentUserAccessor.requireUserId()` 获取;requestId 统一由构造器注入的 `RequestIdAccessor.get(request)` 获取。 +2. 8 个文件对 `SecurityContextHolder`、`Authentication`、`anonymousUser`、身份 `Long.valueOf/parseLong`、私有 requestId 方法、可选注入和手工创建 Accessor 的门禁扫描均为 0。 +3. 角色、资源归属、Service 入参与响应结构未被改写;V1-V45、ErrorCode、OpenAPI 文档均无差异。 + +## 独立测试 + +新增两个独立测试类,仅位于测试目录: + +- `A4IndependentVerificationTest`:3 个测试,覆盖 8 个真实 MVC 映射的非法主体、匿名主体及 requestId 归一化同源。 +- `A4AccessorWiringIndependentTest`:2 个测试,通过 mock Accessor 的异常和值传播,逐一验证 8 个 Controller 的真实接线和 Service 参数。 + +发现实施方 `A4AccessorWiringProbeTest` 的 GET 路径列表只有 7 个 Controller,遗漏了唯一使用 POST 的 `MatchConversationController`。独立测试已使用真实方法 `POST /applications/{id}/conversation` 补齐;该路径通过,没有发现生产缺陷。 + +定向命令结果:48/48,Failures=0,Errors=0,Skipped=0。 + +关联回归结果:163/163,Failures=0,Errors=0,Skipped=0。 + +## 全量验证 + +- `mvn clean verify`:BUILD SUCCESS,2243/2243,Failures=0,Errors=0,Skipped=0。 +- `python docs/verify_openapi.py`:All checks passed,176 paths / 214 operations。 +- `git diff --check`:无空白错误,仅既有 LF/CRLF 提示。 +- 暂存区:空。 +- 迁移差异:空。 +- `docs/openapi-rc.json` 差异:空。 + +全量数由实施前的 2238 增至 2243,增量恰好是本轮新增的 5 个独立测试。 + +## 全仓剩余统计纠正 + +按完整模式逐文件分类: + +- 身份解析:12 个文件,其中基础设施 3、业务 Controller 9;与实施报告一致。 +- requestId 入口:19 个文件,其中基础设施/非业务入口 6、业务 Controller 13。 + +实施报告中的“完整模式 18 个文件”少计 1 个基础设施/非业务入口;业务 Controller 剩余数 13 正确。该统计偏差不影响 A4 的 8 个目标文件全部完成迁移。 + +## Git 与边界 + +- HEAD 未改变;未暂存、未提交、未推送。 +- 本次独立复验只新增上述 2 个测试文件和本报告,未修改生产代码。 +- `GlobalExceptionHandler.java` 仍是 Windows stat 缓存造成的工作区标记,内容 blob 与 HEAD 相同,本轮未触碰。 +- 历史未跟踪文件全部保留,未删除、移动或清理。 diff --git a/docs/r01-a4-request-context-migration-report.md b/docs/r01-a4-request-context-migration-report.md new file mode 100644 index 0000000..11aac84 --- /dev/null +++ b/docs/r01-a4-request-context-migration-report.md @@ -0,0 +1,278 @@ +# R01-A4 第四批:请求身份与 requestId 收束报告 + +日期:2026-09-17 +分支:`refactor/r01-structure-performance` +HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1` +范围:**仅 8 个 Controller**(CandidateFeedbackController、ApplicationMatchController、MatchConversationController、MatchRuleController、TalentDiscoveryController、CareerProgressController、MatchController、TalentInvitationController) + +--- + +## 0. 结论 + +> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。** + +8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生漂移。 + +--- + +## 1. 基线 + +| 检查项 | 结果 | +|---|---| +| 分支 | `refactor/r01-structure-performance` ✔ | +| HEAD | `35baa8416d31e71dd2c2556877f9d2e1c93b14a1` ✔ | +| 与上游同步 | `0 / 0` ✔ | +| 暂存区 | 空 ✔ | +| `git diff --check` | 无空白错误 ✔ | +| 8 个目标文件开始前状态 | **均未改动**(无本批以外修改)✔ | +| `GlobalExceptionHandler.java` | 仍为 stat 缓存标记(内容零差异),未处理、未暂存 ✔ | +| 未跟踪文件 | 247 个,全部保护 ✔ | + +--- + +## 2. 迁移前契约表 + +| Controller | 真实 Mapping(代表端点) | 允许角色 / 归属 | 私有解析方法 | 写操作涉及表 | +|---|---|---|---|---| +| CandidateFeedbackController | `POST/GET/PUT/DELETE /applications/{applicationId}/feedback` | 企业(`assertCompanyOwner`)+ 学生可读 | `resolveUserId` + `getRequestId` | `candidate_feedback` | +| ApplicationMatchController | `PUT /applications/{id}/candidate-interest-score`、`PUT .../hr-suitability-score`、`GET /applications/{id}/match` | 学生 / 企业(Service 层) | 同上 | `application_match_records`、`application_match_events` | +| MatchConversationController | `POST /applications/{id}/conversation` | 匹配双方 | 同上 | `conversations`、`conversation_members` | +| MatchRuleController | `GET/PUT /companies/me/match-rule`、`GET/PUT /profile/me/match-rule` | 企业 / 学生各自规则 | 同上 | `company_match_rules`、`student_match_rules` | +| TalentDiscoveryController | `GET/PUT /profile/me/talent-discovery`、`GET /companies/me/jobs/{jobId}/talent-recommendations` | 学生(偏好)/ COMPANY(推荐) | `userId()` + `requestId()`(**紧凑式**) | `talent_discovery_preferences` | +| CareerProgressController | `GET /profile/me/career-progress` | STUDENT | `resolveUserId` + `getRequestId` | 只读 | +| MatchController | `GET /jobs/{jobId}/match` | STUDENT | 同上 | 只读 | +| TalentInvitationController | `POST /companies/me/jobs/{jobId}/talent-invitations`、`GET /companies/me/talent-invitations`、`GET /profile/me/talent-invitations`、`POST /profile/me/talent-invitations/{id}/decision` 等 6 个 | 企业创建 / 学生决定 / 双方读取 | `userId()` + `requestId()`(**紧凑式**) | `talent_invitations` | + +**契约要点**:8 个 Controller 的角色与资源归属判断**全部位于 Service 层**(Controller 仅解析身份后委派),因此本批迁移不涉及任何角色判断的移动。 + +**迁移前状态码**:非法/匿名主体因 `Long.valueOf(auth.getName())` 抛 `NumberFormatException` → **HTTP 500**(见 §3 实测)。 + +--- + +## 3. 修复前失败测试(TDD 证据) + +新增 `R01A4RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/`),真实 `SecurityFilterChain` + `MockMvc`。 + +**修复前实测(仅加入测试、未改生产代码):`Tests run: 9, Failures: 3`** + +| 失败测试 | 修复前实际 | +|---|---| +| `illegalPrincipals_areAlways401` | `path=/applications/1/feedback principal='anonymousUser'` → **`expected: 401 but was: 500`** | +| `conversationEndpoint_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 500`** | +| `errorBody_isSafe` | 同路径 → **500** | + +**修复后:`Tests run: 9, Failures: 0`** + +--- + +## 4. 生产修改 + +8 个 Controller 统一构造器注入(均为 `private final`): + +```java +/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ +private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; +/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ +private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; +``` + +| Controller | `requireUserId()` | `requestIdAccessor.get()` | +|---|---|---| +| CandidateFeedbackController | 4 | 4 | +| ApplicationMatchController | 3 | 3 | +| MatchConversationController | 1 | 1 | +| MatchRuleController | 4 | 4 | +| TalentDiscoveryController | 3 | 3 | +| CareerProgressController | 1 | 1 | +| MatchController | 1 | 1 | +| TalentInvitationController | 6 | 6 | + +**共删除私有解析方法 16 个**(8 Controller × 2:6× `resolveUserId` + 6× `getRequestId` + 2× `userId()` + 2× `requestId()`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import。 + +**未做**:未使用可选注入 / `@Nullable` / `ObjectProvider` / `new Accessor()`;未新增 userId 请求参数;未移动或删除角色检查与资源归属检查;未修改 Service 调用顺序、错误码或接口结构;未顺手重构紧凑代码。 + +--- + +## 5. 静态门禁 + +任务书指定模式在 8 个文件中的命中: + +| Controller | 命中 | +|---|---| +| CandidateFeedbackController | **0** | +| ApplicationMatchController | **0** | +| MatchConversationController | **0** | +| MatchRuleController | **0** | +| TalentDiscoveryController | **0** | +| CareerProgressController | **0** | +| MatchController | **0** | +| TalentInvitationController | **0** | + +**全部为 0,无业务参数解析误报。身份/requestId 私有解析残留为 0。** + +### 全仓剩余统计 + +**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`) + +| | A3 后 | A4 后 | 预期 | +|---|---|---|---| +| 全仓文件 | 20 | **12** | 12 ✔ | +| —— 基础设施 | 3 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | — | +| —— **业务 Controller** | 17 | **9** | 9 ✔ | +| —— 误报 | 0 | **0** | — | + +**requestId 解析**(`request.getHeader("X-Request-Id")` \| `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` \| `String (getRequestId|resolveRequestId|requestId)(`) + +| | A3 后 | A4 后 | 预期 | +|---|---|---|---| +| 全仓文件(**本轮完整模式**) | 25 | **18** | 17 | +| 全仓文件(**A3 口径**,不含 `REQUEST_ID_HEADER` 项) | 25 | **17** | 17 ✔ | +| —— 基础设施(完整模式) | 4 | **5** | — | +| —— **业务 Controller** | 21 | **13** | 13 ✔ | +| —— Service / 非请求入口 | 0 | **0** | — | +| —— 误报 | 0 | **0** | — | + +**数量差异的逐文件解释(不修改代码迁就数字)**:本轮模式比 A3 口径多含 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` 一项,该模式额外命中的**唯一文件**是 `common/logging/RequestIdAccessor.java`(第 35 行的兜底读取 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)`)—— 这是 **Accessor 自身的合法实现**,属基础设施,非业务入口。因此:完整模式 18 = 基础设施 5 + 业务 13;A3 口径 17 = 基础设施 4 + 业务 13。**业务 Controller 数量与预期完全一致(13)**。 + +--- + +## 6. 角色与副作用验证 + +### 6.1 既有测试回归(角色/归属语义证据) + +| 命令 | 结果 | +|---|---| +| A4 关联回归(`*CandidateFeedback*Test,*ApplicationMatch*Test,*MatchConversation*Test,*MatchRule*Test,*TalentDiscovery*Test,*CareerProgress*Test,*MatchController*Test,*TalentInvitation*Test,*RequestContext*Test,*RequestId*Test`) | **163 / 163**,F=0 E=0 S=0,BUILD SUCCESS | + +既有测试覆盖:反馈仅企业可写、匹配双方可见性、会话归属、匹配规则角色、人才发现 COMPANY 权限、职业进度 STUDENT 权限、邀约企业创建/学生决定/双方读取、其他企业与其他学生的 403/404。 + +### 6.2 真实 HTTP 验证 + +只读实例 PID 7648,验证后已停止。 + +| 场景 | 结果 | +|---|---| +| 无 token / 畸形 Bearer / 纯数字 Bearer × 7 端点 | 全部 **401** ✔ | +| STUDENT → `GET /companies/me/match-rule` | **403** ✔ | +| COMPANY → `GET /companies/me/match-rule` | **403**(该账号无企业资料,既有权属语义)✔ | +| STUDENT → `GET /profile/me/career-progress` | **200** ✔ | +| COMPANY → `GET /profile/me/career-progress` | **200**(该账号注册时自带 STUDENT 角色,属既有语义)✔ | +| STUDENT → `GET /profile/me/talent-discovery` | **200** ✔ | +| ADMIN → `GET /profile/me/talent-discovery` | **403** ✔ | +| STUDENT → `GET /profile/me/talent-invitations` | **200** ✔ | +| COMPANY → `GET /companies/me/talent-invitations` | **403**(无企业资料)✔ | + +### 6.3 副作用快照 + +上述**全部请求前后 22 张表逐行稳定排序的逐字段快照比对 → 全部零变化** ✔ + +- 覆盖表含 `candidate_feedback`、`application_match_records`、`application_match_events`、`conversations`、`conversation_members`、`company_match_rules`、`student_match_rules`、`talent_discovery_preferences`、`talent_invitations`、`notifications`、`messages`、`audit_logs` 等 +- **未使用** COUNT/SUM 代替内容快照;采用显式列 + 稳定 `ORDER BY` 的逐字段比较 + +--- + +## 7. 独立验证辅助测试(实施证据) + +新增 `A4AccessorWiringProbeTest`(4 例),证明接线真实存在: + +| 用例 | 断言 | +|---|---| +| `accessorThrowsUnauthenticated_endpointsReturn401` | mock Accessor 抛认证异常 → 7 端点全部 **401**(若未接线则不可能) | +| `serviceReceivesAccessorUserId` | mock 返回 `4242L` → `careerProgressService.getCareerProgress(4242L, …)`、`talentInvitationService.listCompany(4242L, …)` 收到该值 | +| `requestIdComesFromAccessorBean` | mock 的 requestId 必须出现在响应体 | +| `noToken_is401` | 无 Token → Security 链 401 | + +**删除 Accessor 调用会使本测试失败** ✔(属实施证据,不代替后续独立复验) + +--- + +## 8. requestId 同源证据 + +| 输入 | 结果 | +|---|---| +| 合法值 `a4-legal-0001` | 响应头 == 响应体 `requestId`,原样保留 ✔ | +| 空白 `" "` | 重新生成非空值,头体同源 ✔ | +| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ | +| 非法字符 ``、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格 ✔ | +| request attribute 与 header 不同 | **attribute 优先** ✔ | +| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ | + +覆盖 8 个端点(含 POST 端点)。 + +--- + +## 9. 测试数量与 OpenAPI + +| 命令 | 结果 | +|---|---| +| A4 定向(`R01A4…,A4AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **43 / 43**,F=0 E=0 **S=0** | +| A4 关联回归 | **163 / 163**,F=0 E=0 **S=0** | +| `mvn test` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0** | +| `mvn clean verify` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2225 ✔) | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | + +- 较上轮(2225)新增 **13** 个测试(9 + 4) +- **`docs/openapi-rc.json` 零修改** ✔ +- **V1–V45 零修改** ✔ +- **未新增接口、表、索引、迁移或 ErrorCode** ✔ +- **未发现新的基线测试污染**(上一轮的账号注销夹具隔离修复已生效)✔ + +--- + +## 10. 精确修改文件 + +**生产代码(8 个,全部在任务书指定清单内)** + +``` +mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java +mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java +mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java +mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java +mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java +mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java +mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java +mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java +``` + +**测试(新增 2 个)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java +``` + +**文档(1 个)** + +``` +docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行,历史章节未动 +``` + +**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、OpenAPI、既有测试、`UserRoleGuard`、"匿名 401 缺 requestId"观察项。 + +--- + +## 11. Git 状态 + +``` +分支:refactor/r01-structure-performance +HEAD:35baa8416d31e71dd2c2556877f9d2e1c93b14a1(未变) +暂存区:空 +git add -A / commit / push:均未执行 +Docker:未操作 +历史未跟踪文件:247 个完整保留 +``` + +验收临时用户(a4vstu / a4vcom)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。 + +--- + +## 12. 明确声明 + +- **未暂存、未提交、未推送。** +- 未进入 `UserRoleGuard`;未处理"匿名 401 缺 requestId"观察项。 +- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。 +- 全仓剩余身份/requestId 数量已**逐文件分类解释**,未修改代码迁就数字。 + +--- + +> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java index bb13f12..e336702 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java @@ -8,8 +8,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.Map; @@ -22,6 +20,11 @@ import java.util.Map; @RequiredArgsConstructor public class ApplicationMatchController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final BidirectionalMatchService matchService; @Operation(summary = "提交求职者岗位意愿分", description = "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改") @@ -30,10 +33,10 @@ public class ApplicationMatchController { @PathVariable Long id, @Valid @RequestBody ScoreRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ApplicationMatchRecord record = matchService.submitCandidateInterestScore( userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request))); } @Operation(summary = "提交企业 HR 适配分", description = "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改") @@ -42,10 +45,10 @@ public class ApplicationMatchController { @PathVariable Long id, @Valid @RequestBody ScoreRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); ApplicationMatchRecord record = matchService.submitHrSuitabilityScore( userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request))); } @Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID") @@ -53,8 +56,8 @@ public class ApplicationMatchController { public ResponseEntity>> getMatch( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), requestIdAccessor.get(request))); } /** 评分接口返回记录视图(脱敏,不含内部字段) */ @@ -74,15 +77,4 @@ public class ApplicationMatchController { return v; } - 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"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java index ccd8bd7..760fc7b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java @@ -6,8 +6,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.*; import java.util.Map; @@ -20,6 +18,11 @@ import java.util.Map; @RequiredArgsConstructor public class MatchConversationController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final MatchConversationService matchConversationService; @Operation(summary = "创建/获取匹配会话", description = "仅 MATCHED 双方可调用;重复调用返回同一会话") @@ -27,20 +30,9 @@ public class MatchConversationController { public ResponseEntity>> createOrGetConversation( @PathVariable Long id, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map conversation = matchConversationService.createOrGetMatchConversation(userId, id); - return ResponseEntity.ok(ApiResponse.ok(conversation, 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(conversation, requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java index b22568a..29ef858 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java @@ -9,8 +9,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.Map; @@ -23,51 +21,45 @@ import java.util.Map; @RequiredArgsConstructor public class MatchRuleController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final MatchRuleService matchRuleService; @Operation(summary = "查询企业匹配阈值", description = "未配置返回默认 60") @GetMapping("/companies/me/match-rule") public ResponseEntity>> getCompanyRule(HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getCompanyRule(userId), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getCompanyRule(userId), requestIdAccessor.get(request))); } @Operation(summary = "更新企业匹配阈值", description = "requiredCandidateScore 0-100;首次更新创建配置") @PutMapping("/companies/me/match-rule") public ResponseEntity>> updateCompanyRule( @Valid @RequestBody CompanyMatchRuleRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( matchRuleService.updateCompanyRule(userId, req.getRequiredCandidateScore()), - getRequestId(request))); + requestIdAccessor.get(request))); } @Operation(summary = "查询学生匹配阈值", description = "未配置返回默认 60") @GetMapping("/profile/me/match-rule") public ResponseEntity>> getStudentRule(HttpServletRequest request) { - Long userId = resolveUserId(request); - return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getStudentRule(userId), getRequestId(request))); + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getStudentRule(userId), requestIdAccessor.get(request))); } @Operation(summary = "更新学生匹配阈值", description = "requiredHrScore 0-100;首次更新创建配置") @PutMapping("/profile/me/match-rule") public ResponseEntity>> updateStudentRule( @Valid @RequestBody StudentMatchRuleRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok( matchRuleService.updateStudentRule(userId, req.getRequiredHrScore()), - 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()); + requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java index 891a111..9554b82 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.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.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -30,6 +28,11 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class CareerProgressController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final CareerProgressService careerProgressService; @Operation( @@ -54,20 +57,9 @@ public class CareerProgressController { description = "投递状态过滤,可选 SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN") @RequestParam(required = false) String status, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CareerProgressResponse data = careerProgressService.getCareerProgress(userId, page, size, status); - 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 BusinessException(ErrorCode.UNAUTHORIZED); - } - return Long.valueOf(auth.getName()); + return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java index 414f1b2..0c4c987 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java @@ -9,8 +9,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.Map; @@ -20,6 +18,11 @@ import java.util.Map; @RequiredArgsConstructor public class CandidateFeedbackController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final CandidateFeedbackService feedbackService; @Operation(summary = "创建候选人反馈", description = "仅岗位所属企业;同一投递只能反馈一次") @@ -28,9 +31,9 @@ public class CandidateFeedbackController { @PathVariable Long applicationId, @Valid @RequestBody CandidateFeedbackRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CandidateFeedbackResponse response = feedbackService.createFeedback(userId, applicationId, req); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation(summary = "查询候选人反馈", description = "该投递的企业或学生可查看") @@ -38,9 +41,9 @@ public class CandidateFeedbackController { public ResponseEntity> get( @PathVariable Long applicationId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CandidateFeedbackResponse response = feedbackService.getFeedback(userId, applicationId); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation(summary = "修改候选人反馈", description = "仅岗位所属企业") @@ -49,9 +52,9 @@ public class CandidateFeedbackController { @PathVariable Long applicationId, @Valid @RequestBody CandidateFeedbackRequest req, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); CandidateFeedbackResponse response = feedbackService.updateFeedback(userId, applicationId, req); - return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); } @Operation(summary = "删除候选人反馈", description = "仅岗位所属企业") @@ -59,20 +62,9 @@ public class CandidateFeedbackController { public ResponseEntity>> delete( @PathVariable Long applicationId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); feedbackService.deleteFeedback(userId, applicationId); - return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "feedback 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()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "feedback deleted"), requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java index f6e63f0..a6d0dc9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java @@ -6,8 +6,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.*; import java.util.Map; @@ -18,6 +16,11 @@ import java.util.Map; @RequiredArgsConstructor public class MatchController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final MatchService matchService; @Operation(summary = "岗位匹配诊断", description = "仅 STUDENT;返回匹配分、硬性条件、缺口和说明") @@ -25,21 +28,10 @@ public class MatchController { public ResponseEntity>> diagnose( @PathVariable Long jobId, HttpServletRequest request) { - Long userId = resolveUserId(request); + Long userId = currentUserAccessor.requireUserId(); Map result = matchService.diagnose(userId, jobId, com.mydreamjob.audit.AuditContext.from(request)); - return ResponseEntity.ok(ApiResponse.ok(result, 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(result, requestIdAccessor.get(request))); } - private String getRequestId(HttpServletRequest request) { - return request.getHeader("X-Request-Id"); - } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java index f12069c..34052a0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java @@ -16,8 +16,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -30,13 +28,18 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @Tag(name = "企业人才发现", description = "学生显式授权后,认证企业按自有岗位发现匿名候选人") public class TalentDiscoveryController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final TalentDiscoveryService service; @GetMapping("/profile/me/talent-discovery") @Operation(summary = "查询人才发现授权", security = @SecurityRequirement(name = "bearerAuth")) @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse")))) public ApiResponse getPreference(HttpServletRequest request) { - return ApiResponse.ok(service.getPreference(userId()), requestId(request)); + return ApiResponse.ok(service.getPreference(currentUserAccessor.requireUserId()), requestIdAccessor.get(request)); } @PutMapping("/profile/me/talent-discovery") @@ -44,7 +47,7 @@ public class TalentDiscoveryController { @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseTalentDiscoveryPreferenceResponse")))) public ApiResponse updatePreference( @Valid @RequestBody TalentDiscoveryPreferenceUpdateRequest body, HttpServletRequest request) { - return ApiResponse.ok(service.updatePreference(userId(), body, AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(service.updatePreference(currentUserAccessor.requireUserId(), body, AuditContext.from(request)), requestIdAccessor.get(request)); } @GetMapping("/companies/me/jobs/{jobId}/talent-recommendations") @@ -55,17 +58,7 @@ public class TalentDiscoveryController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - return ApiResponse.ok(service.recommendations(userId(), jobId, page, size), requestId(request)); - } - - private Long userId() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated() || authentication.getName() == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - try { return Long.valueOf(authentication.getName()); } - catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } + return ApiResponse.ok(service.recommendations(currentUserAccessor.requireUserId(), jobId, page, size), requestIdAccessor.get(request)); } - private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java index 55b76cd..61d42fe 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java @@ -14,14 +14,17 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @Tag(name = "企业人才邀约", description = "企业发起邀约,学生同意后建立站内会话") public class TalentInvitationController { + /** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ + private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; + /** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ + private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; + private final TalentInvitationService service; @PostMapping("/companies/me/jobs/{jobId}/talent-invitations") @@ -30,7 +33,7 @@ public class TalentInvitationController { public ApiResponse create(@PathVariable Long jobId, @Valid @RequestBody CreateTalentInvitationRequest body, HttpServletRequest request) { - return ApiResponse.ok(service.create(userId(), jobId, body, AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(service.create(currentUserAccessor.requireUserId(), jobId, body, AuditContext.from(request)), requestIdAccessor.get(request)); } @GetMapping("/companies/me/talent-invitations") @@ -39,7 +42,7 @@ public class TalentInvitationController { public ApiResponse> listCompany( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - return ApiResponse.ok(service.listCompany(userId(), page, size), requestId(request)); + return ApiResponse.ok(service.listCompany(currentUserAccessor.requireUserId(), page, size), requestIdAccessor.get(request)); } @GetMapping("/companies/me/talent-invitations/{invitationId}") @@ -47,7 +50,7 @@ public class TalentInvitationController { @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseCompanyTalentInvitationResponse")))) public ApiResponse getCompany(@PathVariable Long invitationId, HttpServletRequest request) { - return ApiResponse.ok(service.getCompany(userId(), invitationId, AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(service.getCompany(currentUserAccessor.requireUserId(), invitationId, AuditContext.from(request)), requestIdAccessor.get(request)); } @GetMapping("/profile/me/talent-invitations") @@ -56,7 +59,7 @@ public class TalentInvitationController { public ApiResponse> listStudent( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { - return ApiResponse.ok(service.listStudent(userId(), page, size), requestId(request)); + return ApiResponse.ok(service.listStudent(currentUserAccessor.requireUserId(), page, size), requestIdAccessor.get(request)); } @GetMapping("/profile/me/talent-invitations/{invitationId}") @@ -64,7 +67,7 @@ public class TalentInvitationController { @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseStudentTalentInvitationResponse")))) public ApiResponse getStudent(@PathVariable Long invitationId, HttpServletRequest request) { - return ApiResponse.ok(service.getStudent(userId(), invitationId, AuditContext.from(request)), requestId(request)); + return ApiResponse.ok(service.getStudent(currentUserAccessor.requireUserId(), invitationId, AuditContext.from(request)), requestIdAccessor.get(request)); } @PostMapping("/profile/me/talent-invitations/{invitationId}/decision") @@ -73,17 +76,7 @@ public class TalentInvitationController { public ApiResponse decide(@PathVariable Long invitationId, @Valid @RequestBody TalentInvitationDecisionRequest body, HttpServletRequest request) { - return ApiResponse.ok(service.decide(userId(), invitationId, body, AuditContext.from(request)), requestId(request)); - } - - private Long userId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() || auth.getName() == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - try { return Long.valueOf(auth.getName()); } - catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } + return ApiResponse.ok(service.decide(currentUserAccessor.requireUserId(), invitationId, body, AuditContext.from(request)), requestIdAccessor.get(request)); } - private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringIndependentTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringIndependentTest.java new file mode 100644 index 0000000..9659adf --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringIndependentTest.java @@ -0,0 +1,125 @@ +package com.mydreamjob.common.r01a4; + +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; +import com.mydreamjob.bidirectionalmatch.MatchConversationService; +import com.mydreamjob.bidirectionalmatch.MatchRuleService; +import com.mydreamjob.careerprogress.CareerProgressService; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.feedback.CandidateFeedbackService; +import com.mydreamjob.match.MatchService; +import com.mydreamjob.security.CurrentUserAccessor; +import com.mydreamjob.talentdiscovery.TalentDiscoveryService; +import com.mydreamjob.talentinvitation.TalentInvitationService; +import org.junit.jupiter.api.BeforeEach; +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.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** R01-A4 独立接线变异检测,包含实施测试遗漏的 POST MatchConversationController。 */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A4AccessorWiringIndependentTest { + + private static final UsernamePasswordAuthenticationToken LEGAL = + new UsernamePasswordAuthenticationToken("42", "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + + @Autowired private MockMvc mockMvc; + + @MockBean private CurrentUserAccessor currentUserAccessor; + @MockBean private RequestIdAccessor requestIdAccessor; + @MockBean private CandidateFeedbackService feedbackService; + @MockBean private BidirectionalMatchService bidirectionalMatchService; + @MockBean private MatchConversationService matchConversationService; + @MockBean private MatchRuleService matchRuleService; + @MockBean private TalentDiscoveryService talentDiscoveryService; + @MockBean private CareerProgressService careerProgressService; + @MockBean private MatchService matchService; + @MockBean private TalentInvitationService talentInvitationService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void resetMocks() { + Mockito.reset(currentUserAccessor, requestIdAccessor, feedbackService, bidirectionalMatchService, + matchConversationService, matchRuleService, talentDiscoveryService, careerProgressService, + matchService, talentInvitationService); + } + + @Test + @DisplayName("A4 独立接线:8/8 Controller 都调用 CurrentUserAccessor") + void allEightControllersCallCurrentUserAccessor() throws Exception { + when(currentUserAccessor.requireUserId()) + .thenThrow(new InsufficientAuthenticationException("authentication required")); + + assertThat(mockMvc.perform(get("/applications/1/feedback").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(get("/applications/1/match").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(post("/applications/1/conversation").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(get("/companies/me/match-rule").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(get("/profile/me/talent-discovery").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(get("/profile/me/career-progress").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(get("/jobs/1/match").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + assertThat(mockMvc.perform(get("/companies/me/talent-invitations").with(authentication(LEGAL))).andReturn().getResponse().getStatus()).isEqualTo(401); + + verify(currentUserAccessor, Mockito.times(8)).requireUserId(); + } + + @Test + @DisplayName("A4 独立接线:8/8 Service 收到 Accessor 返回的唯一 userId") + void allEightServicesReceiveAccessorUserId() throws Exception { + when(currentUserAccessor.requireUserId()).thenReturn(4242L); + when(requestIdAccessor.get(any())).thenReturn("a4-independent-request"); + when(matchConversationService.createOrGetMatchConversation(4242L, 1L)).thenReturn(Map.of()); + + mockMvc.perform(get("/applications/1/feedback").with(authentication(LEGAL))); + verify(feedbackService).getFeedback(4242L, 1L); + + mockMvc.perform(get("/applications/1/match").with(authentication(LEGAL))); + verify(bidirectionalMatchService).getMatchView(4242L, 1L); + + mockMvc.perform(post("/applications/1/conversation").with(authentication(LEGAL))); + verify(matchConversationService).createOrGetMatchConversation(4242L, 1L); + + mockMvc.perform(get("/companies/me/match-rule").with(authentication(LEGAL))); + verify(matchRuleService).getCompanyRule(4242L); + + mockMvc.perform(get("/profile/me/talent-discovery").with(authentication(LEGAL))); + verify(talentDiscoveryService).getPreference(4242L); + + mockMvc.perform(get("/profile/me/career-progress").with(authentication(LEGAL))); + verify(careerProgressService).getCareerProgress(eq(4242L), anyInt(), anyInt(), any()); + + mockMvc.perform(get("/jobs/1/match").with(authentication(LEGAL))); + verify(matchService).diagnose(eq(4242L), eq(1L), any()); + + mockMvc.perform(get("/companies/me/talent-invitations").with(authentication(LEGAL))); + verify(talentInvitationService).listCompany(eq(4242L), anyInt(), anyInt()); + + verify(currentUserAccessor, Mockito.times(8)).requireUserId(); + verify(requestIdAccessor, Mockito.times(8)).get(any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java new file mode 100644 index 0000000..46d8f6b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java @@ -0,0 +1,135 @@ +package com.mydreamjob.common.r01a4; + +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; +import com.mydreamjob.bidirectionalmatch.MatchConversationService; +import com.mydreamjob.bidirectionalmatch.MatchRuleService; +import com.mydreamjob.careerprogress.CareerProgressService; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.feedback.CandidateFeedbackService; +import com.mydreamjob.match.MatchService; +import com.mydreamjob.security.CurrentUserAccessor; +import com.mydreamjob.talentdiscovery.TalentDiscoveryService; +import com.mydreamjob.talentinvitation.TalentInvitationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-A4 实施证据:**接线变异检测**。 + * + *

将两个 Accessor 替换为 mock,证明 8 个 Controller 确实调用了既有 bean: + * 若某 Controller 仍保留自有解析(未接线),mock 抛未登录时它不会返回 401。 + * 因此**删除 Accessor 调用会使本测试失败**。

+ * + *

本测试属实施阶段证据,不代替后续独立复验。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A4AccessorWiringProbeTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private CurrentUserAccessor currentUserAccessor; + @MockBean private RequestIdAccessor requestIdAccessor; + + @MockBean private CandidateFeedbackService feedbackService; + @MockBean private BidirectionalMatchService bidirectionalMatchService; + @MockBean private MatchConversationService matchConversationService; + @MockBean private MatchRuleService matchRuleService; + @MockBean private TalentDiscoveryService talentDiscoveryService; + @MockBean private CareerProgressService careerProgressService; + @MockBean private MatchService matchService; + @MockBean private TalentInvitationService talentInvitationService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + private static final List PATHS = List.of( + "/applications/1/feedback", + "/applications/1/match", + "/companies/me/match-rule", + "/profile/me/talent-discovery", + "/profile/me/career-progress", + "/jobs/1/match", + "/companies/me/talent-invitations"); + + private static final UsernamePasswordAuthenticationToken LEGAL = + new UsernamePasswordAuthenticationToken("42", "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + + @Test + @DisplayName("A4 接线检测:Accessor 抛未登录时代表端点必须 401") + void accessorThrowsUnauthenticated_endpointsReturn401() throws Exception { + when(currentUserAccessor.requireUserId()) + .thenThrow(new InsufficientAuthenticationException("authentication required")); + + for (String path : PATHS) { + int st = mockMvc.perform(get(path).with(authentication(LEGAL))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s 若未接线 Accessor,不会是 401", path).isEqualTo(401); + } + verify(currentUserAccessor, times(PATHS.size())).requireUserId(); + } + + @Test + @DisplayName("A4 接线检测:Accessor 返回固定 userId 时 Service 收到该值") + void serviceReceivesAccessorUserId() throws Exception { + when(currentUserAccessor.requireUserId()).thenReturn(4242L); + when(requestIdAccessor.get(any())).thenReturn("a4-probe"); + + mockMvc.perform(get("/profile/me/career-progress") + .with(authentication(LEGAL))).andReturn(); + verify(careerProgressService).getCareerProgress(eq(4242L), anyInt(), anyInt(), any()); + + mockMvc.perform(get("/companies/me/talent-invitations") + .with(authentication(LEGAL))).andReturn(); + verify(talentInvitationService).listCompany(eq(4242L), anyInt(), anyInt()); + } + + @Test + @DisplayName("A4 接线检测:响应体 requestId 来自 Accessor mock") + void requestIdComesFromAccessorBean() throws Exception { + when(currentUserAccessor.requireUserId()).thenReturn(42L); + when(requestIdAccessor.get(any())).thenReturn("a4-probe-from-accessor"); + + for (String path : PATHS) { + String body = mockMvc.perform(get(path).with(authentication(LEGAL))) + .andReturn().getResponse().getContentAsString(); + assertThat(body).as("path=%s", path) + .contains("\"requestId\":\"a4-probe-from-accessor\""); + } + Mockito.verify(requestIdAccessor, Mockito.atLeastOnce()).get(any()); + } + + @Test + @DisplayName("A4 接线检测:无 Token 仍 401(Security 链)") + void noToken_is401() throws Exception { + for (String path : PATHS) { + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4IndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4IndependentVerificationTest.java new file mode 100644 index 0000000..dc5d985 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4IndependentVerificationTest.java @@ -0,0 +1,129 @@ +package com.mydreamjob.common.r01a4; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; +import com.mydreamjob.bidirectionalmatch.MatchConversationService; +import com.mydreamjob.bidirectionalmatch.MatchRuleService; +import com.mydreamjob.careerprogress.CareerProgressService; +import com.mydreamjob.feedback.CandidateFeedbackService; +import com.mydreamjob.match.MatchService; +import com.mydreamjob.talentdiscovery.TalentDiscoveryService; +import com.mydreamjob.talentinvitation.TalentInvitationService; +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 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.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** R01-A4 独立复验:八个 Controller 的真实 Mapping 必须全部经过统一身份与 requestId 边界。 */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class A4IndependentVerificationTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final List ILLEGAL = List.of( + "anonymousUser", "not-a-number-a4", "12345678901234567890", "0", "-1"); + + @Autowired private MockMvc mockMvc; + + @MockBean private CandidateFeedbackService feedbackService; + @MockBean private BidirectionalMatchService bidirectionalMatchService; + @MockBean private MatchConversationService matchConversationService; + @MockBean private MatchRuleService matchRuleService; + @MockBean private TalentDiscoveryService talentDiscoveryService; + @MockBean private CareerProgressService careerProgressService; + @MockBean private MatchService matchService; + @MockBean private TalentInvitationService talentInvitationService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + private record Endpoint(String controller, Function request) { } + + private static List endpoints() { + return List.of( + new Endpoint("CandidateFeedbackController", a -> get("/applications/1/feedback").with(authentication(a))), + new Endpoint("ApplicationMatchController", a -> get("/applications/1/match").with(authentication(a))), + new Endpoint("MatchConversationController", a -> post("/applications/1/conversation").with(authentication(a))), + new Endpoint("MatchRuleController", a -> get("/companies/me/match-rule").with(authentication(a))), + new Endpoint("TalentDiscoveryController", a -> get("/profile/me/talent-discovery").with(authentication(a))), + new Endpoint("CareerProgressController", a -> get("/profile/me/career-progress").with(authentication(a))), + new Endpoint("MatchController", a -> get("/jobs/1/match").with(authentication(a))), + new Endpoint("TalentInvitationController", a -> get("/companies/me/talent-invitations").with(authentication(a)))); + } + + private static Authentication principal(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + @Test + @DisplayName("A4 独立复验:8/8 真实 Mapping 对非法主体均返回安全的 401") + void allEightMappingsRejectIllegalPrincipalsWithoutLeakage() throws Exception { + assertThat(endpoints()).hasSize(8); + for (Endpoint endpoint : endpoints()) { + for (String illegal : ILLEGAL) { + 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", + "not-a-number-a4", "12345678901234567890"); + assertThat(body.has("stackTrace")).isFalse(); + assertThat(body.has("trace")).isFalse(); + } + } + } + + @Test + @DisplayName("A4 独立复验:8/8 真实 Mapping 对匿名 token 均返回 401") + void allEightMappingsRejectAnonymousToken() throws Exception { + Authentication anonymous = new AnonymousAuthenticationToken("a4-key", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + for (Endpoint endpoint : endpoints()) { + assertThat(mockMvc.perform(endpoint.request().apply(anonymous)).andReturn().getResponse().getStatus()) + .as(endpoint.controller()).isEqualTo(401); + } + } + + @Test + @DisplayName("A4 独立复验:8/8 真实 Mapping 的响应 body 与规范化响应头 requestId 同源") + void allEightMappingsUseNormalizedRequestId() throws Exception { + Authentication legal = principal("4242"); + for (Endpoint endpoint : endpoints()) { + var request = endpoint.request().apply(legal); + if (request instanceof org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder builder) { + builder.header("X-Request-Id", "a4/" + "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 + "\""); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java new file mode 100644 index 0000000..95a9468 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java @@ -0,0 +1,214 @@ +package com.mydreamjob.common.r01a4; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.bidirectionalmatch.BidirectionalMatchService; +import com.mydreamjob.bidirectionalmatch.MatchConversationService; +import com.mydreamjob.bidirectionalmatch.MatchRuleService; +import com.mydreamjob.careerprogress.CareerProgressService; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.feedback.CandidateFeedbackService; +import com.mydreamjob.match.MatchService; +import com.mydreamjob.talentdiscovery.TalentDiscoveryService; +import com.mydreamjob.talentinvitation.TalentInvitationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * R01-A4 第四批:请求身份与 requestId 收束的特征测试。 + * + *

代表端点 URL 全部取自源码真实 Mapping。身份只来自 + * {@code CurrentUserAccessor#requireUserId()},requestId 只来自 + * {@code RequestIdAccessor#get(HttpServletRequest)}。

+ * + *

对溢出/非数字主体不做整响应数字搜索;无 Token 请求由 Security 链提前返回, + * 本批只断言 401、不要求其携带 requestId。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class R01A4RequestContextMigrationIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private RequestIdAccessor requestIdAccessor; + + @MockBean private CandidateFeedbackService feedbackService; + @MockBean private BidirectionalMatchService bidirectionalMatchService; + @MockBean private MatchConversationService matchConversationService; + @MockBean private MatchRuleService matchRuleService; + @MockBean private TalentDiscoveryService talentDiscoveryService; + @MockBean private CareerProgressService careerProgressService; + @MockBean private MatchService matchService; + @MockBean private TalentInvitationService talentInvitationService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 8 个 Controller 的真实代表端点(GET,取自源码 Mapping)。 */ + private static final List PATHS = List.of( + "/applications/1/feedback", // CandidateFeedbackController + "/applications/1/match", // ApplicationMatchController + "/companies/me/match-rule", // MatchRuleController + "/profile/me/talent-discovery", // TalentDiscoveryController + "/profile/me/career-progress", // CareerProgressController + "/jobs/1/match", // MatchController + "/companies/me/talent-invitations"); // TalentInvitationController + + /** MatchConversationController 只有 POST 端点,单独以 POST 覆盖。 */ + private static final String CONVERSATION_PATH = "/applications/1/conversation"; + + private static final List ILLEGAL = List.of( + "anonymousUser", "", " ", "not-a-number", "abc123", + "12345678901234567890", "0", "-1"); + + private static Authentication principal(String name) { + return new UsernamePasswordAuthenticationToken(name, "n/a", + AuthorityUtils.createAuthorityList("ROLE_STUDENT", "ROLE_COMPANY", "ROLE_ADMIN")); + } + + private List allPaths() { + List all = new java.util.ArrayList<>(PATHS); + all.add(CONVERSATION_PATH); + return all; + } + + // ── 身份 ───────────────────────────────────────────────── + + @Test + @DisplayName("A4:八端点 × 八非法主体 一律 401(非 400/500)") + void illegalPrincipals_areAlways401() throws Exception { + for (String path : PATHS) { + for (String p : ILLEGAL) { + int st = mockMvc.perform(get(path).with(authentication(principal(p)))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s principal='%s' 必须 401", path, p).isEqualTo(401); + } + } + } + + @Test + @DisplayName("A4:POST 端点(MatchConversation)非法主体同样 401") + void conversationEndpoint_illegalPrincipals_are401() throws Exception { + for (String p : ILLEGAL) { + int st = mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .post(CONVERSATION_PATH).with(authentication(principal(p)))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("principal='%s'", p).isEqualTo(401); + } + } + + @Test + @DisplayName("A4:匿名 token 与无 Token 均 401") + void anonymousAndNoToken_are401() throws Exception { + Authentication anon = new AnonymousAuthenticationToken("k", "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + for (String path : allPaths()) { + assertThat(mockMvc.perform(get(path).with(authentication(anon))).andReturn() + .getResponse().getStatus()).as(path).isEqualTo(401); + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + } + + @Test + @DisplayName("A4:错误体安全,不回显 principal/类名/堆栈") + void errorBody_isSafe() throws Exception { + for (String path : PATHS) { + for (String p : ILLEGAL) { + String body = mockMvc.perform(get(path).with(authentication(principal(p)))) + .andReturn().getResponse().getContentAsString(); + JsonNode json = MAPPER.readTree(body); + assertThat(json.path("code").asInt()).as("path=%s", path).isEqualTo(401); + String msg = json.path("message").asText(); + assertThat(msg).isIn("authentication required", "not authenticated"); + assertThat(msg).doesNotContain("Exception").doesNotContain("java.") + .doesNotContain("principal").doesNotContain("at com.mydreamjob"); + assertThat(json.has("stackTrace")).isFalse(); + assertThat(json.has("trace")).isFalse(); + if (p.length() >= 3 && !p.chars().allMatch(Character::isDigit)) { + assertThat(msg).doesNotContain(p); + } + } + } + } + + @Test + @DisplayName("A4:溢出主体无 NumberFormatException") + void overflow_hasNoNumberFormatException() throws Exception { + for (String path : allPaths()) { + String body = mockMvc.perform(get(path) + .with(authentication(principal("12345678901234567890")))) + .andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain("NumberFormatException"); + } + } + + @Test + @DisplayName("A4:合法主体进入原角色/归属判断(不被身份层拒绝)") + void legalPrincipal_reachesRoleLayer() throws Exception { + for (String path : allPaths()) { + int st = mockMvc.perform(get(path).with(authentication(principal("7")))) + .andReturn().getResponse().getStatus(); + assertThat(st).as("path=%s 合法主体不应 401", path).isNotEqualTo(401); + } + } + + // ── requestId ──────────────────────────────────────────── + + @Test + @DisplayName("A4:requestId 合法/空白/超长/非法字符 均与响应头同源") + void requestId_consistentAcrossInputs() throws Exception { + for (String sent : List.of("a4-legal-0001", " ", "w".repeat(80), "", "a b")) { + for (String path : PATHS) { + var r = mockMvc.perform(get(path).header("X-Request-Id", sent) + .with(authentication(principal("7")))).andReturn(); + String hdr = r.getResponse().getHeader("X-Request-Id"); + assertThat(hdr).as("path=%s sent=%s", path, sent).isNotNull() + .doesNotContain("<").doesNotContain(">").doesNotContain(" ") + .doesNotContain("\r").doesNotContain("\n"); + assertThat(hdr.length()).isLessThanOrEqualTo(32); + assertThat(r.getResponse().getContentAsString()).as("path=%s", path) + .contains("\"requestId\":\"" + hdr + "\""); + } + } + } + + @Test + @DisplayName("A4:request attribute 优先于原始 header") + void requestAttributeWins() { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.setAttribute(com.mydreamjob.common.logging.RequestIdFilter.REQUEST_ID_ATTRIBUTE, "a4-attr-wins"); + req.addHeader("X-Request-Id", "a4-header-loses"); + assertThat(requestIdAccessor.get(req)).isEqualTo("a4-attr-wins"); + } + + @Test + @DisplayName("A4:CR/LF 不进入规范化结果(直接经 Accessor)") + void crlfNeverEntersValue() { + for (String bad : List.of("a\rb", "a\nb", "a\r\nb", "\r", "\n", "z\r\nInjected: 1")) { + var req = new org.springframework.mock.web.MockHttpServletRequest(); + req.addHeader("X-Request-Id", bad); + String v = requestIdAccessor.get(req); + assertThat(v).as("输入 %s", bad).isNotBlank().doesNotContain("\r").doesNotContain("\n"); + } + } +}